From d25e32326d1cf9bee89653a33d3bb689e72a1944 Mon Sep 17 00:00:00 2001 From: Maxim Ivanov Date: Sat, 30 May 2026 10:24:18 +0000 Subject: [PATCH] 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(