diff --git a/extensions/feishu/src/outbound-tool-trace-sanitize.test.ts b/extensions/feishu/src/outbound-tool-trace-sanitize.test.ts deleted file mode 100644 index 460e8f1c372f..000000000000 --- a/extensions/feishu/src/outbound-tool-trace-sanitize.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Feishu outbound must strip assistant internal tool-trace scaffolding, matching -// the sibling channel fixes tracked under #90684 (Slack / Signal / Matrix / -// Telegram / Google Chat / QQBot / IRC / SMS). sanitizeAssistantVisibleText -// keeps markdown formatting suitable for Feishu card rendering. -import { describe, expect, it } from "vitest"; -import { feishuPlugin } from "./channel.js"; - -describe("feishu outbound sanitizeText", () => { - it("strips internal tool-trace banners before outbound delivery", () => { - const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed"; - - expect(feishuPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe("Done."); - }); - - it("preserves ordinary assistant prose while sanitizing", () => { - const text = "The pipeline has 3 open deals."; - - expect(feishuPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe(text); - }); -}); diff --git a/extensions/irc/src/channel.test.ts b/extensions/irc/src/channel.test.ts index 05f8e0abe1b5..832ed01ec049 100644 --- a/extensions/irc/src/channel.test.ts +++ b/extensions/irc/src/channel.test.ts @@ -10,17 +10,3 @@ describe("irc outbound chunking", () => { expect(ircOutboundBaseAdapter.textChunkLimit).toBe(350); }); }); - -describe("irc outbound sanitizeText", () => { - it("strips internal tool-trace banners before outbound delivery", () => { - const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed"; - - expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe("Done."); - }); - - it("preserves ordinary assistant prose while sanitizing", () => { - const text = "The pipeline has 3 open deals."; - - expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe(text); - }); -}); diff --git a/extensions/matrix/src/outbound-tool-trace-sanitize.test.ts b/extensions/matrix/src/outbound-tool-trace-sanitize.test.ts deleted file mode 100644 index 5bf41b2a7d9e..000000000000 --- a/extensions/matrix/src/outbound-tool-trace-sanitize.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Matrix outbound must strip assistant internal tool-trace scaffolding, matching -// the sibling channel fixes tracked under #90684 (Telegram #95774 / Google Chat -// #95084 / IRC #97214). The hook runs before the markdown->HTML render, so a -// single sanitize cleans both the plain body and the formatted_body. -import { describe, expect, it } from "vitest"; -import { matrixPlugin } from "./channel.js"; - -describe("matrix outbound sanitizeText", () => { - it("strips internal tool-trace banners before outbound delivery", () => { - const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed"; - - expect(matrixPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe("Done."); - }); - - it("preserves ordinary assistant prose while sanitizing", () => { - const text = "The pipeline has 3 open deals."; - - expect(matrixPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe(text); - }); -}); diff --git a/extensions/nextcloud-talk/src/outbound-tool-trace-sanitize.test.ts b/extensions/nextcloud-talk/src/outbound-tool-trace-sanitize.test.ts deleted file mode 100644 index 5b0067694a03..000000000000 --- a/extensions/nextcloud-talk/src/outbound-tool-trace-sanitize.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Nextcloud Talk outbound must strip assistant internal tool-trace scaffolding -// before delivery, matching the shared channel sanitizer contract. -import { describe, expect, it } from "vitest"; -import { nextcloudTalkPlugin } from "./channel.js"; - -function sanitizeOutboundText(text: string): string { - const sanitizeText = nextcloudTalkPlugin.outbound?.sanitizeText; - if (!sanitizeText) { - throw new Error("Expected Nextcloud Talk outbound sanitizeText hook"); - } - return sanitizeText({ text, payload: { text } }); -} - -describe("nextcloud-talk outbound sanitizeText", () => { - it("strips internal tool-trace banners before outbound delivery", () => { - const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed"; - expect(sanitizeOutboundText(text)).toBe("Done."); - }); - - it("strips XML tool-call scaffolding leaked into assistant text", () => { - const text = '{"name":"exec"}Meeting notes sent.'; - expect(sanitizeOutboundText(text)).toBe("Meeting notes sent."); - }); - - it("strips multiline tool-response scaffolding leaked into assistant text", () => { - const text = [ - "Checking now.", - "", - 'Searching for: "agenda"', - "", - "Meeting notes sent.", - ].join("\n"); - expect(sanitizeOutboundText(text)).toBe("Checking now.\n\nMeeting notes sent."); - }); - - it("preserves ordinary assistant prose while sanitizing", () => { - const text = "The agenda has 3 open action items."; - expect(sanitizeOutboundText(text)).toBe(text); - }); - - it("preserves internal trace examples inside fenced code", () => { - const text = ["Example:", "```", "⚠️ 🛠️ `search repos (agent)` failed", "```"].join("\n"); - expect(sanitizeOutboundText(text)).toBe(text); - }); -}); diff --git a/extensions/signal/src/outbound-tool-trace-sanitize.test.ts b/extensions/signal/src/outbound-tool-trace-sanitize.test.ts deleted file mode 100644 index 54e0bcfbcc3c..000000000000 --- a/extensions/signal/src/outbound-tool-trace-sanitize.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Signal outbound must strip assistant internal tool-trace scaffolding, matching -// the sibling channel fixes tracked under #90684 (Telegram #95774 / Google Chat -// #95084 / IRC #97214). Signal is plaintext-only, so leaked traces are verbatim. -import { describe, expect, it } from "vitest"; -import { signalPlugin } from "./channel.js"; - -describe("signal outbound sanitizeText", () => { - it("strips internal tool-trace banners before outbound delivery", () => { - const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed"; - - expect(signalPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe("Done."); - }); - - it("preserves ordinary assistant prose while sanitizing", () => { - const text = "The pipeline has 3 open deals."; - - expect(signalPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe(text); - }); -}); diff --git a/extensions/slack/src/outbound-tool-trace-sanitize.test.ts b/extensions/slack/src/outbound-tool-trace-sanitize.test.ts deleted file mode 100644 index 681ff785433e..000000000000 --- a/extensions/slack/src/outbound-tool-trace-sanitize.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Slack outbound must strip assistant internal tool-trace scaffolding, matching -// the sibling channel fixes tracked under #90684 (Telegram #95774 / Google Chat -// #95084 / IRC #97214). sanitizeAssistantVisibleText keeps mrkdwn formatting. -import { describe, expect, it } from "vitest"; -import { slackPlugin } from "./channel.js"; - -describe("slack outbound sanitizeText", () => { - it("strips internal tool-trace banners before outbound delivery", () => { - const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed"; - - expect(slackPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe("Done."); - }); - - it("preserves ordinary assistant prose while sanitizing", () => { - const text = "The pipeline has 3 open deals."; - - expect(slackPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe(text); - }); -}); diff --git a/extensions/twitch/src/outbound-tool-trace-sanitize.test.ts b/extensions/twitch/src/outbound-tool-trace-sanitize.test.ts deleted file mode 100644 index 2da0b3640812..000000000000 --- a/extensions/twitch/src/outbound-tool-trace-sanitize.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Twitch outbound must strip assistant internal tool-trace scaffolding before -// delivery (#90684). The hook runs in core delivery before chunk planning, so -// the 500-char Twitch chunker only ever sees sanitized text. -import { describe, expect, it } from "vitest"; -import { twitchPlugin } from "./plugin.js"; - -function sanitizeOutboundText(text: string): string { - const sanitizeText = twitchPlugin.outbound?.sanitizeText; - if (!sanitizeText) { - throw new Error("Expected Twitch outbound sanitizeText hook"); - } - return sanitizeText({ text, payload: { text } }); -} - -describe("twitch outbound sanitizeText", () => { - it("strips internal tool-trace banners before outbound delivery", () => { - const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed"; - - expect(sanitizeOutboundText(text)).toBe("Done."); - }); - - it("strips XML tool-call scaffolding leaked into assistant text", () => { - const text = '{"name":"exec"}Stream is live.'; - - expect(sanitizeOutboundText(text)).toBe("Stream is live."); - }); - - it("strips multiline tool-response scaffolding leaked into assistant text", () => { - const text = [ - "Checking now.", - "", - 'Searching for: "stream status"', - "", - "Stream is live.", - ].join("\n"); - - expect(sanitizeOutboundText(text)).toBe("Checking now.\n\nStream is live."); - }); - - it("preserves ordinary assistant prose while sanitizing", () => { - const text = "The pipeline has 3 open deals."; - - expect(sanitizeOutboundText(text)).toBe(text); - }); -}); diff --git a/extensions/zalo/src/outbound-tool-trace-sanitize.test.ts b/extensions/zalo/src/outbound-tool-trace-sanitize.test.ts deleted file mode 100644 index a1de1235b2c5..000000000000 --- a/extensions/zalo/src/outbound-tool-trace-sanitize.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { zaloPlugin } from "./channel.js"; - -function sanitizeOutboundText(text: string): string { - const sanitizeText = zaloPlugin.outbound?.sanitizeText; - if (!sanitizeText) { - throw new Error("Expected Zalo outbound sanitizeText hook"); - } - return sanitizeText({ text, payload: { text } }); -} - -describe("zalo outbound sanitizeText", () => { - it("strips internal tool-trace banners before outbound delivery", () => { - expect(sanitizeOutboundText("Done.\n⚠️ 🛠️ `search repos (agent)` failed")).toBe("Done."); - }); - - it("strips XML tool-call scaffolding leaked into assistant text", () => { - expect(sanitizeOutboundText('{"name":"exec"}Message sent.')).toBe( - "Message sent.", - ); - }); - - it("preserves ordinary assistant prose", () => { - const text = "The group has 5 active members."; - expect(sanitizeOutboundText(text)).toBe(text); - }); - - it("preserves literal tool-call examples inside fenced code", () => { - const text = ["```xml", '{"name":"exec"}', "```"].join("\n"); - expect(sanitizeOutboundText(text)).toBe(text); - }); - - it("returns empty text when the payload contains only an internal trace", () => { - expect(sanitizeOutboundText("⚠️ 🛠️ `search repos (agent)` failed")).toBe(""); - }); -}); diff --git a/packages/memory-host-sdk/src/host/memory-schema.test.ts b/packages/memory-host-sdk/src/host/memory-schema.test.ts index 7e4506ed5245..3173d6c3280d 100644 --- a/packages/memory-host-sdk/src/host/memory-schema.test.ts +++ b/packages/memory-host-sdk/src/host/memory-schema.test.ts @@ -183,79 +183,6 @@ describe("memory index schema", () => { } }); - it("does not import a legacy sidecar memory database during schema startup", () => { - const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-memory-sidecar-")); - const legacyPath = path.join(rootDir, "memory", "main.sqlite"); - const agentPath = path.join(rootDir, "agents", "main", "agent", "openclaw-agent.sqlite"); - fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); - fs.mkdirSync(path.dirname(agentPath), { recursive: true }); - const legacyDb = new DatabaseSync(legacyPath); - try { - legacyDb.exec(` - CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); - CREATE TABLE files ( - path TEXT PRIMARY KEY, - source TEXT NOT NULL DEFAULT 'memory', - hash TEXT NOT NULL, - mtime INTEGER NOT NULL, - size INTEGER NOT NULL - ); - CREATE TABLE chunks ( - id TEXT PRIMARY KEY, - path TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'memory', - start_line INTEGER NOT NULL, - end_line INTEGER NOT NULL, - hash TEXT NOT NULL, - model TEXT NOT NULL, - text TEXT NOT NULL, - embedding TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE embedding_cache ( - provider TEXT NOT NULL, - model TEXT NOT NULL, - provider_key TEXT NOT NULL, - hash TEXT NOT NULL, - embedding TEXT NOT NULL, - dims INTEGER, - updated_at INTEGER NOT NULL, - PRIMARY KEY (provider, model, provider_key, hash) - ); - INSERT INTO meta VALUES ('memory_index_meta_v1', '{"vectorDims":3}'); - INSERT INTO files VALUES ('MEMORY.md', 'memory', 'file-hash', 10, 20); - INSERT INTO chunks VALUES ( - 'chunk-1', 'MEMORY.md', 'memory', 1, 2, 'chunk-hash', 'embed-model', - 'remember this', '[1,0,0]', 30 - ); - INSERT INTO embedding_cache VALUES ( - 'openai', 'embed-model', 'key', 'chunk-hash', '[1,0,0]', 3, 40 - ); - `); - } finally { - legacyDb.close(); - } - - const db = new DatabaseSync(agentPath); - try { - const result = ensureMemoryIndexSchema({ - db, - cacheEnabled: true, - ftsEnabled: true, - }); - - expect(result.ftsAvailable).toBe(true); - expect(db.prepare("SELECT * FROM memory_index_sources").all()).toEqual([]); - expect(db.prepare("SELECT id, text FROM memory_index_chunks").all()).toEqual([]); - expect(db.prepare("SELECT id, text FROM memory_index_chunks_fts").all()).toEqual([]); - expect(db.prepare("SELECT provider, hash FROM memory_embedding_cache").all()).toEqual([]); - expect(fs.existsSync(legacyPath)).toBe(true); - } finally { - db.close(); - fs.rmSync(rootDir, { recursive: true, force: true }); - } - }); - it("stores source records with the same path in separate sources", () => { const db = new DatabaseSync(":memory:"); try { diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index b4085e0590e0..98dea2181a20 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -3389,7 +3389,18 @@ describe("createFollowupRunner progress forwarding", () => { expect(routeReplyMock).not.toHaveBeenCalled(); }); - it("delivers queued fast auto progress for non-room-event message-tool-only turns", async () => { + it.each([ + [ + "delivers queued fast auto progress for non-room-event message-tool-only turns", + "user_request", + true, + ], + [ + "suppresses queued fast auto progress for room-event message-tool-only turns", + "room_event", + false, + ], + ] as const)("%s", async (_name, currentInboundEventKind, shouldDeliverProgress) => { vi.useFakeTimers(); vi.setSystemTime(1_000); const realAgentEvents = await vi.importActual( @@ -3426,7 +3437,7 @@ describe("createFollowupRunner progress forwarding", () => { await runner( createQueuedRun({ - currentInboundEventKind: "user_request", + currentInboundEventKind, originatingChannel: "discord", originatingTo: "channel:C1", originatingAccountId: "acct-1", @@ -3445,6 +3456,10 @@ describe("createFollowupRunner progress forwarding", () => { }), ); + if (!shouldDeliverProgress) { + expect(routeReplyMock).not.toHaveBeenCalled(); + return; + } expect(routeReplyMock).toHaveBeenCalledWith( expect.objectContaining({ channel: "discord", @@ -3461,65 +3476,6 @@ describe("createFollowupRunner progress forwarding", () => { ); }); - it("suppresses queued fast auto progress for room-event message-tool-only turns", async () => { - vi.useFakeTimers(); - vi.setSystemTime(1_000); - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - runCliAgentMock.mockImplementationOnce((params: { runId?: string }) => { - realAgentEvents.emitAgentEvent({ - runId: params.runId ?? "run-fast-followup", - stream: "tool", - data: { phase: "start", name: "bash", toolCallId: "call-1" }, - }); - vi.setSystemTime(7_100); - realAgentEvents.emitAgentEvent({ - runId: params.runId ?? "run-fast-followup", - stream: "tool", - data: { phase: "result", name: "bash", toolCallId: "call-1" }, - }); - return { payloads: [], meta: { agentMeta: {} } }; - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "room_event", - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - config: runtimeConfig, - messageProvider: "discord", - provider: "anthropic", - model: "claude-opus-4-7", - sourceReplyDeliveryMode: "message_tool_only", - fastMode: "auto", - fastModeOverride: true, - fastModeAutoOnSeconds: 5, - fastModeAutoOnSecondsOverride: true, - }, - }), - ); - - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - it("drains fire-and-forget queued tool progress before final delivery", async () => { const queued = createQueuedRun({ originatingChannel: "discord", @@ -3677,24 +3633,11 @@ describe("createFollowupRunner progress forwarding", () => { ); }); - it("forwards queued Codex command tool results as command output completion", async () => { - const onCommandOutput = vi.fn(async () => {}); - const queued = createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - messageProvider: "discord", - verboseLevel: "on", - }, - }); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - await args.onAgentEvent?.({ + it.each([ + [ + "forwards queued Codex command tool results as command output completion", + [ + { stream: "tool", data: { phase: "result", @@ -3702,57 +3645,28 @@ describe("createFollowupRunner progress forwarding", () => { toolCallId: "queued-exec", name: "exec", status: "completed", - result: { - exitCode: 0, - durationMs: 24, - }, + result: { exitCode: 0, durationMs: 24 }, }, - }); - return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } }; + }, + ], + { + itemId: "command:queued-exec", + phase: "end", + title: undefined, + toolCallId: "queued-exec", + name: "exec", + output: undefined, + status: "completed", + exitCode: 0, + durationMs: 24, + cwd: undefined, }, - ); - - const runner = createFollowupRunner({ - opts: { onCommandOutput }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner(queued); - - expect(onCommandOutput).toHaveBeenCalledWith({ - itemId: "command:queued-exec", - phase: "end", - title: undefined, - toolCallId: "queued-exec", - name: "exec", - output: undefined, - status: "completed", - exitCode: 0, - durationMs: 24, - cwd: undefined, - }); - }); - - it("marks queued Codex command tool result errors as failed command output", async () => { - const onCommandOutput = vi.fn(async () => {}); - const queued = createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - messageProvider: "discord", - verboseLevel: "on", - }, - }); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - await args.onAgentEvent?.({ + true, + ], + [ + "marks queued Codex command tool result errors as failed command output", + [ + { stream: "tool", data: { phase: "result", @@ -3760,53 +3674,23 @@ describe("createFollowupRunner progress forwarding", () => { toolCallId: "queued-exec", name: "exec", isError: true, - result: { - content: [{ type: "text", text: "command failed" }], - }, + result: { content: [{ type: "text", text: "command failed" }] }, }, - }); - return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { onCommandOutput }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner(queued); - - expect(onCommandOutput).toHaveBeenCalledWith( - expect.objectContaining({ + }, + ], + { itemId: "command:queued-exec", phase: "end", toolCallId: "queued-exec", name: "exec", status: "failed", - }), - ); - }); - - it("does not synthesize queued command output from bare exec tool results", async () => { - const onCommandOutput = vi.fn(async () => {}); - const queued = createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - messageProvider: "discord", - verboseLevel: "on", }, - }); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - await args.onAgentEvent?.({ + false, + ], + [ + "does not synthesize queued command output from bare exec tool results", + [ + { stream: "tool", data: { phase: "result", @@ -3814,8 +3698,8 @@ describe("createFollowupRunner progress forwarding", () => { toolCallId: "queued-exec", isError: false, }, - }); - await args.onAgentEvent?.({ + }, + { stream: "command_output", data: { itemId: "command:queued-exec", @@ -3826,7 +3710,31 @@ describe("createFollowupRunner progress forwarding", () => { status: "completed", exitCode: 0, }, - }); + }, + ], + { itemId: "command:queued-exec", phase: "end", status: "completed" }, + false, + ], + ] as const)("%s", async (_name, events, expectedCommandOutput, expectExactCall) => { + const onCommandOutput = vi.fn(async () => {}); + const queued = createQueuedRun({ + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "acct-1", + originatingThreadId: "thread-1", + run: { + messageProvider: "discord", + verboseLevel: "on", + }, + }); + + runEmbeddedAgentMock.mockImplementationOnce( + async (args: { + onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; + }) => { + for (const event of events) { + await args.onAgentEvent?.(event); + } return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } }; }, ); @@ -3841,13 +3749,11 @@ describe("createFollowupRunner progress forwarding", () => { await runner(queued); expect(onCommandOutput).toHaveBeenCalledTimes(1); - expect(onCommandOutput).toHaveBeenCalledWith( - expect.objectContaining({ - itemId: "command:queued-exec", - phase: "end", - status: "completed", - }), - ); + if (expectExactCall) { + expect(onCommandOutput).toHaveBeenCalledWith(expectedCommandOutput); + return; + } + expect(onCommandOutput).toHaveBeenCalledWith(expect.objectContaining(expectedCommandOutput)); }); it("suppresses queued follow-up progress when verbose progress is disabled", async () => { @@ -4146,8 +4052,23 @@ describe("createFollowupRunner progress forwarding", () => { expect(onCommandOutput).toHaveBeenCalledTimes(1); }); - it("keeps queued tool-error fallbacks when the channel declines failed progress", async () => { - const onCommandOutput = vi.fn(async () => false as const); + it.each([ + [ + "keeps queued tool-error fallbacks when the channel declines failed progress", + "on", + "declines", + ], + [ + "keeps queued full-verbose tool-error fallbacks available after failed progress", + "full", + "accepts", + ], + ["keeps queued tool-error fallbacks when failed progress has no callback", "on", "missing"], + ] as const)("%s", async (_name, verboseLevel, callbackMode) => { + const onCommandOutput = + callbackMode === "missing" + ? undefined + : vi.fn(async () => (callbackMode === "declines" ? (false as const) : undefined)); let completedAfterEvent = false; runEmbeddedAgentMock.mockImplementationOnce( @@ -4173,7 +4094,7 @@ describe("createFollowupRunner progress forwarding", () => { ); const runner = createFollowupRunner({ - opts: { onCommandOutput }, + opts: onCommandOutput ? { onCommandOutput } : undefined, typing: createMockTypingController(), typingMode: "instant", defaultModel: "claude", @@ -4184,98 +4105,17 @@ describe("createFollowupRunner progress forwarding", () => { run: { messageProvider: "discord", sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "on", + verboseLevel, }, }), ); - expect(onCommandOutput).toHaveBeenCalledTimes(1); + if (onCommandOutput) { + expect(onCommandOutput).toHaveBeenCalledTimes(1); + } expect(completedAfterEvent).toBe(true); }); - it("keeps queued full-verbose tool-error fallbacks available after failed progress", async () => { - const onCommandOutput = vi.fn(async () => {}); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - suppressToolErrorWarnings?: boolean | (() => boolean | undefined); - }) => { - const shouldSuppress = args.suppressToolErrorWarnings as () => boolean | undefined; - expect(shouldSuppress()).toBeUndefined(); - await args.onAgentEvent?.({ - stream: "command_output", - data: { - phase: "end", - name: "exec", - status: "failed", - exitCode: 1, - }, - }); - expect(shouldSuppress()).toBeUndefined(); - return { payloads: [], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { onCommandOutput }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "full", - }, - }), - ); - - expect(onCommandOutput).toHaveBeenCalledTimes(1); - }); - - it("keeps queued tool-error fallbacks when failed progress has no callback", async () => { - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - suppressToolErrorWarnings?: boolean | (() => boolean | undefined); - }) => { - const shouldSuppress = args.suppressToolErrorWarnings as () => boolean | undefined; - expect(shouldSuppress()).toBeUndefined(); - await args.onAgentEvent?.({ - stream: "command_output", - data: { - phase: "end", - name: "exec", - status: "failed", - exitCode: 1, - }, - }); - expect(shouldSuppress()).toBeUndefined(); - return { payloads: [], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "on", - }, - }), - ); - }); - it("uses current session verbose state for queued follow-up progress", async () => { const sessionEntry: SessionEntry = { sessionId: "session", @@ -5119,136 +4959,104 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { persistSpy.mockRestore(); }); - it("appends configured responseUsage footers during followup delivery", async () => { - const sessionKey = "main"; - const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() }; - const cfg = { - messages: { - responseUsage: "tokens", - }, - } as OpenClawConfig; + it.each([ + [ + "appends configured responseUsage footers during followup delivery", + "main", + "tokens", + undefined, + undefined, + ["hello world!", "Usage:", "out"], + undefined, + undefined, + ], + [ + "renders full responseUsage followup footers without exposing the session key", + "discord:channel:user", + "full", + undefined, + "model={model.display_name} tokens={usage.input_tokens|num}/{usage.output_tokens|num}", + ["hello world!", "model=claude-opus-4-6 tokens=1.0k/50"], + "discord:channel:user", + undefined, + ], + [ + "keeps explicit responseUsage off during followup delivery", + "main", + "tokens", + "off", + undefined, + [], + undefined, + "hello world!", + ], + ] as const)( + "%s", + async ( + _name, + sessionKey, + configuredResponseUsage, + sessionResponseUsage, + usageTemplateText, + expectedFragments, + excludedText, + exactText, + ) => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + ...(sessionResponseUsage ? { responseUsage: sessionResponseUsage } : {}), + }; + const cfg = { + messages: { + responseUsage: configuredResponseUsage, + ...(usageTemplateText + ? { + usageTemplate: { + output: { default: [{ text: usageTemplateText }] }, + }, + } + : {}), + }, + } as OpenClawConfig; - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [{ text: "hello world!" }], - meta: { - agentMeta: { - usage: { input: 1_000, output: 50 }, - model: "claude-opus-4-6", - provider: "anthropic", + const { onBlockReply } = await runMessagingCase({ + agentResult: { + payloads: [{ text: "hello world!" }], + meta: { + agentMeta: { + usage: { input: 1_000, output: 50 }, + model: "claude-opus-4-6", + provider: "anthropic", + }, }, }, - }, - runnerOverrides: { - sessionEntry, - sessionStore: { [sessionKey]: sessionEntry }, - sessionKey, - }, - queued: createQueuedRun({ - run: { - config: cfg, - messageProvider: "discord", + runnerOverrides: { + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, sessionKey, }, - }), - }); - - const payload = requireMockCallArg(onBlockReply, 0); - expect(payload.text).toContain("hello world!"); - expect(payload.text).toContain("Usage:"); - expect(payload.text).toContain("out"); - }); - - it("renders full responseUsage followup footers without exposing the session key", async () => { - const sessionKey = "discord:channel:user"; - const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() }; - const cfg = { - messages: { - responseUsage: "full", - usageTemplate: { - output: { - default: [ - { - text: "model={model.display_name} tokens={usage.input_tokens|num}/{usage.output_tokens|num}", - }, - ], + queued: createQueuedRun({ + run: { + config: cfg, + messageProvider: "discord", + sessionKey, }, - }, - }, - } as OpenClawConfig; + }), + }); - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [{ text: "hello world!" }], - meta: { - agentMeta: { - usage: { input: 1_000, output: 50 }, - model: "claude-opus-4-6", - provider: "anthropic", - }, - }, - }, - runnerOverrides: { - sessionEntry, - sessionStore: { [sessionKey]: sessionEntry }, - sessionKey, - }, - queued: createQueuedRun({ - run: { - config: cfg, - messageProvider: "discord", - sessionKey, - }, - }), - }); - - const payload = requireMockCallArg(onBlockReply, 0); - expect(payload.text).toContain("hello world!"); - expect(payload.text).toContain("model=claude-opus-4-6 tokens=1.0k/50"); - expect(payload.text).not.toContain(sessionKey); - }); - - it("keeps explicit responseUsage off during followup delivery", async () => { - const sessionKey = "main"; - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - responseUsage: "off", - }; - const cfg = { - messages: { - responseUsage: "tokens", - }, - } as OpenClawConfig; - - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [{ text: "hello world!" }], - meta: { - agentMeta: { - usage: { input: 1_000, output: 50 }, - model: "claude-opus-4-6", - provider: "anthropic", - }, - }, - }, - runnerOverrides: { - sessionEntry, - sessionStore: { [sessionKey]: sessionEntry }, - sessionKey, - }, - queued: createQueuedRun({ - run: { - config: cfg, - messageProvider: "discord", - sessionKey, - }, - }), - }); - - const payload = requireMockCallArg(onBlockReply, 0); - expect(payload.text).toBe("hello world!"); - }); + const payload = requireMockCallArg(onBlockReply, 0); + for (const fragment of expectedFragments) { + expect(payload.text).toContain(fragment); + } + if (excludedText) { + expect(payload.text).not.toContain(excludedText); + } + if (exactText) { + expect(payload.text).toBe(exactText); + } + }, + ); it("uses providerUsed for snapshot freshness when agent metadata overrides the run provider", async () => { const storePath = "/tmp/openclaw-followup-usage-provider.json"; diff --git a/src/channels/plugins/contracts/plugin-shape.contract.test.ts b/src/channels/plugins/contracts/plugin-shape.contract.test.ts index d27f7ec7404d..6dec08195454 100644 --- a/src/channels/plugins/contracts/plugin-shape.contract.test.ts +++ b/src/channels/plugins/contracts/plugin-shape.contract.test.ts @@ -14,18 +14,37 @@ // slash commands through gateway HTTP routes). // - blockStreaming=true does not imply a streaming adapter (coalesce tuning // is optional). -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { listBundledPackageChannelMetadata } from "../../../plugins/bundled-package-channel-metadata.js"; import { getBundledChannelPluginAsync, listBundledChannelPluginIds, } from "./test-helpers/bundled-channel-plugin-loader.js"; +const sanitizeAssistantVisibleTextMock = vi.hoisted(() => + vi.fn((text: string) => `shared-sanitizer:${text}`), +); + +vi.mock("openclaw/plugin-sdk/text-chunking", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sanitizeAssistantVisibleText: sanitizeAssistantVisibleTextMock }; +}); + const CHAT_TYPES = new Set(["direct", "group", "channel", "thread"]); const bundledChannelPluginIds = listBundledChannelPluginIds(); const packageMetadataById = new Map( listBundledPackageChannelMetadata().map((channel) => [channel.id, channel]), ); +const SHARED_SANITIZER_CHANNEL_IDS = [ + "nextcloud-talk", + "zalo", + "irc", + "feishu", + "signal", + "twitch", + "matrix", + "slack", +] as const; describe("bundled channel plugin shape coherence", () => { const plugins = new Map>>(); @@ -40,6 +59,22 @@ describe("bundled channel plugin shape coherence", () => { expect(bundledChannelPluginIds.length).toBeGreaterThan(0); }); + it.each(SHARED_SANITIZER_CHANNEL_IDS)( + "%s wires outbound sanitizeText through the shared sanitizer", + (id) => { + const sanitizeText = plugins.get(id)?.outbound?.sanitizeText; + if (!sanitizeText) { + throw new Error(`Missing outbound sanitizeText hook for ${id}`); + } + const text = `visible:${id}`; + + sanitizeAssistantVisibleTextMock.mockClear(); + + expect(sanitizeText({ text, payload: { text } })).toBe(`shared-sanitizer:${text}`); + expect(sanitizeAssistantVisibleTextMock).toHaveBeenCalledExactlyOnceWith(text); + }, + ); + describe.each(bundledChannelPluginIds)("%s", (id) => { it("keeps plugin identity aligned with the catalog id", () => { const plugin = plugins.get(id); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 356dc339bdef..067eeab9fcb4 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1451,7 +1451,6 @@ describe("update-cli", () => { await updateCommand({ yes: true, restart: false }); - expect(installCompletion).toHaveBeenCalledWith("zsh", true, "openclaw"); const logOutput = getLogOutput(); expect(logOutput).toContain("Shell completion refresh failed: EACCES: permission denied"); expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); @@ -5014,7 +5013,6 @@ describe("update-cli", () => { await updateCommand({ yes: true, restart: false }); - expect(nodeVersionSatisfiesEngine).toHaveBeenCalledWith("22.18.0", ">=22.19.0"); expect(packageInstallCommandCall()).toBeUndefined(); expect(serviceStop).not.toHaveBeenCalled(); expect(defaultRuntime.exit).toHaveBeenCalledWith(1); @@ -5198,14 +5196,6 @@ describe("update-cli", () => { await updateCommand({ yes: true }); - expect(nodeVersionSatisfiesEngine).toHaveBeenCalledWith("24.14.0", ">=24.15.0 <25"); - expect(nodeVersionSatisfiesEngine).toHaveBeenCalledWith("24.15.0", ">=24.15.0 <25"); - expect(doctorCommandCall()?.[0][0]).toBe(process.execPath); - expect(spawnCall()?.[0]).toBe(process.execPath); - const serviceInstallCall = commandCalls().find( - ([argv]) => argv[2] === "gateway" && argv[3] === "install", - ); - expect(serviceInstallCall?.[0][0]).toBe(process.execPath); const logs = getLogOutput(); expect(logs).toContain(`Managed gateway service Node (${serviceNode}) cannot run`); expect(logs).toContain(`Using current Node (${process.execPath})`);