fix: restore verbose tool progress in chats (#76716)

* fix: restore verbose tool progress in chats

* test: fix gateway verbose mock types
This commit is contained in:
Josh Lehman
2026-05-03 07:14:39 -07:00
committed by GitHub
parent 0b9a063a5b
commit 30018bddc6
5 changed files with 193 additions and 10 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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",

View File

@@ -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";
}
};