Files
openclaw/packages/tool-call-repair/src/payload.test.ts
Peter Steinberger 05ebaf41f6 fix(tool-call-repair): bound serialized stream normalization (#104100)
* fix(tool-call-repair): bound serialized stream normalization

Co-authored-by: wuqingxuan <wu.qingxuan@xydigit.com>
Co-authored-by: 徐闻涵0668001344 <xu.wenhan1@xydigit.com>
Co-authored-by: qingminlong <qing.minlong@xydigit.com>
Co-authored-by: WangYan <wang.yan29@xydigit.com>

* fix(tool-call-repair): preserve strict scan narrowing

* fix(tool-call-repair): lint serialized name validation

* fix(tool-call-repair): replay split suffix whitespace

* fix(tool-call-repair): cap complete-call whitespace

* test(tool-call-repair): assert bounded whitespace replay

---------

Co-authored-by: wuqingxuan <wu.qingxuan@xydigit.com>
Co-authored-by: 徐闻涵0668001344 <xu.wenhan1@xydigit.com>
Co-authored-by: qingminlong <qing.minlong@xydigit.com>
Co-authored-by: WangYan <wang.yan29@xydigit.com>
2026-07-10 20:00:11 -07:00

191 lines
6.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { scanXmlishToolCall } from "./grammar.js";
import { scanPlainTextJsonToolCall, stripPlainTextToolCallBlocks } from "./payload.js";
function trackStringOperations(value: string) {
let indexedReads = 0;
let indexOfCalls = 0;
const source = Object(value) as object;
const text = new Proxy(source, {
get(target, property) {
if (typeof property === "string" && /^(?:0|[1-9]\d*)$/.test(property)) {
indexedReads += 1;
}
if (property === "indexOf") {
return (searchString: string, position?: number) => {
indexOfCalls += 1;
return value.indexOf(searchString, position);
};
}
const member = Reflect.get(target, property, target) as unknown;
return typeof member === "function" ? member.bind(target) : member;
},
});
return {
get indexedReads() {
return indexedReads;
},
get indexOfCalls() {
return indexOfCalls;
},
text: text as unknown as string,
};
}
describe("scanPlainTextJsonToolCall", () => {
it.each([
["named", '[read]\n{"path":"/tmp/file"}[/read]visible'],
["legacy", '[read]\n{"path":"/tmp/file"}[END_TOOL_REQUEST]visible'],
["Harmony", '<|channel|>commentary to=read code<|message|>{"path":"/tmp/file"}<|call|>visible'],
])("returns the complete %s call before a visible suffix", (_syntax, raw) => {
const scan = scanPlainTextJsonToolCall(raw);
expect(scan.kind).toBe("complete");
if (scan.kind !== "complete") {
return;
}
expect(raw.slice(scan.end)).toBe("visible");
expect(raw.slice(scan.name.start, scan.name.end)).toBe("read");
expect(raw.slice(scan.payload.start, scan.payload.end)).toBe('{"path":"/tmp/file"}');
});
it.each([
["[", undefined, undefined, undefined],
["[tool", undefined, undefined, undefined],
["[tool:re", "tool-bracket", "re", false],
["[read]", "named-bracket", "read", true],
["comment", undefined, undefined, undefined],
["commentary to=read co", "harmony", "read", true],
['[read]\n{"path":1}[/re', "named-bracket", "read", true],
] as const)("classifies the streaming prefix %s", (raw, syntax, name, nameComplete) => {
const scan = scanPlainTextJsonToolCall(raw);
expect(scan.kind).toBe("prefix");
if (scan.kind !== "prefix") {
return;
}
expect(scan.candidate?.syntax).toBe(syntax);
expect(scan.candidate?.nameComplete).toBe(nameComplete);
expect(
scan.candidate?.name
? raw.slice(scan.candidate.name.start, scan.candidate.name.end)
: undefined,
).toBe(name);
});
it("exposes lexical continuation state for an incomplete JSON object", () => {
const raw = '[tool:read]{"value":"still open';
const scan = scanPlainTextJsonToolCall(raw);
expect(scan.kind).toBe("prefix");
if (scan.kind !== "prefix") {
return;
}
expect(scan.candidate?.json).toEqual({ depth: 1, escaped: false, inString: true });
expect(
scan.candidate?.payload &&
raw.slice(scan.candidate.payload.start, scan.candidate.payload.end),
).toBe('{"value":"still open');
});
it("uses a virtual text-part boundary as the named-header line break", () => {
const raw = '[read]{"path":"/tmp/file"}[/read]';
const usedLineBreakOffsets = new Set<number>();
const scan = scanPlainTextJsonToolCall(raw, 0, {
lineBreakOffsets: new Set(["[read]".length]),
usedLineBreakOffsets,
});
expect(scan.kind).toBe("complete");
expect([...usedLineBreakOffsets]).toEqual(["[read]".length]);
});
it.each([
(name: string) => `[${name}]\n{}[/${name}]`,
(name: string) => `[tool:${name}] {}`,
(name: string) => `analysis to=${name} code {}`,
])("accepts 120-character names and rejects the 121st character", (build) => {
expect(scanPlainTextJsonToolCall(build("x".repeat(120))).kind).toBe("complete");
const oversized = scanPlainTextJsonToolCall(build("x".repeat(121)));
expect(oversized.kind).toBe("invalid");
if (oversized.kind === "invalid") {
expect(oversized.at).toBeGreaterThan(0);
}
});
it("returns invalid progress without consuming a wrong named closer", () => {
const raw = '[read]\n{"path":"/tmp/file"}[/write] visible';
const scan = scanPlainTextJsonToolCall(raw);
expect(scan.kind).toBe("invalid");
if (scan.kind !== "invalid") {
return;
}
expect(raw.slice(scan.at)).toBe("[/write] visible");
expect(
scan.candidate?.payload &&
raw.slice(scan.candidate.payload.start, scan.candidate.payload.end),
).toBe('{"path":"/tmp/file"}');
});
it.each([
["tool bracket", '[tool:read]{"path":"/tmp/file"}'],
["Harmony", 'analysis to=read code {"path":"/tmp/file"}'],
])("buffers every partial optional closer for %s syntax", (_name, call) => {
for (const marker of ["<|call|>", "[END_TOOL_REQUEST]", "[/read]"]) {
for (let split = 1; split < marker.length; split += 1) {
expect(scanPlainTextJsonToolCall(call + marker.slice(0, split)).kind).toBe("prefix");
}
const complete = scanPlainTextJsonToolCall(call + marker);
expect(complete).toMatchObject({ kind: "complete", end: call.length + marker.length });
}
const mismatch = scanPlainTextJsonToolCall(`${call}<|cap`);
expect(mismatch).toMatchObject({ kind: "complete", end: call.length });
});
});
describe("stripPlainTextToolCallBlocks", () => {
it("preserves a balanced tool block whose JSON is invalid", () => {
const raw = '[read]\n{"path":}\n[/read]';
expect(stripPlainTextToolCallBlocks(raw)).toBe(raw);
});
it.each([
["JSON", "[tool:read] {\n", scanPlainTextJsonToolCall],
["XML", "<function=read><parameter=x>x\n", scanXmlishToolCall],
] as const)(
"preserves a long repeated incomplete %s candidate in one scan",
(_name, line, scan) => {
const raw = line.repeat(20_000);
const result = scan(raw);
expect(result.kind).toBe("prefix");
if (result.kind !== "prefix") {
return;
}
expect(result.candidate?.payload?.end).toBe(raw.length);
expect(stripPlainTextToolCallBlocks(raw)).toBe(raw);
},
);
it("advances once through a far-invalid XML parameter", () => {
const repeats = 256;
const raw = "<function=read><parameter=x>x\n".repeat(repeats) + "</parameter>X";
const tracked = trackStringOperations(raw);
expect(stripPlainTextToolCallBlocks(tracked.text)).toBe(raw);
expect(tracked.indexOfCalls).toBeLessThan(repeats * 8);
});
it("advances once through a far-invalid named JSON payload", () => {
const repeats = 256;
const raw = "[read]\n{\n".repeat(repeats) + "}".repeat(repeats) + "[/wrong]";
const tracked = trackStringOperations(raw);
expect(stripPlainTextToolCallBlocks(tracked.text)).toBe(raw);
expect(tracked.indexedReads).toBeLessThan(raw.length * 16);
});
});