From 3e0ec9daec7da71e91ab8384ac3875437eb50b83 Mon Sep 17 00:00:00 2001 From: Roy Date: Sun, 26 Jul 2026 16:33:24 +0000 Subject: [PATCH] fix(ai): respect Responses replay pair order --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/openai-shared.ts | 75 +++++++++---------- .../openai-responses-orphan-repair.test.ts | 40 ++++++++++ 3 files changed, 80 insertions(+), 39 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cd71558b2..0bf39b0f6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI Responses replay treating a tool output as paired with a matching call that appeared later in the input, or a tool call as paired with an earlier output. Pair repair now respects wire order before preserving or synthesizing each side. + ## [17.1.4] - 2026-07-26 ### Added diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 8f516a16a..d72d97ac3 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -1362,28 +1362,22 @@ export function collectComputerCallIds(messages: ResponseInput): Set { * codex provider — issue #1351 / regression of #472. */ export function repairOrphanResponsesToolOutputs(input: ResponseInput): ResponseInput { - const callKinds = new Map(); - for (const item of input) { - const kind = responsesToolCallKind(item.type); + const precedingCalls = new Set(); + let repaired: ResponseInput | undefined; + for (let index = 0; index < input.length; index++) { + const item = input[index]; + const callKind = responsesToolCallKind(item.type); const callId = responseInputCallId(item); - if (kind && callId) callKinds.set(callId, kind); - } - let hasOrphan = false; - for (const item of input) { - const kind = responsesToolOutputKind(item.type); - const callId = responseInputCallId(item); - if (kind && callId && callKinds.get(callId) !== kind) { - hasOrphan = true; - break; + if (callKind && callId) precedingCalls.add(`${callKind}\0${callId}`); + + const outputKind = responsesToolOutputKind(item.type); + if (!outputKind || !callId || precedingCalls.has(`${outputKind}\0${callId}`)) { + repaired?.push(item); + continue; } - } - if (!hasOrphan) return input; - return input.map(item => { - const kind = responsesToolOutputKind(item.type); - if (!kind) return item; - const callId = responseInputCallId(item); - if (!callId || callKinds.get(callId) === kind) return item; - const toolName = kind === "computer" ? "computer" : "tool"; + + if (!repaired) repaired = input.slice(0, index); + const toolName = outputKind === "computer" ? "computer" : "tool"; const rawOutput = "output" in item ? item.output : undefined; let text: string; if (typeof rawOutput === "string") text = rawOutput; @@ -1397,12 +1391,13 @@ export function repairOrphanResponsesToolOutputs(input: ResponseInput): Response } const ORPHAN_OUTPUT_LIMIT = 16_000; if (text.length > ORPHAN_OUTPUT_LIMIT) text = `${text.slice(0, ORPHAN_OUTPUT_LIMIT)}\n...[truncated]`; - return { + repaired.push({ type: "message", role: "assistant", content: `[Orphan ${toolName} result; call_id=${callId}]: ${text}`, - } as ResponseInput[number]; - }); + } as ResponseInput[number]); + } + return repaired ?? input; } /** Placeholder output for a tool call whose result is absent from the input. */ @@ -1424,27 +1419,29 @@ const ORPHAN_TOOL_CALL_PLACEHOLDER = * {@link repairOrphanResponsesToolOutputs}. */ export function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseInput { - const outputKinds = new Map(); - for (const item of input) { - const kind = responsesToolOutputKind(item.type); + const laterOutputs = new Set(); + const orphanIndexes = new Set(); + for (let index = input.length - 1; index >= 0; index--) { + const item = input[index]; const callId = responseInputCallId(item); - if (kind && callId) outputKinds.set(callId, kind); + const outputKind = responsesToolOutputKind(item.type); + if (outputKind && callId) laterOutputs.add(`${outputKind}\0${callId}`); + + const callKind = responsesToolCallKind(item.type); + if (callKind && callId && !laterOutputs.has(`${callKind}\0${callId}`)) orphanIndexes.add(index); } - let hasOrphan = false; - for (const item of input) { - const kind = responsesToolCallKind(item.type); - const callId = responseInputCallId(item); - if (kind && callId && outputKinds.get(callId) !== kind) { - hasOrphan = true; - break; - } - } - if (!hasOrphan) return input; + if (orphanIndexes.size === 0) return input; + const repaired: ResponseInput = []; - for (const item of input) { + for (let index = 0; index < input.length; index++) { + const item = input[index]; + if (!orphanIndexes.has(index)) { + repaired.push(item); + continue; + } const kind = responsesToolCallKind(item.type); const callId = responseInputCallId(item); - if (!kind || !callId || outputKinds.get(callId) === kind) { + if (!kind || !callId) { repaired.push(item); continue; } diff --git a/packages/ai/test/openai-responses-orphan-repair.test.ts b/packages/ai/test/openai-responses-orphan-repair.test.ts index 6a0951cba..2022fa797 100644 --- a/packages/ai/test/openai-responses-orphan-repair.test.ts +++ b/packages/ai/test/openai-responses-orphan-repair.test.ts @@ -45,6 +45,20 @@ describe("repairOrphanResponsesToolCalls", () => { expect(repaired).toBe(input); }); + it("does not pair a call with an output that appears earlier in replay order", () => { + const input: ResponseInput = [ + { type: "function_call_output", call_id: "call_a", output: "stale" } as ResponseInput[number], + { type: "function_call", call_id: "call_a", name: "read", arguments: "{}" }, + ]; + + const repaired = repairOrphanResponsesToolCalls(input); + expect(repaired.at(-1)).toMatchObject({ + type: "function_call_output", + call_id: "call_a", + output: expect.stringMatching(/interrupted/i), + }); + }); + it("composes with output repair so a tree-branch snapshot stays API-valid", () => { // Branching to a node that ends on a tool call drops the result child: // the assistant turn keeps the call, but no matching output remains. @@ -67,3 +81,29 @@ describe("repairOrphanResponsesToolCalls", () => { for (const id of callIds) expect(outputIds.has(id)).toBe(true); }); }); + +describe("repairOrphanResponsesToolOutputs", () => { + it("does not pair an output with a call that appears later in replay order", () => { + const input: ResponseInput = [ + { type: "function_call_output", call_id: "call_a", output: "stale" } as ResponseInput[number], + { type: "function_call", call_id: "call_a", name: "read", arguments: "{}" }, + ]; + + const repaired = repairOrphanResponsesToolOutputs(input); + expect(repaired[0]).toMatchObject({ + type: "message", + role: "assistant", + content: expect.stringContaining("stale"), + }); + expect(repaired[1]).toBe(input[1]); + }); + + it("returns the input unchanged when every output follows its matching call", () => { + const input: ResponseInput = [ + { type: "function_call", call_id: "call_a", name: "read", arguments: "{}" }, + { type: "function_call_output", call_id: "call_a", output: "ok" } as ResponseInput[number], + ]; + + expect(repairOrphanResponsesToolOutputs(input)).toBe(input); + }); +});