From 30018bddc611c4d5cae4541806d1e9baabfb3b47 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Sun, 3 May 2026 07:14:39 -0700 Subject: [PATCH] fix: restore verbose tool progress in chats (#76716) * fix: restore verbose tool progress in chats * test: fix gateway verbose mock types --- CHANGELOG.md | 1 + .../reply/dispatch-from-config.test.ts | 82 ++++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 10 +- src/gateway/server-chat.agent-events.test.ts | 93 +++++++++++++++++++ src/gateway/server-chat.ts | 17 ++-- 5 files changed, 193 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c194523be7..1d9f972a67f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - 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. - 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. - 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. +- 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. - 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. - 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. - 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. diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 55a97bd7955..4a9898e05be 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -1751,6 +1751,88 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" }); }); + it("delivers text-only tool summaries when verbose overrides preview suppression", async () => { + setNoAbort(); + sessionStoreMocks.currentEntry = { + verboseLevel: "on", + }; + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "direct", + SessionKey: "agent:main:main", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await opts?.onToolResult?.({ text: "🔧 exec: ls" }); + return { text: "done" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher, + replyResolver, + replyOptions: { suppressDefaultToolProgressMessages: true }, + }); + + expect(dispatcher.sendToolResult).toHaveBeenCalledWith({ text: "🔧 exec: ls" }); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" }); + }); + + it("delivers plan and working-status progress when verbose overrides preview suppression", async () => { + setNoAbort(); + sessionStoreMocks.currentEntry = { + verboseLevel: "on", + }; + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "direct", + SessionKey: "agent:main:main", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await opts?.onPlanUpdate?.({ + phase: "update", + explanation: "Inspect code.", + steps: ["Patch code"], + }); + await opts?.onApprovalEvent?.({ + phase: "requested", + status: "pending", + command: "pnpm test", + }); + return { text: "done" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher, + replyResolver, + replyOptions: { suppressDefaultToolProgressMessages: true }, + }); + + expect(dispatcher.sendToolResult).toHaveBeenNthCalledWith(1, { + text: "Inspect code.\n\n1. Patch code", + }); + expect(dispatcher.sendToolResult).toHaveBeenNthCalledWith(2, { + text: "Working: awaiting approval: pnpm test", + }); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" }); + }); + it("still delivers media-only tool payloads when preview tool-progress suppression is enabled", async () => { setNoAbort(); const cfg = emptyConfig; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index f4b6adc53f8..b4dfff71f47 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1192,6 +1192,8 @@ export async function dispatchReplyFromConfig( }); const suppressDefaultToolProgressMessages = params.replyOptions?.suppressDefaultToolProgressMessages === true; + const shouldSuppressDefaultToolProgressMessages = () => + suppressDefaultToolProgressMessages && !shouldEmitVerboseProgress(); const onToolResultFromReplyOptions = params.replyOptions?.onToolResult; const onPlanUpdateFromReplyOptions = params.replyOptions?.onPlanUpdate; const onApprovalEventFromReplyOptions = params.replyOptions?.onApprovalEvent; @@ -1257,7 +1259,7 @@ export async function dispatchReplyFromConfig( if (!deliveryPayload) { return; } - if (suppressDefaultToolProgressMessages) { + if (shouldSuppressDefaultToolProgressMessages()) { const hasMedia = resolveSendableOutboundReplyParts(deliveryPayload).hasMedia; const execApproval = deliveryPayload.channelData && @@ -1286,7 +1288,7 @@ export async function dispatchReplyFromConfig( if (!suppressAutomaticSourceDelivery) { await onPlanUpdateFromReplyOptions?.(payload); } - if (payload.phase !== "update" || suppressDefaultToolProgressMessages) { + if (payload.phase !== "update" || shouldSuppressDefaultToolProgressMessages()) { return; } await sendPlanUpdate({ explanation: payload.explanation, steps: payload.steps }); @@ -1297,7 +1299,7 @@ export async function dispatchReplyFromConfig( if (!suppressAutomaticSourceDelivery) { await onApprovalEventFromReplyOptions?.(payload); } - if (payload.phase !== "requested" || suppressDefaultToolProgressMessages) { + if (payload.phase !== "requested" || shouldSuppressDefaultToolProgressMessages()) { return; } const label = summarizeApprovalLabel({ @@ -1316,7 +1318,7 @@ export async function dispatchReplyFromConfig( if (!suppressAutomaticSourceDelivery) { await onPatchSummaryFromReplyOptions?.(payload); } - if (payload.phase !== "end" || suppressDefaultToolProgressMessages) { + if (payload.phase !== "end" || shouldSuppressDefaultToolProgressMessages()) { return; } const label = summarizePatchLabel({ summary: payload.summary, title: payload.title }); diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 437bd0da421..4b70874901f 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -28,6 +28,17 @@ vi.mock("./server-chat.load-gateway-session-row.runtime.js", () => ({ loadGatewaySessionRow: vi.fn(), })); +vi.mock("./session-utils.js", () => ({ + loadSessionEntry: vi.fn(() => ({ + cfg: {}, + storePath: "/tmp/sessions.json", + store: {}, + entry: undefined, + canonicalKey: "session-1", + legacyKey: undefined, + })), +})); + import { getRuntimeConfig } from "../config/io.js"; import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; import { @@ -37,6 +48,7 @@ import { createToolEventRecipientRegistry, } from "./server-chat.js"; import { loadGatewaySessionRow } from "./server-chat.load-gateway-session-row.runtime.js"; +import { loadSessionEntry } from "./session-utils.js"; describe("agent event handler", () => { beforeEach(() => { @@ -46,6 +58,14 @@ describe("agent event handler", () => { showAlerts: true, useIndicator: true, }); + vi.mocked(loadSessionEntry).mockReset().mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + store: {}, + entry: undefined, + canonicalKey: "session-1", + legacyKey: undefined, + }); vi.mocked(loadGatewaySessionRow).mockReset().mockReturnValue(null); persistGatewaySessionLifecycleEventMock.mockReset().mockResolvedValue(undefined); resetAgentRunContextForTest(); @@ -760,6 +780,79 @@ describe("agent event handler", () => { resetAgentRunContextForTest(); }); + it("uses newer session verbose state for in-flight tool events", () => { + const { nodeSendToSession, handler } = createHarness({ + now: 1_000, + resolveSessionKeyForRun: () => "session-1", + }); + vi.mocked(loadSessionEntry).mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + store: {}, + entry: { sessionId: "session-1", verboseLevel: "on", updatedAt: 1_500 }, + canonicalKey: "session-1", + legacyKey: undefined, + }); + + registerAgentRunContext("run-tool-toggle", { + sessionKey: "session-1", + verboseLevel: "off", + }); + + handler({ + runId: "run-tool-toggle", + seq: 1, + stream: "tool", + ts: Date.now(), + data: { phase: "start", name: "read", toolCallId: "t-toggle" }, + }); + + const nodeToolCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent"); + expect(nodeToolCalls).toHaveLength(1); + expect(nodeToolCalls[0]?.[2]).toEqual( + expect.objectContaining({ + stream: "tool", + data: expect.objectContaining({ + phase: "start", + name: "read", + }), + }), + ); + resetAgentRunContextForTest(); + }); + + it("keeps one-shot run verbose over older session state", () => { + const { nodeSendToSession, handler } = createHarness({ + now: 2_000, + resolveSessionKeyForRun: () => "session-1", + }); + vi.mocked(loadSessionEntry).mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + store: {}, + entry: { sessionId: "session-1", verboseLevel: "off", updatedAt: 1_500 }, + canonicalKey: "session-1", + legacyKey: undefined, + }); + + registerAgentRunContext("run-tool-inline", { + sessionKey: "session-1", + verboseLevel: "on", + }); + + handler({ + runId: "run-tool-inline", + seq: 1, + stream: "tool", + ts: Date.now(), + data: { phase: "start", name: "read", toolCallId: "t-inline" }, + }); + + const nodeToolCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "agent"); + expect(nodeToolCalls).toHaveLength(1); + resetAgentRunContextForTest(); + }); + it("mirrors tool events to session subscribers so late-joining operator UIs can render them", () => { const { broadcastToConnIds, sessionEventSubscribers, handler } = createHarness({ resolveSessionKeyForRun: () => "session-1", diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index dcdefe54eb1..7e15951d867 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -562,22 +562,27 @@ export function createAgentEventHandler({ const resolveToolVerboseLevel = (runId: string, sessionKey?: string) => { const runContext = getAgentRunContext(runId); const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel); - if (runVerbose) { - return runVerbose; - } if (!sessionKey) { - return "off"; + return runVerbose ?? "off"; } try { const { cfg, entry } = loadSessionEntry(sessionKey); const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel); - if (sessionVerbose) { + const sessionUpdatedAt = typeof entry?.updatedAt === "number" ? entry.updatedAt : undefined; + const sessionChangedAfterRunStarted = + sessionUpdatedAt !== undefined && + runContext?.registeredAt !== undefined && + sessionUpdatedAt >= runContext.registeredAt; + if (sessionVerbose && (!runVerbose || sessionChangedAfterRunStarted)) { return sessionVerbose; } + if (runVerbose) { + return runVerbose; + } const defaultVerbose = normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault); return defaultVerbose ?? "off"; } catch { - return "off"; + return runVerbose ?? "off"; } };