mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user