Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/9228-codex-orphaned-tool-outputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(codex):** strip orphaned tool outputs from compacted conversations. (thanks @raflyazf)
54 changes: 54 additions & 0 deletions open-sse/executors/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,59 @@ export function stripStoredItemReferences(body: Record<string, unknown>): void {
}
}

function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): 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<string>();
let outputCount = 0;

for (const item of body.input) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const record = item as Record<string, unknown>;

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<string, unknown>).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<string, unknown>;
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<string, unknown>): void {
if (!Array.isArray(body.input)) return;

Expand Down Expand Up @@ -1319,6 +1372,7 @@ export class CodexExecutor extends BaseExecutor {
dropInternalAssistantMessages: !nativeCodexPassthrough,
});
}
stripOrphanedCodexFunctionCallOutputs(body);
repairMissingCodexFunctionCallOutputs(body);

// ── Cache-aware system prompt handling (both paths) ──
Expand Down
137 changes: 137 additions & 0 deletions tests/unit/codex-orphaned-tool-outputs-2928.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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: "" },
]);
});
Loading