mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
f78d958aca
commit
446816f751
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
Reference in New Issue
Block a user