Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions open-sse/translator/helpers/toolCallShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ function coerceToArray(v: unknown): unknown[] {
}

const TOOL_SHIMS: Record<string, ShimFn> = {
// 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<string, unknown>) };
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<string, unknown>) };
Expand Down
47 changes: 30 additions & 17 deletions open-sse/translator/response/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment on lines +15 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the logical OR operator (||) to provide a fallback value of {} will cause any valid falsy JSON values (such as false, 0, or "") to be incorrectly replaced with {}. For example, if a tool argument is a stringified boolean "false", it will be parsed as false, cleaned as false, and then false || {} will evaluate to {}, resulting in "{}" being returned instead of "false".

Using the nullish coalescing operator (??) instead ensures that only null or undefined values are coalesced to {}, preserving other valid falsy values.

Suggested change
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
const cleaned = stripEmptyOptionalToolArgs(parsed);
return JSON.stringify(cleaned || {});
} catch {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
93 changes: 93 additions & 0 deletions tests/unit/translator-resp-openai-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
76 changes: 75 additions & 1 deletion tests/unit/translator-tool-call-shim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = [
Expand Down