diff --git a/changelog.d/fixes/9228-codex-orphaned-tool-outputs.md b/changelog.d/fixes/9228-codex-orphaned-tool-outputs.md new file mode 100644 index 0000000000..46897c0a4f --- /dev/null +++ b/changelog.d/fixes/9228-codex-orphaned-tool-outputs.md @@ -0,0 +1 @@ +- **fix(codex):** strip orphaned tool outputs from compacted conversations. (thanks @raflyazf) diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 4b16b7a85b..fb9336c784 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -307,6 +307,59 @@ export function stripStoredItemReferences(body: Record): void { } } +function stripOrphanedCodexFunctionCallOutputs(body: Record): void { + if (!Array.isArray(body.input)) return; + // A previous_response_id delegates history resolution to the upstream + // Responses service, so a matching function_call may legitimately live in + // that remote response rather than in the local input array. + if (typeof body.previous_response_id === "string" && body.previous_response_id.trim()) return; + + const callIds = new Set(); + let outputCount = 0; + + for (const item of body.input) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + + if (record.type === "function_call" && typeof record.call_id === "string") { + callIds.add(record.call_id); + } + + if (Array.isArray(record.tool_calls)) { + for (const toolCall of record.tool_calls) { + if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) continue; + const toolCallId = (toolCall as Record).id; + if (typeof toolCallId === "string") { + callIds.add(toolCallId); + } + } + } + + if (record.type === "function_call_output") { + outputCount++; + } + } + + if (outputCount === 0) return; + + const before = body.input.length; + body.input = body.input.filter((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return true; + const record = item as Record; + if (record.type === "function_call_output" && typeof record.call_id === "string") { + return callIds.has(record.call_id); + } + return true; + }); + + const removedCount = before - body.input.length; + if (removedCount > 0) { + console.debug( + `[Codex] stripOrphanedCodexFunctionCallOutputs: removed ${removedCount} orphaned function_call_output item(s)` + ); + } +} + function repairMissingCodexFunctionCallOutputs(body: Record): void { if (!Array.isArray(body.input)) return; @@ -1293,6 +1346,7 @@ export class CodexExecutor extends BaseExecutor { dropInternalAssistantMessages: !nativeCodexPassthrough, }); } + stripOrphanedCodexFunctionCallOutputs(body); repairMissingCodexFunctionCallOutputs(body); // ── Cache-aware system prompt handling (both paths) ── diff --git a/tests/unit/codex-orphaned-tool-outputs-2928.test.ts b/tests/unit/codex-orphaned-tool-outputs-2928.test.ts new file mode 100644 index 0000000000..2246af4158 --- /dev/null +++ b/tests/unit/codex-orphaned-tool-outputs-2928.test.ts @@ -0,0 +1,137 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-codex-orphaned-2928-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { CodexExecutor } = await import("../../open-sse/executors/codex.ts"); + +type InputItem = Record; + +function transform(input: InputItem[]): InputItem[] { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.6-sol", + { + _nativeCodexPassthrough: true, + model: "gpt-5.6-sol", + input, + stream: true, + }, + true, + { requestEndpointPath: "/responses" } + ); + + assert.ok(Array.isArray(result.input)); + return result.input as InputItem[]; +} + +function toolOutputs(input: InputItem[]): InputItem[] { + return input.filter((item) => item.type === "function_call_output"); +} + +test.after(() => { + core.resetDbInstance(); + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Codex strips function_call_output items without matching function calls", () => { + const result = transform([ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "hi" }], + }, + { type: "function_call_output", call_id: "call_orphan1", output: "result1" }, + { type: "function_call_output", call_id: "call_orphan2", output: "result2" }, + ]); + + assert.deepEqual(toolOutputs(result), []); +}); + +test("Codex keeps a function_call_output with a matching Responses function_call", () => { + const result = transform([ + { + type: "function_call", + call_id: "call_valid", + name: "read_file", + arguments: '{"path":"a.txt"}', + }, + { type: "function_call_output", call_id: "call_valid", output: "file contents" }, + ]); + + assert.deepEqual(toolOutputs(result), [ + { type: "function_call_output", call_id: "call_valid", output: "file contents" }, + ]); +}); + +test("Codex keeps matched outputs and removes orphaned outputs from mixed input", () => { + const result = transform([ + { type: "function_call", call_id: "call_keep", name: "read_file", arguments: "{}" }, + { type: "function_call_output", call_id: "call_keep", output: "ok" }, + { type: "function_call_output", call_id: "call_orphan", output: "orphaned" }, + ]); + + assert.deepEqual(toolOutputs(result), [ + { type: "function_call_output", call_id: "call_keep", output: "ok" }, + ]); +}); + +test("Codex recognizes embedded Chat Completions tool_calls as matching calls", () => { + const result = transform([ + { + type: "message", + role: "assistant", + content: [], + tool_calls: [ + { id: "call_cc1", type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + { type: "function_call_output", call_id: "call_cc1", output: "ok" }, + { type: "function_call_output", call_id: "call_cc2", output: "orphaned" }, + ]); + + assert.deepEqual(toolOutputs(result), [ + { type: "function_call_output", call_id: "call_cc1", output: "ok" }, + ]); +}); + +test("Codex preserves tool outputs linked to a remote previous_response_id", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.6-sol", + { + _nativeCodexPassthrough: true, + previous_response_id: "resp_remote_history", + input: [{ type: "function_call_output", call_id: "call_remote", output: "remote result" }], + stream: true, + }, + true, + { requestEndpointPath: "/responses" } + ); + + assert.deepEqual(result.input, [ + { type: "function_call_output", call_id: "call_remote", output: "remote result" }, + ]); +}); + +test("Codex leaves inputs without function_call_output items unchanged", () => { + const input = [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "hi" }], + }, + { type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" }, + ]; + + assert.deepEqual(transform(input), [ + input[0], + input[1], + { type: "function_call_output", call_id: "call_1", output: "" }, + ]); +});