From d6e86f413b7b427f7228a7749f36fb881fd9bfde Mon Sep 17 00:00:00 2001 From: Erick Kinnee Date: Sat, 18 Jul 2026 19:20:01 -0500 Subject: [PATCH] fix(translator): synthesize tool call chunks from response.completed batched output (#7613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(translator): synthesize tool call chunks from response.completed output[] When an upstream provider sends a batched response.completed event carrying function_call items in its data.response.output[] array — without having sent the individual response.output_item.added / .delta / .done events — the state variables toolCallIndex and currentToolCallId were never set, causing computeFinishReason to return 'stop' instead of 'tool_calls'. This broke the agent loop for downstream Chat Completions clients (OpenCode, Hermes, etc.) when routing through providers that batch their output into the completed event. Fix: parse data.response.output[] for function_call items in the response.completed handler, synthesize the tool call header + arguments delta chunks, advance state, and emit finish_reason: 'tool_calls'. Also updates withAssistantRoleOnFirstDelta to handle array results. Fixes #180, #3980 Refs: https://github.com/diegosouzapw/OmniRoute/issues/180 Refs: https://github.com/diegosouzapw/OmniRoute/issues/3980 * fix(translator): guard against double-emission for incrementally-streamed tool calls Add a guard that skips response.completed synthesis for call_ids already tracked via incremental output_item.added/.done events. Without this, providers that stream incrementally AND echo function_call items in the response.completed output[] snapshot get duplicate tool call chunks. Also adds a regression test combining both incremental events and a response.completed snapshot in the same turn. Refs: diegosouzapw/OmniRoute#7613 * chore: add docker-compose.yml.bak to gitignore * refactor(translator): extract response.completed synthesis, fix ratchets Fixes the file-size and complexity/cognitive-complexity ratchet regressions the dedup-guard commit (6bbff5ea) introduced, so the PR's own validation block (typecheck, eslint, file-size, complexity, cognitive-complexity, changelog-integrity, test-discovery) is fully green, not just its own tests: - Extract the response.completed batched-tool-call synthesis body into a new leaf module open-sse/translator/response/openai-responses/synthesizeCompletedToolCalls.ts, mirroring this file's own established eventEmitter.ts/toolSchemas.ts/ pureHelpers.ts extraction pattern, further split internally (buildToolCallChunks/buildFinalChunk/resolveArgsStr/baseChunk) to keep synthesizeCompletedToolCalls() itself under the complexity/ cognitive-complexity/max-lines-per-function thresholds. - computeFinishReason moves alongside it (not into the sibling pureHelpers.ts) because it takes stream `state` — pureHelpers.ts is guarded by tests/unit/response-openai-responses-purehelpers-split.test.ts to have NO state coupling at all. - DRY the withAssistantRoleOnFirstDelta array/single-result branches into a shared setAssistantRoleIfEligible(state, delta) helper — the array branch alone pushed this function's cyclomatic complexity to 16 (over the 15 threshold). - Split the 5 new response.completed tests out of tests/unit/translator-resp-openai-responses.test.ts (which would have exceeded the 800-line test-file-size cap) into a new sibling tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts. - Bump the frozen open-sse/translator/response/openai-responses.ts file-size baseline for the small irreducible remainder (dedup-guard state tracking + call-site wiring) with a justification entry in config/quality/file-size-baseline.json. Independently re-verified red-first (temporarily reintroducing the pre-guard filter reproduces exactly one failure — the dedup test — no collateral damage) and confirmed check:complexity-ratchets is back to exactly baseline (2058/890) and check:file-size is fully green. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(translator): move completed-tool-call glue into module (file-size cap) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Erick Kinnee Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .gitignore | 1 + ...180-3980-responses-completed-tool-calls.md | 1 + ...responses-completed-guard-seen-call-ids.md | 1 + .../translator/response/openai-responses.ts | 58 ++-- .../synthesizeCompletedToolCalls.ts | 198 ++++++++++++ ...enai-responses-completed-synthesis.test.ts | 299 ++++++++++++++++++ 6 files changed, 522 insertions(+), 36 deletions(-) create mode 100644 changelog.d/fixes/180-3980-responses-completed-tool-calls.md create mode 100644 changelog.d/fixes/7613-responses-completed-guard-seen-call-ids.md create mode 100644 open-sse/translator/response/openai-responses/synthesizeCompletedToolCalls.ts create mode 100644 tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts diff --git a/.gitignore b/.gitignore index a507938f26..4a0b192e9c 100644 --- a/.gitignore +++ b/.gitignore @@ -249,3 +249,4 @@ _artifacts/ # release-green artifacts tests/homolog/.auth/ tests/homolog/ui/.auth/ homolog-report/ +docker-compose.yml.bak diff --git a/changelog.d/fixes/180-3980-responses-completed-tool-calls.md b/changelog.d/fixes/180-3980-responses-completed-tool-calls.md new file mode 100644 index 0000000000..f9556e0138 --- /dev/null +++ b/changelog.d/fixes/180-3980-responses-completed-tool-calls.md @@ -0,0 +1 @@ +- **fix(translator):** synthesize tool call chunks from `response.completed` batched output when upstream omits individual `output_item.added`/`done` events, fixing `finish_reason: "stop"` instead of `"tool_calls"` for agentic clients ([#180](https://github.com/diegosouzapw/OmniRoute/issues/180), [#3980](https://github.com/diegosouzapw/OmniRoute/issues/3980)) diff --git a/changelog.d/fixes/7613-responses-completed-guard-seen-call-ids.md b/changelog.d/fixes/7613-responses-completed-guard-seen-call-ids.md new file mode 100644 index 0000000000..b2e32f26be --- /dev/null +++ b/changelog.d/fixes/7613-responses-completed-guard-seen-call-ids.md @@ -0,0 +1 @@ +- **fix(translator):** guard against double-emission when `response.completed` echoes `function_call` items already streamed via incremental `output_item.added`/`done` events — skip synthesis for `call_id`s already tracked, preventing duplicate tool call chunks for incrementally-streaming providers diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index fdd214d06d..768cc8b609 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -15,6 +15,11 @@ import { getVisibleResponsesReasoningSummaryText, } from "./openai-responses/pureHelpers.ts"; import { createEventEmitter } from "./openai-responses/eventEmitter.ts"; +import { + synthesizeCompletedToolCalls, + computeFinishReason, + withAssistantRoleOnFirstDelta, +} from "./openai-responses/synthesizeCompletedToolCalls.ts"; // normalizeUpstreamFailure is re-exported for external importers (tests). export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts"; @@ -609,42 +614,6 @@ function flushEvents(state) { return events; } -/** - * OpenAI Chat Completions streams announce the assistant role on the FIRST delta - * (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant", - * "tool_calls": [...] }`). The Responses API has no role-announcement event, so when - * translating Responses → Chat we must synthesize it on the first emitted chunk. - * - * Strict streaming clients — notably @langchain/openai's `_convertDeltaToMessageChunk` - * (used by n8n's AI Agent) — key off the first chunk's role to build an AIMessageChunk. - * Without it, streamed tool_call deltas are dropped and the agent returns an empty - * response, even though the underlying tool call is well-formed. - */ -function withAssistantRoleOnFirstDelta(state, result) { - if (!result || state.roleEmitted) return result; - const delta = result.choices?.[0]?.delta; - if (delta && typeof delta === "object" && !Array.isArray(delta)) { - delta.role = "assistant"; - state.roleEmitted = true; - } - return result; -} - -/** - * Resolve the terminal finish_reason for a Responses→Chat stream. - * - * `currentToolCallId` is intentionally sticky for the current turn: it is set when a - * function_call item is announced (`response.output_item.added`) and is only cleared once - * the matching `response.output_item.done` advances `toolCallIndex`. If the stream ends - * (flush or `response.completed`) after a tool call was emitted but BEFORE its - * `output_item.done` arrived, `toolCallIndex` is still 0 while `currentToolCallId` is set. - * Guarding on it as well lets us still finalize as `tool_calls` instead of `stop`, so - * OpenAI-compatible clients continue tool-result processing instead of stopping prematurely. - */ -function computeFinishReason(state): "tool_calls" | "stop" { - return (state.toolCallIndex || 0) > 0 || state.currentToolCallId ? "tool_calls" : "stop"; -} - // #5786 — remember that a reasoning delta was streamed for a given reasoning item, so // the terminal `response.output_item.done` snapshot for that item is NOT re-emitted // (which would duplicate the reasoning channel). Keyed by item_id when present, with a @@ -770,6 +739,10 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer state.currentToolCallDeferred = false; + // Track this call_id so response.completed doesn't synthesize a duplicate + if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); + if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId); + const toolName = normalizeToolName(item.name); if (!toolName) { // Some Responses providers briefly emit placeholder/empty tool names. @@ -849,6 +822,10 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { const toolName = normalizeToolName(item.name); const toolSchema = state.toolSchemas?.get(toolName); + // Track this call_id so response.completed doesn't synthesize a duplicate + if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); + if (callId) state.toolCallIdsSeen.add(callId); + if (state.currentToolCallDeferred) { state.currentToolCallDeferred = false; state.currentToolCallArgsBuffer = ""; @@ -979,6 +956,15 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { } } + // #fix: synthesize tool call chunks from response.completed output[] for + // providers that batch everything into response.completed without prior + // incremental output_item.* events — including the dedup guard against + // providers that DO stream incrementally and also echo the same + // function_call items here. See synthesizeCompletedToolCalls's own + // doc-comment for the full rationale. + const synthesized = synthesizeCompletedToolCalls(state, data.response?.output); + if (synthesized) return synthesized; + if (!state.finishReasonSent) { state.finishReasonSent = true; const reason = computeFinishReason(state); diff --git a/open-sse/translator/response/openai-responses/synthesizeCompletedToolCalls.ts b/open-sse/translator/response/openai-responses/synthesizeCompletedToolCalls.ts new file mode 100644 index 0000000000..2ea36f70b4 --- /dev/null +++ b/open-sse/translator/response/openai-responses/synthesizeCompletedToolCalls.ts @@ -0,0 +1,198 @@ +// Extracted from response/openai-responses.ts (file-size ratchet) — synthesizes +// tool_calls chunks from a response.completed event's output[] snapshot, for +// upstream providers that send a single batched completed event WITHOUT first +// emitting the individual response.output_item.added/.delta/.done events. +// Without this, state.toolCallIndex stays 0 and state.currentToolCallId stays +// null, so computeFinishReason returns "stop" instead of "tool_calls", +// breaking the agent loop for downstream Chat Completions clients. +import { fallbackToolCallId } from "../../helpers/toolCallHelper.ts"; +import { normalizeToolName, stripEmptyOptionalToolArgs } from "./pureHelpers.ts"; + +/** + * Resolve the terminal finish_reason for a Responses→Chat stream. + * + * `currentToolCallId` is intentionally sticky for the current turn: it is set when a + * function_call item is announced (`response.output_item.added`) and is only cleared once + * the matching `response.output_item.done` advances `toolCallIndex`. If the stream ends + * (flush or `response.completed`) after a tool call was emitted but BEFORE its + * `output_item.done` arrived, `toolCallIndex` is still 0 while `currentToolCallId` is set. + * Guarding on it as well lets us still finalize as `tool_calls` instead of `stop`, so + * OpenAI-compatible clients continue tool-result processing instead of stopping prematurely. + * + * Lives here (not pureHelpers.ts) because it takes stream `state` — pureHelpers.ts is + * guarded (tests/unit/response-openai-responses-purehelpers-split.test.ts) to have NO + * state coupling at all. + */ +export function computeFinishReason(state): "tool_calls" | "stop" { + return (state.toolCallIndex || 0) > 0 || state.currentToolCallId ? "tool_calls" : "stop"; +} + +/** + * OpenAI Chat Completions streams announce the assistant role on the FIRST delta + * (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant", + * "tool_calls": [...] }`). The Responses API has no role-announcement event, so when + * translating Responses → Chat we must synthesize it on the first emitted chunk. + * + * Strict streaming clients — notably @langchain/openai's `_convertDeltaToMessageChunk` + * (used by n8n's AI Agent) — key off the first chunk's role to build an AIMessageChunk. + * Without it, streamed tool_call deltas are dropped and the agent returns an empty + * response, even though the underlying tool call is well-formed. + */ +// Shared by both branches of withAssistantRoleOnFirstDelta below: stamps +// role: "assistant" onto a single delta object when eligible, returning +// whether it did so (used to short-circuit the array branch's loop). +function setAssistantRoleIfEligible(state, delta) { + if (delta && typeof delta === "object" && !Array.isArray(delta)) { + delta.role = "assistant"; + state.roleEmitted = true; + return true; + } + return false; +} + +export function withAssistantRoleOnFirstDelta(state, result) { + if (!result || state.roleEmitted) return result; + + // Handle arrays of chunks (e.g. synthesized from response.completed output[]) + if (Array.isArray(result)) { + for (const chunk of result) { + if (setAssistantRoleIfEligible(state, chunk?.choices?.[0]?.delta)) break; + } + return result; + } + + setAssistantRoleIfEligible(state, result.choices?.[0]?.delta); + return result; +} + +function baseChunk(state): Record { + return { + id: state.chatId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "gpt-4", + }; +} + +/** Resolve the arguments string to emit — may arrive as a string or object. */ +function resolveArgsStr(rawArgs, toolName, toolSchema): string { + const argsToEmit = stripEmptyOptionalToolArgs(rawArgs, toolName, toolSchema); + if (argsToEmit != null) { + return typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit); + } + if (rawArgs != null) { + return typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs); + } + return ""; +} + +/** + * Build the header + args chunks for one synthesized function_call item, + * mutating `state` exactly as the incremental output_item.added/.done path + * would (currentToolCallId, currentToolCallArgsBuffer, currentToolCallDeferred, + * toolCallIndex), so downstream chunk math (computeFinishReason, subsequent + * incremental events in the same turn) stays consistent. + */ +function buildToolCallChunks(state, fcItem): Record[] { + const chunks: Record[] = []; + const callId = fcItem.call_id || fallbackToolCallId(state.toolCallIndex); + const toolName = normalizeToolName(fcItem.name); + const toolSchema = state.toolSchemas?.get(toolName); + + // Set state as output_item.added would + state.currentToolCallId = callId; + state.currentToolCallArgsBuffer = ""; + state.currentToolCallDeferred = false; + + // Emit the tool call header chunk (id, type, function.name) + const currentIndex = state.toolCallIndex; + chunks.push({ + ...baseChunk(state), + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: currentIndex, + id: callId, + type: "function", + function: { name: toolName || "", arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + + const argsStr = resolveArgsStr(fcItem.arguments, toolName, toolSchema); + if (argsStr) { + state.currentToolCallArgsBuffer = argsStr; + chunks.push({ + ...baseChunk(state), + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: currentIndex, function: { arguments: argsStr } }] }, + finish_reason: null, + }, + ], + }); + } + + // Advance state as output_item.done would + state.toolCallIndex++; + state.currentToolCallArgsBuffer = ""; + state.currentToolCallId = null; + + return chunks; +} + +/** Build the terminal chunk (finish_reason + usage) once all tool calls are synthesized. */ +function buildFinalChunk(state): Record { + state.finishReasonSent = true; + const reason = computeFinishReason(state); + state.finishReason = reason; + + const finalChunk: Record = { + ...baseChunk(state), + choices: [{ index: 0, delta: {}, finish_reason: reason }], + }; + if (state.usage && typeof state.usage === "object") { + finalChunk.usage = state.usage; + } + return finalChunk; +} + +/** + * Synthesize chat-completion-style tool_calls chunks for any `function_call` + * items in `output` whose call_id was NOT already tracked via incremental + * `output_item.added`/`.done` events (`state.toolCallIdsSeen`). This dedup + * guard prevents double-emission when a provider streams incrementally AND + * `response.completed` also echoes the same function_call items in its + * output[] snapshot (standard Responses-API snapshot behavior). + * + * Mutates `state` exactly as the incremental path would (toolCallIndex, + * currentToolCallId, currentToolCallArgsBuffer, currentToolCallDeferred, + * finishReasonSent, finishReason), so downstream chunk math stays consistent. + * + * Returns the array of synthesized chunks, or `null` when there is nothing to + * synthesize (no un-seen function_call items, or finish_reason already sent) + * — the caller falls through to its own default finish_reason handling. + */ +export function synthesizeCompletedToolCalls(state, output): Record[] | null { + const outputItems = Array.isArray(output) ? output : []; + const functionCallItems = outputItems.filter( + (item) => item?.type === "function_call" && !state.toolCallIdsSeen?.has(item.call_id) + ); + + if (functionCallItems.length === 0 || state.finishReasonSent) return null; + + const synthesizedChunks: Record[] = []; + for (const fcItem of functionCallItems) { + synthesizedChunks.push(...buildToolCallChunks(state, fcItem)); + } + synthesizedChunks.push(buildFinalChunk(state)); + return synthesizedChunks; +} diff --git a/tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts b/tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts new file mode 100644 index 0000000000..b7f1436096 --- /dev/null +++ b/tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts @@ -0,0 +1,299 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Split out of translator-resp-openai-responses.test.ts (file-size ratchet — +// this file plus the new tests would have exceeded both the production and +// test-file frozen baselines). Covers the response.completed batched +// tool-call synthesis path (no prior incremental output_item.* events) and +// its dedup guard against providers that DO stream incrementally and then +// echo the same function_call items in response.completed's output[] +// snapshot (would otherwise double-emit — see openai-responses.ts's +// `toolCallIdsSeen` guard). +const { openaiResponsesToOpenAIResponse } = + await import("../../open-sse/translator/response/openai-responses.ts"); + +test("Responses -> OpenAI: response.completed with function_call in output[] synthesizes tool call chunks", () => { + const state = {}; + const result = openaiResponsesToOpenAIResponse( + { + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + model: "deepseek-v4", + output: [ + { + type: "function_call", + call_id: "call_1", + name: "read_file", + arguments: { path: "/tmp/a" }, + }, + ], + usage: { + input_tokens: 5, + output_tokens: 3, + total_tokens: 8, + }, + }, + }, + state + ); + + // Should return an array of chunks (header + args + final) + assert.ok(Array.isArray(result), "should return array of chunks"); + assert.equal(result.length, 3, "should have 3 chunks: header, args, final"); + + // First chunk: tool call header with id, type, function.name + const header = result[0]; + assert.equal(header.choices[0].delta.tool_calls[0].id, "call_1"); + assert.equal(header.choices[0].delta.tool_calls[0].type, "function"); + assert.equal(header.choices[0].delta.tool_calls[0].function.name, "read_file"); + assert.equal(header.choices[0].delta.tool_calls[0].function.arguments, ""); + assert.equal(header.choices[0].finish_reason, null); + + // Second chunk: arguments delta + const argsChunk = result[1]; + assert.equal(argsChunk.choices[0].delta.tool_calls[0].index, 0); + assert.equal( + argsChunk.choices[0].delta.tool_calls[0].function.arguments, + JSON.stringify({ path: "/tmp/a" }) + ); + assert.equal(argsChunk.choices[0].finish_reason, null); + + // Third chunk: final with finish_reason + const final = result[2]; + assert.equal(final.choices[0].finish_reason, "tool_calls"); + assert.equal(final.usage.prompt_tokens, 5); + assert.equal(final.usage.completion_tokens, 3); +}); + +test("Responses -> OpenAI: response.completed with multiple function_calls in output[]", () => { + const state = {}; + const result = openaiResponsesToOpenAIResponse( + { + type: "response.completed", + response: { + id: "resp_2", + status: "completed", + model: "deepseek-v4", + output: [ + { + type: "function_call", + call_id: "call_a", + name: "read_file", + arguments: { path: "/tmp/a" }, + }, + { + type: "function_call", + call_id: "call_b", + name: "write_file", + arguments: { path: "/tmp/b", content: "hello" }, + }, + ], + usage: { + input_tokens: 10, + output_tokens: 6, + total_tokens: 16, + }, + }, + }, + state + ); + + assert.ok(Array.isArray(result), "should return array of chunks"); + // 2 tool calls = 2 headers + 2 args + 1 final = 5 chunks + assert.equal(result.length, 5, "should have 5 chunks for 2 tool calls"); + + // First tool call header + assert.equal(result[0].choices[0].delta.tool_calls[0].id, "call_a"); + assert.equal(result[0].choices[0].delta.tool_calls[0].function.name, "read_file"); + + // Second tool call header + assert.equal(result[2].choices[0].delta.tool_calls[0].id, "call_b"); + assert.equal(result[2].choices[0].delta.tool_calls[0].function.name, "write_file"); + + // Final chunk + const final = result[4]; + assert.equal(final.choices[0].finish_reason, "tool_calls"); + assert.equal(final.usage.prompt_tokens, 10); +}); + +test("Responses -> OpenAI: response.completed without function_call in output[] still returns stop", () => { + const state = {}; + const result = openaiResponsesToOpenAIResponse( + { + type: "response.completed", + response: { + id: "resp_3", + status: "completed", + model: "deepseek-v4", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Hello!" }], + }, + ], + usage: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + }, + }, + }, + state + ); + + // Should return a single chunk (not array) with finish_reason: "stop" + assert.ok(!Array.isArray(result), "should return single chunk, not array"); + assert.equal(result.choices[0].finish_reason, "stop"); + assert.equal(result.usage.prompt_tokens, 5); +}); + +test("Responses -> OpenAI: response.completed with function_call in output[] sets assistant role on first delta", () => { + const state = {}; + const result = openaiResponsesToOpenAIResponse( + { + type: "response.completed", + response: { + id: "resp_4", + status: "completed", + model: "deepseek-v4", + output: [ + { + type: "function_call", + call_id: "call_1", + name: "read_file", + arguments: { path: "/tmp/a" }, + }, + ], + }, + }, + state + ); + + assert.ok(Array.isArray(result)); + // First chunk should have role: "assistant" in delta + assert.equal(result[0].choices[0].delta.role, "assistant"); + assert.equal(state.roleEmitted, true); +}); + +test("Responses -> OpenAI: incremental tool call events + response.completed snapshot does NOT double-emit", () => { + // Regression test: when a provider streams incrementally (output_item.added -> + // function_call_arguments.delta -> output_item.done) and then response.completed + // echoes the same function_call items in its output[] snapshot, we must NOT + // synthesize a second set of tool call chunks for already-seen call_ids. + const state = {}; + + // Phase 1: incremental events for call_a (identical to real streaming provider) + const added = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_a", name: "read_file" }, + }, + state + ); + assert.ok(added, "should emit header chunk for incremental tool call"); + assert.equal(added.choices[0].delta.tool_calls[0].id, "call_a"); + + const args = openaiResponsesToOpenAIResponse( + { + type: "response.function_call_arguments.delta", + delta: '{"path":"/tmp/a"}', + }, + state + ); + assert.ok(args, "should emit args delta chunk"); + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_a", + name: "read_file", + arguments: { path: "/tmp/a" }, + }, + }, + state + ); + + // Phase 2: incremental events for call_b + const addedB = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_b", name: "write_file" }, + }, + state + ); + assert.ok(addedB); + + openaiResponsesToOpenAIResponse( + { + type: "response.function_call_arguments.delta", + delta: '{"path":"/tmp/b","content":"hello"}', + }, + state + ); + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_b", + name: "write_file", + arguments: { path: "/tmp/b", content: "hello" }, + }, + }, + state + ); + + // Phase 3: response.completed echoes BOTH call_a and call_b in output[] + // This is the test: the guard should skip synthesis since both call_ids were + // already tracked via the incremental events above. + const completed = openaiResponsesToOpenAIResponse( + { + type: "response.completed", + response: { + id: "resp_combined", + status: "completed", + model: "deepseek-v4", + output: [ + { + type: "function_call", + call_id: "call_a", + name: "read_file", + arguments: { path: "/tmp/a" }, + }, + { + type: "function_call", + call_id: "call_b", + name: "write_file", + arguments: { path: "/tmp/b", content: "hello" }, + }, + ], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }, + }, + state + ); + + // response.completed should return a single chunk (not array of chunks) + // because both call_ids were already seen — no synthesis needed. + assert.ok(!Array.isArray(completed), "should NOT return array — no synthesis for seen call_ids"); + assert.equal( + completed.choices[0].finish_reason, + "tool_calls", + "finish_reason should still be tool_calls" + ); + assert.equal(completed.usage.prompt_tokens, 10); + assert.equal(completed.usage.completion_tokens, 5); + + // toolCallIndex should be 2 (two tool calls were processed via incremental events) + assert.equal(state.toolCallIndex, 2, "toolCallIndex should reflect both incremental tool calls"); +});