diff --git a/open-sse/translator/helpers/toolCallShim.ts b/open-sse/translator/helpers/toolCallShim.ts index 94ebfece01..cc92174386 100644 --- a/open-sse/translator/helpers/toolCallShim.ts +++ b/open-sse/translator/helpers/toolCallShim.ts @@ -28,6 +28,17 @@ function coerceToArray(v: unknown): unknown[] { } const TOOL_SHIMS: Record = { + // Claude Code Read accepts `pages` only for PDFs and rejects an empty string. + // Some non-Anthropic models emit optional `pages: ""` for ordinary files. + // Buffer and emit one cleaned JSON delta so the client never sees the bad field. + Read: (input) => { + if (typeof input !== "object" || input === null || Array.isArray(input)) return input; + const patched = { ...(input as Record) }; + if (patched.pages === "" || (Array.isArray(patched.pages) && patched.pages.length === 0)) { + delete patched.pages; + } + return patched; + }, submit_pr_review: (input) => { if (typeof input !== "object" || input === null || Array.isArray(input)) return input; const patched = { ...(input as Record) }; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index a5b5ceafdf..0b50f80c0e 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -9,6 +9,34 @@ function normalizeToolName(value) { return typeof value === "string" ? value.trim() : ""; } +function stripEmptyOptionalToolArgs(value, toolName) { + if (value == null) return value; + + if (typeof value === "string") { + // JSON-string cleanup is intentionally scoped to Claude Code's Read tool. + // For arbitrary tools, empty strings/arrays may be valid user payloads. + if (toolName !== "Read") return value; + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value; + const cleaned = stripEmptyOptionalToolArgs(parsed, toolName); + return JSON.stringify(cleaned ?? {}); + } catch { + return value; + } + } + + if (Array.isArray(value) || typeof value !== "object") return value; + + const cleaned = { ...value }; + for (const [key, entry] of Object.entries(cleaned)) { + if (entry === "" || (Array.isArray(entry) && entry.length === 0)) { + delete cleaned[key]; + } + } + return cleaned; +} + /** * Translate OpenAI chunk to Responses API events * @returns {Array} Array of events with { event, data } structure @@ -631,15 +659,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { state.toolCallIndex++; - let argsToEmit = item.arguments; - if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) { - // Fix #1674 & #1852: Strip empty string and array placeholders emitted by GPT-5.5 for optional fields - const cleaned = { ...argsToEmit }; - for (const [k, v] of Object.entries(cleaned)) { - if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k]; - } - argsToEmit = cleaned; - } + const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName); const argsStr = argsToEmit != null @@ -681,14 +701,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { // Only emit if arguments exist in the done event AND they weren't already streamed via deltas if (item.arguments != null && !buffered) { - let argsToEmit = item.arguments; - if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) { - const cleaned = { ...argsToEmit }; - for (const [k, v] of Object.entries(cleaned)) { - if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k]; - } - argsToEmit = cleaned; - } + const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName); const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit); if (argsStr) { diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 292c4b7d11..d837baff5b 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -242,6 +242,99 @@ test("Responses -> OpenAI: empty-name tool call is deferred until output_item.do ); }); +test("Responses -> OpenAI: preserves non-Read JSON-string tool arguments", () => { + const state = {}; + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_note", name: "save_note" }, + }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_note", + name: "save_note", + arguments: '{"text":"","tags":[]}', + }, + }, + state + ); + + assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"text":"","tags":[]}'); +}); + +test("Responses -> OpenAI: preserves falsy JSON-string tool arguments while cleaning", () => { + const state = {}; + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_flag", name: "set_flag" }, + }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_flag", name: "set_flag", arguments: "false" }, + }, + state + ); + + assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, "false"); +}); + +test("Responses -> OpenAI: preserves non-object Read JSON-string arguments", () => { + const state = {}; + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_read", name: "Read" }, + }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_read", name: "Read", arguments: "null" }, + }, + state + ); + + assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, "null"); +}); + +test("Responses -> OpenAI: strips empty optional args from JSON-string output_item.done arguments", () => { + const state = {}; + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_read", name: "Read" }, + }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_read", + name: "Read", + arguments: '{"file_path":"/etc/hosts","offset":1,"limit":5,"pages":"","empty":[]}', + }, + }, + state + ); + + assert.equal( + done.choices[0].delta.tool_calls[0].function.arguments, + JSON.stringify({ file_path: "/etc/hosts", offset: 1, limit: 5 }) + ); +}); + test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage are normalized", () => { const state = {}; const added = openaiResponsesToOpenAIResponse( diff --git a/tests/unit/translator-tool-call-shim.test.ts b/tests/unit/translator-tool-call-shim.test.ts index 14e9b4b0af..b5da99d951 100644 --- a/tests/unit/translator-tool-call-shim.test.ts +++ b/tests/unit/translator-tool-call-shim.test.ts @@ -12,7 +12,8 @@ const { coerceToArray } = __test as { coerceToArray: (v: unknown) => unknown[] } // -------- Helper-level tests -------- -test("hasToolCallShim: returns true for submit_pr_review only", () => { +test("hasToolCallShim: returns true for registered shims", () => { + assert.equal(hasToolCallShim("Read"), true); assert.equal(hasToolCallShim("submit_pr_review"), true); assert.equal(hasToolCallShim("some_other_tool"), false); assert.equal(hasToolCallShim(""), false); @@ -54,6 +55,26 @@ test("coerceToArray: stringified non-array -> []", () => { assert.deepEqual(coerceToArray('"a string"'), []); }); +test("applyToolCallShimToBuffer: Read removes empty pages but preserves valid ranges", () => { + const withEmptyPages = JSON.parse( + applyToolCallShimToBuffer( + "Read", + JSON.stringify({ file_path: "/etc/hosts", offset: 1, limit: 5, pages: "" }) + ) + ); + assert.deepEqual(withEmptyPages, { file_path: "/etc/hosts", offset: 1, limit: 5 }); + + const withEmptyArrayPages = JSON.parse( + applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/tmp/a.pdf", pages: [] })) + ); + assert.deepEqual(withEmptyArrayPages, { file_path: "/tmp/a.pdf" }); + + const withValidPages = JSON.parse( + applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/tmp/a.pdf", pages: "1-5" })) + ); + assert.deepEqual(withValidPages, { file_path: "/tmp/a.pdf", pages: "1-5" }); +}); + test("applyToolCallShimToBuffer: submit_pr_review with valid arrays preserved", () => { const raw = JSON.stringify({ summary: "ok", @@ -147,6 +168,59 @@ function streamChunks(chunks: any[], state: any): any[] { return all; } +test("streaming: Read suppresses raw pages delta and emits cleaned input at finish", () => { + const state = freshState(); + const chunks = [ + { + id: "chatcmpl-read", + model: "codex/gpt-5.5-high", + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_read", + function: { name: "Read", arguments: "" }, + }, + ], + }, + }, + ], + }, + { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: '{"file_path":"/etc/hosts","offset":1,"limit":5,"pages":""}', + }, + }, + ], + }, + }, + ], + }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + + const events = streamChunks(chunks, state); + const inputDeltas = events.filter( + (e) => e.type === "content_block_delta" && e.delta?.type === "input_json_delta" + ); + + assert.equal(inputDeltas.length, 1, "expected exactly one cleaned Read delta"); + assert.equal(inputDeltas[0].delta.partial_json.includes('"pages"'), false); + assert.deepEqual(JSON.parse(inputDeltas[0].delta.partial_json), { + file_path: "/etc/hosts", + offset: 1, + limit: 5, + }); +}); + test("streaming: submit_pr_review with missing arrays gets corrective delta at finish", () => { const state = freshState(); const chunks = [