test: table-drive followup scenarios, centralize sanitizer wiring, remove obsolete negatives (#113583)

* test(auto-reply): table-drive followup runner scenarios

* test(channels): centralize sanitizer wiring coverage

* test: remove obsolete negative coverage
This commit is contained in:
Peter Steinberger
2026-07-25 02:14:44 -07:00
committed by GitHub
parent f78d958aca
commit 446816f751
12 changed files with 233 additions and 691 deletions
@@ -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);
});
});
-14
View File
@@ -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);
});
});
@@ -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);
});
});
@@ -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 = '<tool_call>{"name":"exec"}</tool_call>Meeting notes sent.';
expect(sanitizeOutboundText(text)).toBe("Meeting notes sent.");
});
it("strips multiline tool-response scaffolding leaked into assistant text", () => {
const text = [
"Checking now.",
"<function_response>",
'Searching for: "agenda"',
"</function_response>",
"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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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 = '<tool_call>{"name":"exec"}</tool_call>Stream is live.';
expect(sanitizeOutboundText(text)).toBe("Stream is live.");
});
it("strips multiline tool-response scaffolding leaked into assistant text", () => {
const text = [
"Checking now.",
"<function_response>",
'Searching for: "stream status"',
"</function_response>",
"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);
});
});
@@ -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('<tool_call>{"name":"exec"}</tool_call>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", '<tool_call>{"name":"exec"}</tool_call>', "```"].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("");
});
});
@@ -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 {
+197 -389
View File
@@ -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<typeof import("../../infra/agent-events.js")>(
@@ -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<typeof import("../../infra/agent-events.js")>(
"../../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<string, unknown> }) => Promise<void>;
}) => {
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<string, unknown> }) => Promise<void>;
}) => {
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<string, unknown> }) => Promise<void>;
}) => {
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<string, unknown> }) => Promise<void>;
}) => {
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<string, unknown> }) => Promise<void>;
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<string, unknown> }) => Promise<void>;
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";
@@ -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<typeof import("openclaw/plugin-sdk/text-chunking")>();
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<string, Awaited<ReturnType<typeof getBundledChannelPluginAsync>>>();
@@ -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);
-10
View File
@@ -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})`);