pi-mono/packages/ai/test/openai-responses-partial-json-cleanup.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

132 lines
4.3 KiB
TypeScript

import type { ResponseStreamEvent } from "openai/resources/responses/responses.js";
import { describe, expect, it, vi } from "vitest";
import { processResponsesStream } from "../src/api/openai-responses-shared.ts";
import type { AssistantMessage, AssistantMessageEvent, Model } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function createOutput(model: Model<"openai-responses">): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
}
async function* createFunctionCallEvents(argumentsJson: string): AsyncIterable<ResponseStreamEvent> {
yield {
type: "response.output_item.added",
item: {
type: "function_call",
id: "fc_test",
call_id: "call_test",
name: "edit",
arguments: "",
},
} as ResponseStreamEvent;
yield {
type: "response.function_call_arguments.delta",
delta: '{"path":"README.md"',
} as ResponseStreamEvent;
yield {
type: "response.function_call_arguments.delta",
delta: ',"content":"updated"}',
} as ResponseStreamEvent;
yield {
type: "response.function_call_arguments.done",
arguments: argumentsJson,
} as ResponseStreamEvent;
yield {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_test",
call_id: "call_test",
name: "edit",
arguments: argumentsJson,
},
} as ResponseStreamEvent;
yield {
type: "response.completed",
sequence_number: 5,
response: { id: "resp_test", status: "completed" },
} as ResponseStreamEvent;
}
describe("openai responses partialJson cleanup", () => {
it("removes partialJson from persisted tool-call blocks at output_item.done", async () => {
const model: Model<"openai-responses"> = {
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const output = createOutput(model);
const stream = new AssistantMessageEventStream();
const pushSpy = vi.spyOn(stream, "push");
const argumentsJson = '{"path":"README.md","content":"updated"}';
await processResponsesStream(createFunctionCallEvents(argumentsJson), output, stream, model);
expect(output.content).toHaveLength(1);
const persistedToolCall = output.content[0];
expect(persistedToolCall?.type).toBe("toolCall");
if (!persistedToolCall || persistedToolCall.type !== "toolCall") {
throw new Error("Expected toolCall block");
}
expect(persistedToolCall.arguments).toEqual({ path: "README.md", content: "updated" });
expect("partialJson" in persistedToolCall).toBe(false);
const emittedEvents = pushSpy.mock.calls.map(([event]) => event as AssistantMessageEvent);
const toolCallEnd = emittedEvents.find((event) => event.type === "toolcall_end");
expect(toolCallEnd).toBeDefined();
if (!toolCallEnd || toolCallEnd.type !== "toolcall_end") {
throw new Error("Expected toolcall_end event");
}
expect(toolCallEnd.toolCall).toBe(persistedToolCall);
expect("partialJson" in toolCallEnd.toolCall).toBe(false);
});
// #10897
it("marks malformed final arguments instead of keeping the parsed prefix", async () => {
const model: Model<"openai-responses"> = {
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const output = createOutput(model);
const stream = new AssistantMessageEventStream();
const argumentsJson = '{"path":"README.md","content":"updated","mode":.5}';
await processResponsesStream(createFunctionCallEvents(argumentsJson), output, stream, model);
const toolCall = output.content[0];
if (!toolCall || toolCall.type !== "toolCall") throw new Error("Expected toolCall block");
expect(toolCall.arguments).toEqual({});
expect(toolCall.argumentsError?.endsWith(`Received arguments:\n${argumentsJson}`)).toBe(true);
});
});