Skip to content

Commit 30018bd

Browse files
authored
fix: restore verbose tool progress in chats (#76716)
* fix: restore verbose tool progress in chats * test: fix gateway verbose mock types
1 parent 0b9a063 commit 30018bd

5 files changed

Lines changed: 193 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
4444
- Plugins/update: treat catalog-matched official npm updates and OpenClaw-authored externalized-bundled npm bridges as trusted official installs so launch-code plugins can update or migrate out of the bundled tree without scanner false positives. Thanks @vincentkoc.
4545
- Plugins/onboarding: fall back from ClawHub to npm only for missing package/version errors, keeping integrity and verification failures fail-closed during storepack rollout. Thanks @vincentkoc.
4646
- Control UI/Talk: fix Talk (OpenAI Realtime WebRTC) CORS failure by stripping server-side-only attribution headers (`originator`, `version`, `User-Agent`) from browser offer headers; `api.openai.com/v1/realtime/calls` only allows `authorization` and `content-type` in its CORS preflight, so forwarding these headers caused the browser SDP exchange to fail. Fixes #76435. Thanks @hclsys.
47+
- Chat delivery: make `/verbose on|full|off` changes affect subsequent tool-use chat bubbles again, including channels with draft preview tool progress enabled, while preserving one-shot verbose directives.
4748
- CLI/logs: auto-reconnect `openclaw logs --follow` on transient gateway disconnects (WebSocket close, timeout, connection drop) with bounded exponential backoff (up to 8 retries, capped at 30 s) and stderr retry warnings, while still exiting immediately on non-recoverable auth or configuration errors. Fixes #74782. (#75059) Thanks @shashank-poola.
4849
- CLI/logs: announce `--follow` recovery with a `[logs] gateway reconnected` notice once a poll succeeds after a transient outage, and emit JSON `notice` records in `--json` mode for both the retry warning and the reconnect transition, so live monitoring scripts can react to the recovery. Carries forward #75059. (#75372) Thanks @romneyda.
4950
- Plugins/onboarding: trust optional official plugin and web-search installs selected from the official catalog so npm security scanning treats them like other source-linked official install paths. Thanks @vincentkoc.

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1751,6 +1751,88 @@ describe("dispatchReplyFromConfig", () => {
17511751
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
17521752
});
17531753

1754+
it("delivers text-only tool summaries when verbose overrides preview suppression", async () => {
1755+
setNoAbort();
1756+
sessionStoreMocks.currentEntry = {
1757+
verboseLevel: "on",
1758+
};
1759+
const cfg = emptyConfig;
1760+
const dispatcher = createDispatcher();
1761+
const ctx = buildTestCtx({
1762+
Provider: "telegram",
1763+
ChatType: "direct",
1764+
SessionKey: "agent:main:main",
1765+
});
1766+
1767+
const replyResolver = async (
1768+
_ctx: MsgContext,
1769+
opts?: GetReplyOptions,
1770+
_cfg?: OpenClawConfig,
1771+
) => {
1772+
await opts?.onToolResult?.({ text: "🔧 exec: ls" });
1773+
return { text: "done" } satisfies ReplyPayload;
1774+
};
1775+
1776+
await dispatchReplyFromConfig({
1777+
ctx,
1778+
cfg,
1779+
dispatcher,
1780+
replyResolver,
1781+
replyOptions: { suppressDefaultToolProgressMessages: true },
1782+
});
1783+
1784+
expect(dispatcher.sendToolResult).toHaveBeenCalledWith({ text: "🔧 exec: ls" });
1785+
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
1786+
});
1787+
1788+
it("delivers plan and working-status progress when verbose overrides preview suppression", async () => {
1789+
setNoAbort();
1790+
sessionStoreMocks.currentEntry = {
1791+
verboseLevel: "on",
1792+
};
1793+
const cfg = emptyConfig;
1794+
const dispatcher = createDispatcher();
1795+
const ctx = buildTestCtx({
1796+
Provider: "telegram",
1797+
ChatType: "direct",
1798+
SessionKey: "agent:main:main",
1799+
});
1800+
1801+
const replyResolver = async (
1802+
_ctx: MsgContext,
1803+
opts?: GetReplyOptions,
1804+
_cfg?: OpenClawConfig,
1805+
) => {
1806+
await opts?.onPlanUpdate?.({
1807+
phase: "update",
1808+
explanation: "Inspect code.",
1809+
steps: ["Patch code"],
1810+
});
1811+
await opts?.onApprovalEvent?.({
1812+
phase: "requested",
1813+
status: "pending",
1814+
command: "pnpm test",
1815+
});
1816+
return { text: "done" } satisfies ReplyPayload;
1817+
};
1818+
1819+
await dispatchReplyFromConfig({
1820+
ctx,
1821+
cfg,
1822+
dispatcher,
1823+
replyResolver,
1824+
replyOptions: { suppressDefaultToolProgressMessages: true },
1825+
});
1826+
1827+
expect(dispatcher.sendToolResult).toHaveBeenNthCalledWith(1, {
1828+
text: "Inspect code.\n\n1. Patch code",
1829+
});
1830+
expect(dispatcher.sendToolResult).toHaveBeenNthCalledWith(2, {
1831+
text: "Working: awaiting approval: pnpm test",
1832+
});
1833+
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
1834+
});
1835+
17541836
it("still delivers media-only tool payloads when preview tool-progress suppression is enabled", async () => {
17551837
setNoAbort();
17561838
const cfg = emptyConfig;

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,6 +1192,8 @@ export async function dispatchReplyFromConfig(
11921192
});
11931193
const suppressDefaultToolProgressMessages =
11941194
params.replyOptions?.suppressDefaultToolProgressMessages === true;
1195+
const shouldSuppressDefaultToolProgressMessages = () =>
1196+
suppressDefaultToolProgressMessages && !shouldEmitVerboseProgress();
11951197
const onToolResultFromReplyOptions = params.replyOptions?.onToolResult;
11961198
const onPlanUpdateFromReplyOptions = params.replyOptions?.onPlanUpdate;
11971199
const onApprovalEventFromReplyOptions = params.replyOptions?.onApprovalEvent;
@@ -1257,7 +1259,7 @@ export async function dispatchReplyFromConfig(
12571259
if (!deliveryPayload) {
12581260
return;
12591261
}
1260-
if (suppressDefaultToolProgressMessages) {
1262+
if (shouldSuppressDefaultToolProgressMessages()) {
12611263
const hasMedia = resolveSendableOutboundReplyParts(deliveryPayload).hasMedia;
12621264
const execApproval =
12631265
deliveryPayload.channelData &&
@@ -1286,7 +1288,7 @@ export async function dispatchReplyFromConfig(
12861288
if (!suppressAutomaticSourceDelivery) {
12871289
await onPlanUpdateFromReplyOptions?.(payload);
12881290
}
1289-
if (payload.phase !== "update" || suppressDefaultToolProgressMessages) {
1291+
if (payload.phase !== "update" || shouldSuppressDefaultToolProgressMessages()) {
12901292
return;
12911293
}
12921294
await sendPlanUpdate({ explanation: payload.explanation, steps: payload.steps });
@@ -1297,7 +1299,7 @@ export async function dispatchReplyFromConfig(
12971299
if (!suppressAutomaticSourceDelivery) {
12981300
await onApprovalEventFromReplyOptions?.(payload);
12991301
}
1300-
if (payload.phase !== "requested" || suppressDefaultToolProgressMessages) {
1302+
if (payload.phase !== "requested" || shouldSuppressDefaultToolProgressMessages()) {
13011303
return;
13021304
}
13031305
const label = summarizeApprovalLabel({
@@ -1316,7 +1318,7 @@ export async function dispatchReplyFromConfig(
13161318
if (!suppressAutomaticSourceDelivery) {
13171319
await onPatchSummaryFromReplyOptions?.(payload);
13181320
}
1319-
if (payload.phase !== "end" || suppressDefaultToolProgressMessages) {
1321+
if (payload.phase !== "end" || shouldSuppressDefaultToolProgressMessages()) {
13201322
return;
13211323
}
13221324
const label = summarizePatchLabel({ summary: payload.summary, title: payload.title });

src/gateway/server-chat.agent-events.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ vi.mock("./server-chat.load-gateway-session-row.runtime.js", () => ({
2828
loadGatewaySessionRow: vi.fn(),
2929
}));
3030

31+
vi.mock("./session-utils.js", () => ({
32+
loadSessionEntry: vi.fn(() => ({
33+
cfg: {},
34+
storePath: "/tmp/sessions.json",
35+
store: {},
36+
entry: undefined,
37+
canonicalKey: "session-1",
38+
legacyKey: undefined,
39+
})),
40+
}));
41+
3142
import { getRuntimeConfig } from "../config/io.js";
3243
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
3344
import {
@@ -37,6 +48,7 @@ import {
3748
createToolEventRecipientRegistry,
3849
} from "./server-chat.js";
3950
import { loadGatewaySessionRow } from "./server-chat.load-gateway-session-row.runtime.js";
51+
import { loadSessionEntry } from "./session-utils.js";
4052

4153
describe("agent event handler", () => {
4254
beforeEach(() => {
@@ -46,6 +58,14 @@ describe("agent event handler", () => {
4658
showAlerts: true,
4759
useIndicator: true,
4860
});
61+
vi.mocked(loadSessionEntry).mockReset().mockReturnValue({
62+
cfg: {},
63+
storePath: "/tmp/sessions.json",
64+
store: {},
65+
entry: undefined,
66+
canonicalKey: "session-1",
67+
legacyKey: undefined,
68+
});
4969
vi.mocked(loadGatewaySessionRow).mockReset().mockReturnValue(null);
5070
persistGatewaySessionLifecycleEventMock.mockReset().mockResolvedValue(undefined);
5171
resetAgentRunContextForTest();
@@ -760,6 +780,79 @@ describe("agent event handler", () => {
760780
resetAgentRunContextForTest();
761781
});
762782

783+
it("uses newer session verbose state for in-flight tool events", () => {
784+
const { nodeSendToSession, handler } = createHarness({
785+
now: 1_000,
786+
resolveSessionKeyForRun: () => "session-1",
787+
});
788+
vi.mocked(loadSessionEntry).mockReturnValue({
789+
cfg: {},
790+
storePath: "/tmp/sessions.json",
791+
store: {},
792+
entry: { sessionId: "session-1", verboseLevel: "on", updatedAt: 1_500 },
793+
canonicalKey: "session-1",
794+
legacyKey: undefined,
795+
});
796+
797+
registerAgentRunContext("run-tool-toggle", {
798+
sessionKey: "session-1",
799+
verboseLevel: "off",
800+
});
801+
802+
handler({
803+
runId: "run-tool-toggle",
804+
seq: 1,
805+
stream: "tool",
806+
ts: Date.now(),
807+
data: { phase: "start", name: "read", toolCallId: "t-toggle" },
808+
});
809+
810+
const nodeToolCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent");
811+
expect(nodeToolCalls).toHaveLength(1);
812+
expect(nodeToolCalls[0]?.[2]).toEqual(
813+
expect.objectContaining({
814+
stream: "tool",
815+
data: expect.objectContaining({
816+
phase: "start",
817+
name: "read",
818+
}),
819+
}),
820+
);
821+
resetAgentRunContextForTest();
822+
});
823+
824+
it("keeps one-shot run verbose over older session state", () => {
825+
const { nodeSendToSession, handler } = createHarness({
826+
now: 2_000,
827+
resolveSessionKeyForRun: () => "session-1",
828+
});
829+
vi.mocked(loadSessionEntry).mockReturnValue({
830+
cfg: {},
831+
storePath: "/tmp/sessions.json",
832+
store: {},
833+
entry: { sessionId: "session-1", verboseLevel: "off", updatedAt: 1_500 },
834+
canonicalKey: "session-1",
835+
legacyKey: undefined,
836+
});
837+
838+
registerAgentRunContext("run-tool-inline", {
839+
sessionKey: "session-1",
840+
verboseLevel: "on",
841+
});
842+
843+
handler({
844+
runId: "run-tool-inline",
845+
seq: 1,
846+
stream: "tool",
847+
ts: Date.now(),
848+
data: { phase: "start", name: "read", toolCallId: "t-inline" },
849+
});
850+
851+
const nodeToolCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent");
852+
expect(nodeToolCalls).toHaveLength(1);
853+
resetAgentRunContextForTest();
854+
});
855+
763856
it("mirrors tool events to session subscribers so late-joining operator UIs can render them", () => {
764857
const { broadcastToConnIds, sessionEventSubscribers, handler } = createHarness({
765858
resolveSessionKeyForRun: () => "session-1",

src/gateway/server-chat.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -562,22 +562,27 @@ export function createAgentEventHandler({
562562
const resolveToolVerboseLevel = (runId: string, sessionKey?: string) => {
563563
const runContext = getAgentRunContext(runId);
564564
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
565-
if (runVerbose) {
566-
return runVerbose;
567-
}
568565
if (!sessionKey) {
569-
return "off";
566+
return runVerbose ?? "off";
570567
}
571568
try {
572569
const { cfg, entry } = loadSessionEntry(sessionKey);
573570
const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel);
574-
if (sessionVerbose) {
571+
const sessionUpdatedAt = typeof entry?.updatedAt === "number" ? entry.updatedAt : undefined;
572+
const sessionChangedAfterRunStarted =
573+
sessionUpdatedAt !== undefined &&
574+
runContext?.registeredAt !== undefined &&
575+
sessionUpdatedAt >= runContext.registeredAt;
576+
if (sessionVerbose && (!runVerbose || sessionChangedAfterRunStarted)) {
575577
return sessionVerbose;
576578
}
579+
if (runVerbose) {
580+
return runVerbose;
581+
}
577582
const defaultVerbose = normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault);
578583
return defaultVerbose ?? "off";
579584
} catch {
580-
return "off";
585+
return runVerbose ?? "off";
581586
}
582587
};
583588

0 commit comments

Comments
 (0)