Merge PR 2937 into release/v3.8.8

This commit is contained in:
diegosouzapw
2026-05-30 22:00:29 -03:00
4 changed files with 209 additions and 18 deletions

View File

@@ -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>) };

View File

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

View File

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

View File

@@ -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 = [