diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 69116139ed..4d255d40be 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -1155,7 +1155,12 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { // Keyed by index, not insertion order — readers that need call order for // parallel calls closed out of order should sort by this key rather than // relying on Map iteration order. + // Responses→Claude uses this same shared map for Claude block lifecycle + // state. Preserve those fields when adding the completed-call summary; + // replacing the entry makes the arguments chunk look like a new unnamed + // tool and emits a duplicate empty content_block_start. state.toolCalls.set(currentIndex, { + ...state.toolCalls.get(currentIndex), id: callId, index: currentIndex, type: "function", diff --git a/tests/unit/codex-claude-empty-tool-use.test.ts b/tests/unit/codex-claude-empty-tool-use.test.ts new file mode 100644 index 0000000000..6d0d948a51 --- /dev/null +++ b/tests/unit/codex-claude-empty-tool-use.test.ts @@ -0,0 +1,133 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.ts"; + +function sse(type: string, data: Record): string { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; +} + +async function translateCodexToolCall(rawSse: string): Promise[]> { + const transform = createSSETransformStreamWithLogger( + FORMATS.OPENAI_RESPONSES, + FORMATS.CLAUDE, + "codex", + null, + null, + "gpt-5.6-sol", + "connection-codex-tool", + { model: "gpt-5.6-sol", stream: true }, + null, + null, + null + ); + const writer = transform.writable.getWriter(); + const reader = transform.readable.getReader(); + const readAll = (async () => { + const decoder = new TextDecoder(); + let output = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + output += decoder.decode(); + return output; + })(); + + await writer.write(new TextEncoder().encode(rawSse)); + await writer.close(); + + return (await readAll) + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()) + .filter((payload) => payload && payload !== "[DONE]") + .map((payload) => JSON.parse(payload) as Record); +} + +test("Codex Responses tool call emits exactly one named Claude tool_use block", async () => { + const callId = "call_codex_claude_1"; + const itemId = "fc_codex_claude_1"; + const raw = [ + sse("response.created", { + sequence_number: 0, + response: { id: "resp_codex_claude_1", status: "in_progress", model: "gpt-5.6-sol" }, + }), + sse("response.output_item.added", { + sequence_number: 1, + output_index: 0, + item: { + id: itemId, + type: "function_call", + call_id: callId, + name: "check_status", + arguments: "", + status: "in_progress", + }, + }), + sse("response.function_call_arguments.delta", { + sequence_number: 2, + item_id: itemId, + output_index: 0, + delta: '{"value":"ok"}', + }), + sse("response.function_call_arguments.done", { + sequence_number: 3, + item_id: itemId, + output_index: 0, + arguments: '{"value":"ok"}', + }), + sse("response.output_item.done", { + sequence_number: 4, + output_index: 0, + item: { + id: itemId, + type: "function_call", + call_id: callId, + name: "check_status", + arguments: '{"value":"ok"}', + status: "completed", + }, + }), + sse("response.completed", { + sequence_number: 5, + response: { + id: "resp_codex_claude_1", + status: "completed", + model: "gpt-5.6-sol", + output: [ + { + id: itemId, + type: "function_call", + call_id: callId, + name: "check_status", + arguments: '{"value":"ok"}', + status: "completed", + }, + ], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }, + }), + ].join(""); + + const events = await translateCodexToolCall(raw); + const starts = events.filter((event) => event.type === "content_block_start") as Array<{ + index?: number; + content_block?: { type?: string; id?: string; name?: string }; + }>; + const toolStarts = starts.filter((event) => event.content_block?.type === "tool_use"); + + assert.equal(toolStarts.length, 1, "must not append a duplicate empty tool_use block"); + assert.deepEqual(toolStarts[0], { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: callId, + name: "check_status", + input: {}, + }, + }); +});