From cffd67a6316374c30cbe4396f18a8745b9aadf8d Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 17 Aug 2026 18:17:11 +0800 Subject: [PATCH 1/5] fix(responses): synthesize placeholder results for orphaned stateless tool calls DeepSeek's official Responses route is stateless and strictly validates that every function_call/local_shell_call/custom_tool_call has a matching output item in the same body. A Codex thread can reach that state when an interrupted tool turn records the call but not its late-arriving result, and the upstream then rejects every retry with a 'No tool output found for tool call' error, making the thread non-continuable. repairOrphanedInputItems already repaired orphaned outputs (output without call); extend it to synthesize an honest placeholder output immediately after each orphaned call, gated to stateless wires (forward replay keeps the prior fail-closed behavior). Mirrors the openai-chat adapter's flushPendingToolCalls wording so the model sees execution status is unknown, not a fabricated result. --- src/adapters/openai-responses.ts | 32 +++++- tests/deepseek-inbound-wire.test.ts | 11 +- ...ses-stateless-dangling-call-repair.test.ts | 101 ++++++++++++++++++ 3 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 tests/responses-stateless-dangling-call-repair.test.ts diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 27268a481b..afecdd2e44 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -572,6 +572,12 @@ function toolOutputText(output: unknown): string { * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped * (the ChatGPT backend rejects it), so the delta may carry items that reference now-absent * prior items and 400 upstream: + * - `function_call`/`local_shell_call`/`custom_tool_call` without their paired output item + * ("No tool output found for tool call "). A stateless upstream cannot resolve + * the pair from its own storage, so a placeholder output is synthesized right after the + * call to keep the turn continuable without pretending the result was real. Gated on + * `synthesizeMissingCallOutputs` (stateless wires); forward replay keeps the prior + * fail-closed behavior. * - `function_call_output`/`custom_tool_call_output` without their paired call item * ("No tool call found for function call output with call_id ..."). Converted to user * messages so the result text survives. `function_call_output` also pairs with @@ -608,16 +614,20 @@ function backfillWebSearchQueries(body: unknown): unknown { return changed ? { ...body, input } : body; } -function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknown { +function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthesizeMissingCallOutputs = false): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; const input = body.input; const functionCallIds = new Set(); const customCallIds = new Set(); + const functionOutputIds = new Set(); + const customOutputIds = new Set(); for (const item of input) { if (!isPlainObject(item) || typeof item.call_id !== "string") continue; if (item.type === "function_call" || item.type === "local_shell_call") functionCallIds.add(item.call_id); else if (item.type === "custom_tool_call") customCallIds.add(item.call_id); + else if (item.type === "function_call_output") functionOutputIds.add(item.call_id); + else if (item.type === "custom_tool_call_output") customOutputIds.add(item.call_id); } let changed = false; @@ -640,6 +650,24 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknow continue; } } + const isFnCall = item.type === "function_call" || item.type === "local_shell_call"; + const isCustomCall = item.type === "custom_tool_call"; + if (isFnCall || isCustomCall) { + repaired.push(item); + if (synthesizeMissingCallOutputs) { + const callId = typeof item.call_id === "string" ? item.call_id : ""; + const hasOutput = isFnCall ? functionOutputIds.has(callId) : customOutputIds.has(callId); + if (!hasOutput && callId) { + changed = true; + const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; + const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; + repaired.push(isFnCall + ? { type: "function_call_output", call_id: callId, output: text } + : { type: "custom_tool_call_output", call_id: callId, output: text }); + } + } + continue; + } repaired.push(item); } @@ -1375,7 +1403,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // backend gets — dropping previous_response_id is not much use if the body that // reaches the wire is unparseable. if (forward || stateless) { - outBody = repairOrphanedInputItems(outBody, unexpandedMiss); + outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless); } if (provider.requiresAdjacentResponsesToolResults === true) { outBody = normalizeResponsesToolResultAdjacency(outBody); diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 77455e67db..01d6dfd87c 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -906,7 +906,7 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(body.input).toEqual(input); }); - test("DeepSeek fails closed when a collected call has no matching result", () => { + test("DeepSeek synthesizes a placeholder result when a collected call has no matching result", () => { const callA = { type: "function_call", call_id: "call_a", name: "read_file", arguments: "{}" }; const callB = { type: "function_call", call_id: "call_b", name: "read_file", arguments: "{}" }; const outputB = { type: "function_call_output", call_id: "call_b", output: "B" }; @@ -914,7 +914,14 @@ describe("stateless Responses upstreams get no stateful parameters", () => { const input = [callA, callB, injected, outputB]; const body = buildBody(deepseekProvider(), { input }) as { input: unknown[] }; - expect(body.input).toEqual(input); + const repaired = body.input as Array>; + const callAIndex = repaired.findIndex(item => (item as { call_id?: string }).call_id === "call_a"); + const synthesized = repaired[callAIndex + 1] as Record; + expect(synthesized.type).toBe("function_call_output"); + expect(synthesized.call_id).toBe("call_a"); + expect(String(synthesized.output)).toContain("no tool result was recorded"); + // The real result for call_b survives untouched. + expect(repaired.some(item => (item as { type?: string }).type === "function_call_output" && (item as { call_id?: string }).call_id === "call_b" && (item as { output?: unknown }).output === "B")).toBe(true); }); test("DeepSeek fails closed when a collected call/result pair is backwards", () => { diff --git a/tests/responses-stateless-dangling-call-repair.test.ts b/tests/responses-stateless-dangling-call-repair.test.ts new file mode 100644 index 0000000000..59adad29f7 --- /dev/null +++ b/tests/responses-stateless-dangling-call-repair.test.ts @@ -0,0 +1,101 @@ +/** + * Stateless Responses wire repair for orphaned tool CALLS. + * + * DeepSeek's official Responses route is stateless and strict: a `function_call`, + * `local_shell_call`, or `custom_tool_call` item with no matching output item in the same + * body 400s with "No tool output found for tool call ". A Codex thread can reach + * that state when an interrupted tool turn records the call but not its late-arriving + * result. ocx already repaired orphaned OUTPUTS (output without call); these tests pin the + * mirrored repair: synthesize an honest placeholder output right after the orphaned call. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { handleResponses } from "../src/server/responses/core"; +import type { OcxConfig } from "../src/types"; + +const MODEL = "deepseek-v4-flash"; + +function deepseekProvider(): ReturnType & { apiKey: string } { + return { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }; +} + +describe("stateless Responses wire repairs orphaned tool calls", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + async function drive(input: unknown[]): Promise<{ url: string; body: Record }> { + const requests: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (inputUrl: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(inputUrl), + body: JSON.parse(String(init?.body ?? "{}")) as Record, + }); + return Response.json({ id: "resp_deepseek", object: "response", status: "completed", output: [] }); + }) as typeof fetch; + const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; + await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: MODEL, input, stream: true }), + }), + config, + { model: "", provider: "" }, + ); + return requests[0] ?? { url: "", body: {} }; + } + + test("synthesizes a function_call_output after a dangling function_call", async () => { + const { url, body } = await drive([ + { type: "function_call", id: "fc_1", call_id: "call_dangling_fn", name: "write_stdin", arguments: "{}" }, + ]); + expect(url).toBe("https://api.deepseek.com/responses"); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_dangling_fn", name: "write_stdin" }); + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_dangling_fn" }); + expect(String((input[1] as { output: unknown }).output)).toContain("no tool result was recorded"); + expect(input).toHaveLength(2); + }); + + test("synthesizes a function_call_output after a dangling local_shell_call", async () => { + const { body } = await drive([ + { type: "local_shell_call", id: "sh_1", call_id: "call_dangling_sh", status: "completed", action: { type: "exec", command: ["echo", "ok"] } }, + ]); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "local_shell_call", call_id: "call_dangling_sh" }); + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_dangling_sh" }); + expect(String((input[1] as { output: unknown }).output)).toContain("no tool result was recorded"); + }); + + test("synthesizes a custom_tool_call_output after a dangling custom_tool_call", async () => { + const { body } = await drive([ + { type: "custom_tool_call", id: "ctc_1", call_id: "call_dangling_ct", name: "custom_probe", input: "{}" }, + ]); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "custom_tool_call", call_id: "call_dangling_ct" }); + expect(input[1]).toMatchObject({ type: "custom_tool_call_output", call_id: "call_dangling_ct" }); + expect(String((input[1] as { output: unknown }).output)).toContain("no tool result was recorded"); + }); + + test("leaves intact call/output pairs untouched", async () => { + const { body } = await drive([ + { type: "function_call", id: "fc_ok", call_id: "call_ok", name: "exec_command", arguments: "{}" }, + { type: "function_call_output", call_id: "call_ok", output: "ok" }, + ]); + const input = body.input as Array>; + expect(input).toHaveLength(2); + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_ok" }); + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_ok", output: "ok" }); + expect(JSON.stringify(body)).not.toContain("no tool result was recorded"); + }); + + test("keeps converting orphan outputs to user messages (regression)", async () => { + const { body } = await drive([ + { type: "function_call_output", call_id: "call_unknown", output: "orphan result" }, + ]); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "message", role: "user" }); + expect(JSON.stringify(input[0])).toContain("orphan result"); + }); +}); From bdfc33d63af63b4a64988c64b3046564634a2dcd Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 17 Aug 2026 18:24:21 +0800 Subject: [PATCH 2/5] test(responses): pin forward-mode fail-closed behavior for dangling calls Address the CodeRabbit merge-risk note by adding explicit regression coverage that forward-authenticated replay does NOT synthesize placeholder outputs for orphaned calls: the repair is gated on statelessResponses, and these tests pin the unchanged forward wire. --- tests/responses-forward-dangling-call.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/responses-forward-dangling-call.test.ts diff --git a/tests/responses-forward-dangling-call.test.ts b/tests/responses-forward-dangling-call.test.ts new file mode 100644 index 0000000000..bfb9f4c4c0 --- /dev/null +++ b/tests/responses-forward-dangling-call.test.ts @@ -0,0 +1,52 @@ +/** + * Forward-mode replay keeps the prior fail-closed behavior for orphaned tool CALLS. + * + * The stateless-wire repair (tests/responses-stateless-dangling-call-repair.test.ts) + * synthesizes placeholder outputs only when statelessResponses is true. A forward-auth + * provider (ChatGPT backend replay) must NOT synthesize: dangling calls stay exactly as + * the client sent them so the strict upstream decides, mirroring the pre-fix contract. + */ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; +import { createTranslatorBudget } from "../src/lib/translator-budget"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +const provider = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward" as const, +}; + +function buildInput(input: unknown[]): unknown[] { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.5", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "gpt-5.5", input }, + }, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + return (JSON.parse(request.body) as { input: unknown[] }).input; +} + +describe("forward-mode replay keeps fail-closed behavior (no synthesized outputs)", () => { + test("a dangling function_call is forwarded unchanged on forward-mode replay", () => { + const input = [ + { type: "function_call", id: "fc_fwd", call_id: "call_fwd", name: "write_stdin", arguments: "{}" }, + ]; + const built = buildInput(input); + expect(built).toEqual(input); + }); + + test("a dangling custom_tool_call is forwarded unchanged on forward-mode replay", () => { + const input = [ + { type: "custom_tool_call", id: "ctc_fwd", call_id: "call_ct_fwd", name: "custom_probe", input: "{}" }, + ]; + const built = buildInput(input); + expect(built).toEqual(input); + }); +}); + From 26fa66bbd21772a4824bc70692d30d9dfd317f31 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 17 Aug 2026 18:30:29 +0800 Subject: [PATCH 3/5] fix(responses): exclude forward-auth replay from stateless placeholder synthesis CodeRabbit flagged that a provider configured with both authMode=forward and statelessResponses could receive synthesized placeholder tool outputs. Tighten the gate to stateless && !forward and add a regression test pinning that forward auth plus statelessResponses still forwards a dangling call unchanged. --- src/adapters/openai-responses.ts | 4 ++-- tests/responses-forward-dangling-call.test.ts | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index afecdd2e44..961c20599a 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -576,7 +576,7 @@ function toolOutputText(output: unknown): string { * ("No tool output found for tool call "). A stateless upstream cannot resolve * the pair from its own storage, so a placeholder output is synthesized right after the * call to keep the turn continuable without pretending the result was real. Gated on - * `synthesizeMissingCallOutputs` (stateless wires); forward replay keeps the prior + * `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps * fail-closed behavior. * - `function_call_output`/`custom_tool_call_output` without their paired call item * ("No tool call found for function call output with call_id ..."). Converted to user @@ -1403,7 +1403,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // backend gets — dropping previous_response_id is not much use if the body that // reaches the wire is unparseable. if (forward || stateless) { - outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless); + outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); } if (provider.requiresAdjacentResponsesToolResults === true) { outBody = normalizeResponsesToolResultAdjacency(outBody); diff --git a/tests/responses-forward-dangling-call.test.ts b/tests/responses-forward-dangling-call.test.ts index bfb9f4c4c0..dbc52a8288 100644 --- a/tests/responses-forward-dangling-call.test.ts +++ b/tests/responses-forward-dangling-call.test.ts @@ -48,5 +48,27 @@ describe("forward-mode replay keeps fail-closed behavior (no synthesized outputs const built = buildInput(input); expect(built).toEqual(input); }); + + test("forward auth with statelessResponses still does not synthesize (fail-closed guard)", () => { + const adapter = createResponsesPassthroughAdapter({ + ...provider, + statelessResponses: true, + }); + const input = [ + { type: "function_call", id: "fc_fwd_stateless", call_id: "call_fwd_stateless", name: "write_stdin", arguments: "{}" }, + ]; + const request = adapter.buildRequest({ + modelId: "gpt-5.5", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "gpt-5.5", input }, + }, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const built = (JSON.parse(request.body) as { input: unknown[] }).input; + // Stateless upstreams strip item ids, but the guard must not synthesize an output. + expect(built).toHaveLength(1); + expect(built[0]).toMatchObject({ type: "function_call", call_id: "call_fwd_stateless" }); + expect(JSON.stringify(request.body)).not.toContain("no tool result was recorded"); + }); }); From 6cda90cc1c84ab4a00aefacdd6c14f4177a0dfac Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Tue, 18 Aug 2026 13:46:50 +0800 Subject: [PATCH 4/5] fix(responses): keep parallel call batches intact when synthesizing missing outputs --- src/adapters/openai-responses.ts | 19 +++++++++--- tests/deepseek-inbound-wire.test.ts | 10 ++++-- ...ses-stateless-dangling-call-repair.test.ts | 31 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 961c20599a..bc7c48b877 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -574,8 +574,10 @@ function toolOutputText(output: unknown): string { * prior items and 400 upstream: * - `function_call`/`local_shell_call`/`custom_tool_call` without their paired output item * ("No tool output found for tool call "). A stateless upstream cannot resolve - * the pair from its own storage, so a placeholder output is synthesized right after the - * call to keep the turn continuable without pretending the result was real. Gated on + * the pair from its own storage, so a placeholder output is synthesized to keep the + * turn continuable without pretending the result was real. Synthetic outputs are + * deferred until after the complete parallel call batch so the adjacency normalizer can + * still recognize the batch as one reasoning-bearing assistant turn (#1477). Gated on * `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps * fail-closed behavior. * - `function_call_output`/`custom_tool_call_output` without their paired call item @@ -632,12 +634,19 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes let changed = false; const repaired: unknown[] = []; + const pendingSyntheticOutputs: unknown[] = []; + const flushPendingSyntheticOutputs = (): void => { + if (pendingSyntheticOutputs.length === 0) return; + repaired.push(...pendingSyntheticOutputs); + pendingSyntheticOutputs.length = 0; + }; for (const item of input) { - if (!isPlainObject(item)) { repaired.push(item); continue; } + if (!isPlainObject(item)) { flushPendingSyntheticOutputs(); repaired.push(item); continue; } if (dropReasoning && item.type === "reasoning") { changed = true; continue; } const isFnOutput = item.type === "function_call_output"; const isCustomOutput = item.type === "custom_tool_call_output"; if (isFnOutput || isCustomOutput) { + flushPendingSyntheticOutputs(); const callId = typeof item.call_id === "string" ? item.call_id : ""; const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId); if (!paired) { @@ -661,15 +670,17 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes changed = true; const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; - repaired.push(isFnCall + pendingSyntheticOutputs.push(isFnCall ? { type: "function_call_output", call_id: callId, output: text } : { type: "custom_tool_call_output", call_id: callId, output: text }); } } continue; } + flushPendingSyntheticOutputs(); repaired.push(item); } + flushPendingSyntheticOutputs(); return changed ? { ...body, input: repaired } : body; } diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 01d6dfd87c..1f298bacce 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -915,13 +915,17 @@ describe("stateless Responses upstreams get no stateful parameters", () => { const body = buildBody(deepseekProvider(), { input }) as { input: unknown[] }; const repaired = body.input as Array>; - const callAIndex = repaired.findIndex(item => (item as { call_id?: string }).call_id === "call_a"); - const synthesized = repaired[callAIndex + 1] as Record; + // The parallel call batch stays contiguous: the synthetic output for call_a is + // emitted after call_b, and the injected context moves after the whole batch. + expect(repaired[0]).toMatchObject({ type: "function_call", call_id: "call_a" }); + expect(repaired[1]).toMatchObject({ type: "function_call", call_id: "call_b" }); + const synthesized = repaired[2] as Record; expect(synthesized.type).toBe("function_call_output"); expect(synthesized.call_id).toBe("call_a"); expect(String(synthesized.output)).toContain("no tool result was recorded"); // The real result for call_b survives untouched. - expect(repaired.some(item => (item as { type?: string }).type === "function_call_output" && (item as { call_id?: string }).call_id === "call_b" && (item as { output?: unknown }).output === "B")).toBe(true); + expect(repaired[3]).toMatchObject({ type: "function_call_output", call_id: "call_b", output: "B" }); + expect(repaired[4]).toMatchObject({ type: "message", role: "developer" }); }); test("DeepSeek fails closed when a collected call/result pair is backwards", () => { diff --git a/tests/responses-stateless-dangling-call-repair.test.ts b/tests/responses-stateless-dangling-call-repair.test.ts index 59adad29f7..51855adf64 100644 --- a/tests/responses-stateless-dangling-call-repair.test.ts +++ b/tests/responses-stateless-dangling-call-repair.test.ts @@ -78,6 +78,37 @@ describe("stateless Responses wire repairs orphaned tool calls", () => { expect(String((input[1] as { output: unknown }).output)).toContain("no tool result was recorded"); }); + test("keeps a parallel call batch together before synthesizing a missing output", async () => { + const { body } = await drive([ + { type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "thinking" }] }, + { type: "function_call", id: "fc_a", call_id: "call_a", name: "exec_command", arguments: "{}" }, + { type: "function_call", id: "fc_b", call_id: "call_b", name: "exec_command", arguments: "{}" }, + { type: "function_call_output", call_id: "call_b", output: "ok" }, + ]); + const input = body.input as Array>; + expect(input).toHaveLength(5); + expect(input[0]).toMatchObject({ type: "reasoning" }); + expect(input[1]).toMatchObject({ type: "function_call", call_id: "call_a" }); + expect(input[2]).toMatchObject({ type: "function_call", call_id: "call_b" }); + expect(input[3]).toMatchObject({ type: "function_call_output", call_id: "call_a" }); + expect(String((input[3] as { output: unknown }).output)).toContain("no tool result was recorded"); + expect(input[4]).toMatchObject({ type: "function_call_output", call_id: "call_b", output: "ok" }); + }); + + test("synthesizes missing outputs after the whole parallel batch", async () => { + const { body } = await drive([ + { type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "thinking" }] }, + { type: "function_call", id: "fc_a", call_id: "call_a", name: "exec_command", arguments: "{}" }, + { type: "function_call", id: "fc_b", call_id: "call_b", name: "exec_command", arguments: "{}" }, + ]); + const input = body.input as Array>; + expect(input).toHaveLength(5); + expect(input[1]).toMatchObject({ type: "function_call", call_id: "call_a" }); + expect(input[2]).toMatchObject({ type: "function_call", call_id: "call_b" }); + expect(input[3]).toMatchObject({ type: "function_call_output", call_id: "call_a" }); + expect(input[4]).toMatchObject({ type: "function_call_output", call_id: "call_b" }); + }); + test("leaves intact call/output pairs untouched", async () => { const { body } = await drive([ { type: "function_call", id: "fc_ok", call_id: "call_ok", name: "exec_command", arguments: "{}" }, From 90c0bd23461e34f38980eeff3c5146d2f96b5da5 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Tue, 18 Aug 2026 13:57:38 +0800 Subject: [PATCH 5/5] fix(responses): emit synthetic outputs in call order for parallel batches --- src/adapters/openai-responses.ts | 61 ++++++++++++++++++- ...ses-stateless-dangling-call-repair.test.ts | 16 +++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index bc7c48b877..ff79a60b8b 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -576,8 +576,9 @@ function toolOutputText(output: unknown): string { * ("No tool output found for tool call "). A stateless upstream cannot resolve * the pair from its own storage, so a placeholder output is synthesized to keep the * turn continuable without pretending the result was real. Synthetic outputs are - * deferred until after the complete parallel call batch so the adjacency normalizer can - * still recognize the batch as one reasoning-bearing assistant turn (#1477). Gated on + * emitted after the complete parallel call batch, in call order alongside any real + * outputs, so the adjacency normalizer can still recognize the batch as one + * reasoning-bearing assistant turn (#1477). Gated on * `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps * fail-closed behavior. * - `function_call_output`/`custom_tool_call_output` without their paired call item @@ -634,6 +635,7 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes let changed = false; const repaired: unknown[] = []; + const syntheticKeys = new Set(); const pendingSyntheticOutputs: unknown[] = []; const flushPendingSyntheticOutputs = (): void => { if (pendingSyntheticOutputs.length === 0) return; @@ -670,6 +672,7 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes changed = true; const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; + syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`); pendingSyntheticOutputs.push(isFnCall ? { type: "function_call_output", call_id: callId, output: text } : { type: "custom_tool_call_output", call_id: callId, output: text }); @@ -682,7 +685,59 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes } flushPendingSyntheticOutputs(); - return changed ? { ...body, input: repaired } : body; + const callKeyOf = (item: unknown): string | null => { + if (!isPlainObject(item) || typeof item.call_id !== "string") return null; + if (item.type === "function_call" || item.type === "local_shell_call") return `function:${item.call_id}`; + if (item.type === "custom_tool_call") return `custom:${item.call_id}`; + return null; + }; + const outputKeyOf = (item: unknown): string | null => { + if (!isPlainObject(item) || typeof item.call_id !== "string") return null; + if (item.type === "function_call_output") return `function:${item.call_id}`; + if (item.type === "custom_tool_call_output") return `custom:${item.call_id}`; + return null; + }; + const reorderBatchOutputs = (items: unknown[]): unknown[] => { + const ordered: unknown[] = []; + let index = 0; + while (index < items.length) { + const key = callKeyOf(items[index]); + if (key === null) { ordered.push(items[index]); index += 1; continue; } + const batch: unknown[] = []; + const batchKeys: string[] = []; + let cursor = index; + while (cursor < items.length) { + const nextKey = callKeyOf(items[cursor]); + if (nextKey === null) break; + batch.push(items[cursor]); + batchKeys.push(nextKey); + cursor += 1; + } + const hasSynthetic = batchKeys.some(batchKey => syntheticKeys.has(batchKey)); + if (!hasSynthetic) { + ordered.push(...batch); + index = cursor; + continue; + } + const remainder: unknown[] = []; + const batchOutputs: Array<{ key: string; item: unknown }> = []; + for (let probe = cursor; probe < items.length; probe += 1) { + const outputKey = outputKeyOf(items[probe]); + if (outputKey !== null && batchKeys.includes(outputKey)) { + batchOutputs.push({ key: outputKey, item: items[probe] }); + } else { + remainder.push(items[probe]); + } + } + batchOutputs.sort((left, right) => batchKeys.indexOf(left.key) - batchKeys.indexOf(right.key)); + ordered.push(...batch, ...batchOutputs.map(output => output.item)); + ordered.push(...reorderBatchOutputs(remainder)); + return ordered; + } + return ordered; + }; + + return changed ? { ...body, input: reorderBatchOutputs(repaired) } : body; } /** diff --git a/tests/responses-stateless-dangling-call-repair.test.ts b/tests/responses-stateless-dangling-call-repair.test.ts index 51855adf64..8ef39259d3 100644 --- a/tests/responses-stateless-dangling-call-repair.test.ts +++ b/tests/responses-stateless-dangling-call-repair.test.ts @@ -109,6 +109,22 @@ describe("stateless Responses wire repairs orphaned tool calls", () => { expect(input[4]).toMatchObject({ type: "function_call_output", call_id: "call_b" }); }); + test("emits a synthetic output in call order after an earlier real output", async () => { + const { body } = await drive([ + { type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "thinking" }] }, + { type: "function_call", id: "fc_a", call_id: "call_a", name: "exec_command", arguments: "{}" }, + { type: "function_call", id: "fc_b", call_id: "call_b", name: "exec_command", arguments: "{}" }, + { type: "function_call_output", call_id: "call_a", output: "A" }, + ]); + const input = body.input as Array>; + expect(input).toHaveLength(5); + expect(input[1]).toMatchObject({ type: "function_call", call_id: "call_a" }); + expect(input[2]).toMatchObject({ type: "function_call", call_id: "call_b" }); + expect(input[3]).toMatchObject({ type: "function_call_output", call_id: "call_a", output: "A" }); + expect(input[4]).toMatchObject({ type: "function_call_output", call_id: "call_b" }); + expect(String((input[4] as { output: unknown }).output)).toContain("no tool result was recorded"); + }); + test("leaves intact call/output pairs untouched", async () => { const { body } = await drive([ { type: "function_call", id: "fc_ok", call_id: "call_ok", name: "exec_command", arguments: "{}" },