pi-mono/packages/ai/test/json-parse.test.ts
David Brailovsky 87f9a36419
fix(ai): report malformed final tool-call arguments instead of keeping the parsed prefix (#10944)
When a finished tool call's arguments text was not valid JSON, the
streaming parser kept only the fields before the error and the tool ran
with them. Finished calls are now parsed strictly (with the existing
string repair); on failure arguments are {} and argumentsError holds the
reason and raw text, which validateToolArguments reports to the model.

fixes #10897
2026-10-14 21:41:42 +02:00

57 lines
2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { parseStreamingJson, parseToolCallArguments } from "../src/utils/json-parse.ts";
describe("parseStreamingJson", () => {
// #10868
it("keeps invalid \\u escapes as literal text without dropping fields", () => {
expect(parseStreamingJson(String.raw`{"text":"\url{x}"}`)).toEqual({ text: "\\url{x}" });
expect(parseStreamingJson(String.raw`{"path":"a.tex","content":"\usepackage{x}","mode":"w"}`)).toEqual({
path: "a.tex",
content: "\\usepackage{x}",
mode: "w",
});
});
it("decodes valid \\u escapes", () => {
expect(parseStreamingJson(String.raw`{"text":"caf\u00e9 \url"}`)).toEqual({ text: "café \\url" });
});
it("omits a \\u escape that is cut off mid-stream", () => {
expect(parseStreamingJson(String.raw`{"text":"ab\u00`)).toEqual({ text: "ab" });
});
});
// #10897
describe("parseToolCallArguments", () => {
it("parses complete JSON and repairs invalid escapes", () => {
expect(parseToolCallArguments(String.raw`{"kind":"seek","note":"\url"}`)).toEqual({
arguments: { kind: "seek", note: "\\url" },
});
});
it("uses empty arguments for empty text", () => {
expect(parseToolCallArguments("")).toEqual({ arguments: {} });
});
it("does not keep the valid prefix of malformed JSON", () => {
const raw = '{"action":"apply","actions":[{"kind":"seek","time":.5}]}';
const result = parseToolCallArguments(raw);
expect(result.arguments).toEqual({});
expect(result.argumentsError).toMatch(/\n\nReceived arguments:\n/);
expect(result.argumentsError?.endsWith(raw)).toBe(true);
});
it("does not complete cut-off JSON", () => {
const raw = '{"kind":"seek","clipId":"clip-fl';
const result = parseToolCallArguments(raw);
expect(result.arguments).toEqual({});
expect(result.argumentsError?.endsWith(raw)).toBe(true);
});
it("rejects JSON that is not an object", () => {
expect(parseToolCallArguments("[1,2]")).toEqual({
arguments: {},
argumentsError: "Tool arguments must be a JSON object\n\nReceived arguments:\n[1,2]",
});
});
});