fix(ai): respect Responses replay pair order

This commit is contained in:
Roy
2026-07-26 16:33:24 +00:00
parent 6d2a8de1bb
commit 3e0ec9daec
3 changed files with 80 additions and 39 deletions
+4
View File
@@ -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
+36 -39
View File
@@ -1362,28 +1362,22 @@ export function collectComputerCallIds(messages: ResponseInput): Set<string> {
* codex provider — issue #1351 / regression of #472.
*/
export function repairOrphanResponsesToolOutputs(input: ResponseInput): ResponseInput {
const callKinds = new Map<string, ResponsesToolCallKind>();
for (const item of input) {
const kind = responsesToolCallKind(item.type);
const precedingCalls = new Set<string>();
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<string, ResponsesToolCallKind>();
for (const item of input) {
const kind = responsesToolOutputKind(item.type);
const laterOutputs = new Set<string>();
const orphanIndexes = new Set<number>();
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;
}
@@ -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);
});
});