Files
openclaw/src/plugin-sdk/tool-payload.test.ts
Jason (Json) 3ea911558c fix: promote serialized tool calls via repair package
Extracts serialized plaintext tool-call parsing, scrubbing, stream normalization, and standalone promotion into the private internal @openclaw/tool-call-repair package.

Provider wrappers and the embedded runner now share one repair path for standalone serialized tool calls, including adjacent text-block splits, while preserving exact argument bytes when already valid. The public plugin SDK payload module remains as the compatibility facade.

Verification:
- pnpm test src/plugin-sdk/provider-stream-shared.test.ts src/plugin-sdk/tool-payload.test.ts src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts -- --reporter=verbose
- env -u OPENCLAW_TESTBOX pnpm check:changed
- PR CI: all reported checks green/skipped/neutral on ff0b3c0a5c

Refs #86924

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-30 22:34:57 +01:00

364 lines
10 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
extractToolPayload,
parseStandalonePlainTextToolCallBlocks,
stripPlainTextToolCallBlocks,
type ToolPayloadCarrier,
} from "./tool-payload.js";
describe("extractToolPayload", () => {
it("returns undefined for missing results", () => {
expect(extractToolPayload(undefined)).toBeUndefined();
expect(extractToolPayload(null)).toBeUndefined();
});
it("prefers explicit details payloads", () => {
expect(
extractToolPayload({
details: { ok: true },
content: [{ type: "text", text: '{"ignored":true}' }],
}),
).toEqual({ ok: true });
});
it("parses JSON text blocks and falls back to raw text, content, or the whole result", () => {
expect(
extractToolPayload({
content: [
{ type: "image", url: "https://example.com/a.png" },
{ type: "text", text: '{"ok":true,"count":2}' },
],
}),
).toEqual({ ok: true, count: 2 });
expect(
extractToolPayload({
content: [{ type: "text", text: "not json" }],
}),
).toBe("not json");
const content = [{ type: "image", url: "https://example.com/a.png" }];
expect(
extractToolPayload({
content,
}),
).toBe(content);
const result = { status: "ok" } as ToolPayloadCarrier & { status: string };
expect(extractToolPayload(result)).toBe(result);
});
});
describe("parseStandalonePlainTextToolCallBlocks", () => {
it("parses bracketed local-model tool blocks", () => {
const raw = ["[read]", '{"path":"/tmp/file.txt","line_start":1}', "[END_TOOL_REQUEST]"].join(
"\n",
);
const blocks = parseStandalonePlainTextToolCallBlocks(raw);
expect(blocks).toEqual([
{
name: "read",
arguments: { path: "/tmp/file.txt", line_start: 1 },
start: 0,
end: raw.length,
raw,
},
]);
});
it("parses Harmony commentary tool calls", () => {
const raw = 'commentary to=read code {"path":"/path/to/file","line_start":1,"line_end":400}';
const blocks = parseStandalonePlainTextToolCallBlocks(raw);
expect(blocks).toEqual([
{
name: "read",
arguments: { path: "/path/to/file", line_start: 1, line_end: 400 },
start: 0,
end: raw.length,
raw,
},
]);
});
it("parses Harmony marker-wrapped tool calls", () => {
const raw = '<|channel|>commentary to=read code<|message|>{"path":"/tmp/file.txt"}<|call|>';
const blocks = parseStandalonePlainTextToolCallBlocks(raw);
expect(blocks).toEqual([
{
name: "read",
arguments: { path: "/tmp/file.txt" },
start: 0,
end: raw.length,
raw,
},
]);
});
it("parses Grok-style bracketed tool calls", () => {
const firstRaw = '[tool:read] {"path":"/app/skills/meme-maker/SKILL.md"}';
const secondRaw = '[tool:message] {"action":"send","channel":"channel:123","message":"done"}';
const raw = [firstRaw, "", secondRaw].join("\n");
const blocks = parseStandalonePlainTextToolCallBlocks(raw);
expect(blocks).toEqual([
{
name: "read",
arguments: { path: "/app/skills/meme-maker/SKILL.md" },
start: 0,
end: firstRaw.length,
raw: firstRaw,
},
{
name: "message",
arguments: { action: "send", channel: "channel:123", message: "done" },
start: firstRaw.length + 2,
end: raw.length,
raw: secondRaw,
},
]);
});
it("parses serialized parameter XML tool calls", () => {
const firstRaw = [
"[tool:exec]",
"<parameter=command>",
'cat /proc/mounts 2>/dev/null | grep -i "libra|rav|openclaw" | head -20',
"</parameter>",
"</function>",
].join("\n");
const secondRaw = [
"<function=exec>",
"<parameter=command>",
'find / -maxdepth 4 -type d \\( -name "ravdb" -o -name "librav" \\) 2>/dev/null | head -20',
"</parameter>",
"</function>",
].join("\n");
const raw = [firstRaw, "", secondRaw].join("\n");
const blocks = parseStandalonePlainTextToolCallBlocks(raw, {
allowedToolNames: ["exec"],
});
expect(blocks).toEqual([
{
name: "exec",
arguments: {
command: 'cat /proc/mounts 2>/dev/null | grep -i "libra|rav|openclaw" | head -20',
},
start: 0,
end: firstRaw.length,
raw: firstRaw,
},
{
name: "exec",
arguments: {
command:
'find / -maxdepth 4 -type d \\( -name "ravdb" -o -name "librav" \\) 2>/dev/null | head -20',
},
start: firstRaw.length + 2,
end: raw.length,
raw: secondRaw,
},
]);
});
it("preserves whitespace inside serialized XML parameter values", () => {
const raw = [
"<function=write>",
"<parameter=content>",
" first line",
" second line",
"",
"</parameter>",
"</function>",
].join("\n");
const blocks = parseStandalonePlainTextToolCallBlocks(raw, {
allowedToolNames: ["write"],
});
expect(blocks?.[0]?.arguments).toEqual({
content: " first line\n second line\n",
});
});
it("rejects serialized XML parameter calls without a function close", () => {
const raw = ["<function=exec>", "<parameter=command>", "pwd", "</parameter>"].join("\n");
expect(
parseStandalonePlainTextToolCallBlocks(raw, {
allowedToolNames: ["exec"],
}),
).toBeNull();
});
it("parses legacy tool-prefixed XML parameter calls without a function close", () => {
const raw = ["[tool:exec]", "<parameter=command>", "pwd", "</parameter>"].join("\n");
expect(
parseStandalonePlainTextToolCallBlocks(raw, {
allowedToolNames: ["exec"],
}),
).toEqual([
{
arguments: { command: "pwd" },
end: raw.length,
name: "exec",
raw,
start: 0,
},
]);
});
it("finds XML parameter close tags without lowercased string offsets", () => {
const dottedCapitalI = "\u0130";
const raw = [
"<function=write>",
"<parameter=content>",
dottedCapitalI,
"</parameter>",
"</function>",
].join("\n");
const blocks = parseStandalonePlainTextToolCallBlocks(raw, {
allowedToolNames: ["write"],
});
expect(blocks?.[0]?.arguments).toEqual({ content: dottedCapitalI });
});
it("rejects XML parameter blocks whose cumulative payload exceeds the cap", () => {
const firstParameter = ["<parameter=first>", "alpha", "</parameter>"].join("\n");
const secondParameter = ["<parameter=second>", "beta", "</parameter>"].join("\n");
const raw = ["<function=write>", firstParameter, secondParameter, "</function>"].join("\n");
const maxPayloadBytes = Math.max(firstParameter.length, secondParameter.length) + 1;
expect(
parseStandalonePlainTextToolCallBlocks(raw, {
allowedToolNames: ["write"],
maxPayloadBytes,
}),
).toBeNull();
});
it("respects allowed tool names for Harmony calls", () => {
const blocks = parseStandalonePlainTextToolCallBlocks(
'commentary to=write code {"path":"/tmp/file.txt","content":"x"}',
{ allowedToolNames: ["read"] },
);
expect(blocks).toBeNull();
});
});
describe("stripPlainTextToolCallBlocks", () => {
it("strips standalone bracketed local-model blocks", () => {
expect(
stripPlainTextToolCallBlocks(
["before", "[read]", '{"path":"/tmp/file.txt"}', "[END_TOOL_REQUEST]", "after"].join("\n"),
),
).toBe("before\nafter");
});
it("strips standalone Harmony tool calls", () => {
expect(
stripPlainTextToolCallBlocks(
'before\ncommentary to=read code {"path":"/tmp/file.txt"}\nafter',
),
).toBe("before\nafter");
});
it("strips standalone Grok-style tool calls", () => {
expect(
stripPlainTextToolCallBlocks(
[
"before",
'[tool:read] {"path":"/tmp/file.txt"}',
'[tool:message] {"action":"send","message":"[tool:read] {\\"path\\":\\"/tmp/file.txt\\"}"}',
"after",
].join("\n"),
),
).toBe("before\nafter");
});
it("strips serialized tool calls with parameter XML blocks", () => {
expect(
stripPlainTextToolCallBlocks(
[
"before",
"[tool:exec]",
"<parameter=command>",
'cat /proc/mounts 2>/dev/null | grep -i "libra|rav|openclaw" | head -20',
"</parameter>",
"</function>",
"",
"<function=exec>",
"<parameter=command>",
'find / -maxdepth 4 -type d \\( -name "ravdb" -o -name "librav" \\) 2>/dev/null | head -20',
"</parameter>",
"<parameter=timeout_ms>",
"1000",
"</parameter>",
"</function>",
"after",
].join("\n"),
),
).toBe("before\n\nafter");
});
it("keeps legacy bracketed XML parameter blocks scrubbed", () => {
expect(
stripPlainTextToolCallBlocks(
[
"before",
"[exec]",
"<parameter=command>",
"pwd",
"</parameter>",
"</function>",
"after",
].join("\n"),
),
).toBe("before\nafter");
});
it("preserves incomplete XML parameter blocks when stripping visible text", () => {
const text = ["before", "[exec]", "<parameter=command>", "pwd", "</parameter>", "after"].join(
"\n",
);
expect(stripPlainTextToolCallBlocks(text)).toBe(text);
});
it("strips legacy tool-prefixed XML parameter blocks without a function close", () => {
expect(
stripPlainTextToolCallBlocks(
["before", "[tool:exec]", "<parameter=command>", "pwd", "</parameter>", "after"].join("\n"),
),
).toBe("before\nafter");
});
it("strips oversized XML parameter tool calls without promoting them", () => {
const largeValue = "x".repeat(140_000);
const block = [
"<function=write>",
"<parameter=first>",
largeValue,
"</parameter>",
"<parameter=second>",
largeValue,
"</parameter>",
"</function>",
].join("\n");
expect(
parseStandalonePlainTextToolCallBlocks(block, {
allowedToolNames: ["write"],
}),
).toBeNull();
expect(stripPlainTextToolCallBlocks(["before", block, "after"].join("\n"))).toBe(
"before\nafter",
);
});
});