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.
This commit is contained in:
Maxim Ivanov
2026-05-30 10:24:18 +00:00
parent 3767e130ea
commit d25e32326d
2 changed files with 53 additions and 4 deletions

View File

@@ -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) {

View File

@@ -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(