fix(ai): respect Responses replay pair order
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user