From bd1098de16c6d4163f037706c23b1fb6682dde06 Mon Sep 17 00:00:00 2001 From: Maxim Ivanov Date: Sat, 30 May 2026 08:49:46 +0000 Subject: [PATCH 1/3] fix(claude): strip empty Read pages tool input Buffer Claude Code Read tool calls through the existing shim layer so empty pages placeholders are removed before streaming input_json_delta to the client. Also clean JSON-string Responses tool arguments, not only object arguments. Closes #2935 Addresses #2889 --- open-sse/translator/helpers/toolCallShim.ts | 11 +++ .../translator/response/openai-responses.ts | 43 ++++++----- .../translator-resp-openai-responses.test.ts | 28 +++++++ tests/unit/translator-tool-call-shim.test.ts | 76 ++++++++++++++++++- 4 files changed, 140 insertions(+), 18 deletions(-) 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..40b3b67076 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -9,6 +9,30 @@ function normalizeToolName(value) { return typeof value === "string" ? value.trim() : ""; } +function stripEmptyOptionalToolArgs(value) { + if (value == null) return value; + + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + const cleaned = stripEmptyOptionalToolArgs(parsed); + 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 +655,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); const argsStr = argsToEmit != null @@ -681,14 +697,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); 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..2d6706b316 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -242,6 +242,34 @@ test("Responses -> OpenAI: empty-name tool call is deferred until output_item.do ); }); +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 = [ From 3767e130ea91dea4e9f4ec5f835fa76137cb94af Mon Sep 17 00:00:00 2001 From: Maxim Ivanov Date: Sat, 30 May 2026 08:58:13 +0000 Subject: [PATCH 2/3] fix: preserve falsy tool argument values --- .../translator/response/openai-responses.ts | 2 +- .../translator-resp-openai-responses.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 40b3b67076..ebd417d007 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -16,7 +16,7 @@ function stripEmptyOptionalToolArgs(value) { try { const parsed = JSON.parse(value); const cleaned = stripEmptyOptionalToolArgs(parsed); - return JSON.stringify(cleaned || {}); + return JSON.stringify(cleaned ?? {}); } catch { return value; } diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 2d6706b316..569cfa5f0d 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -242,6 +242,26 @@ test("Responses -> OpenAI: empty-name tool call is deferred until output_item.do ); }); +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: strips empty optional args from JSON-string output_item.done arguments", () => { const state = {}; openaiResponsesToOpenAIResponse( From d25e32326d1cf9bee89653a33d3bb689e72a1944 Mon Sep 17 00:00:00 2001 From: Maxim Ivanov Date: Sat, 30 May 2026 10:24:18 +0000 Subject: [PATCH 3/3] fix: scope JSON-string cleanup to Read tool Keep existing object-argument cleanup behavior, but avoid parsing and stripping arbitrary JSON-string arguments for unrelated tools where empty strings or arrays may be valid payloads. Add regression coverage for non-Read and non-object Read arguments. --- .../translator/response/openai-responses.ts | 12 +++-- .../translator-resp-openai-responses.test.ts | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index ebd417d007..0b50f80c0e 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -9,13 +9,17 @@ function normalizeToolName(value) { return typeof value === "string" ? value.trim() : ""; } -function stripEmptyOptionalToolArgs(value) { +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); - const cleaned = stripEmptyOptionalToolArgs(parsed); + if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value; + const cleaned = stripEmptyOptionalToolArgs(parsed, toolName); return JSON.stringify(cleaned ?? {}); } catch { return value; @@ -655,7 +659,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { state.toolCallIndex++; - const argsToEmit = stripEmptyOptionalToolArgs(item.arguments); + const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName); const argsStr = argsToEmit != null @@ -697,7 +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) { - const argsToEmit = stripEmptyOptionalToolArgs(item.arguments); + 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 569cfa5f0d..d837baff5b 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -242,6 +242,31 @@ 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( @@ -262,6 +287,26 @@ test("Responses -> OpenAI: preserves falsy JSON-string tool arguments while clea 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(