From 261a9108202ddc5763268bc8d6103bb0acdb8f4c Mon Sep 17 00:00:00 2001 From: Aoxiong Yin Date: Thu, 4 Jun 2026 05:12:54 +0800 Subject: [PATCH] fix(codex): preserve native Responses passthrough tools and history (#3107) * fix(codex): preserve tool_search hosted tool * fix(codex): preserve native custom tools * fix(codex): preserve native assistant commentary history --- open-sse/executors/codex.ts | 31 ++++- open-sse/services/responsesInputSanitizer.ts | 12 +- tests/unit/executor-codex.test.ts | 127 ++++++++++++++++++- 3 files changed, 158 insertions(+), 12 deletions(-) diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 6bd80c0774..b31f96be51 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -156,9 +156,7 @@ export interface CodexQuotaSnapshot { * x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at * x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at */ -export function parseCodexQuotaHeaders( - headers: Record -): CodexQuotaSnapshot | null { +export function parseCodexQuotaHeaders(headers: Record): CodexQuotaSnapshot | null { const usage5h = headers["x-codex-5h-usage"] ?? null; const limit5h = headers["x-codex-5h-limit"] ?? null; const resetAt5h = headers["x-codex-5h-reset-at"] ?? null; @@ -426,6 +424,7 @@ function repairMissingCodexFunctionCallOutputs(body: Record): v // `{ type: "namespace", name: "mcp__atlassian__", tools: [...] }` for MCP tool groups. // Keep them through `normalizeCodexTools` so upstream can execute them. const CODEX_HOSTED_TOOL_TYPES: ReadonlySet = new Set([ + "tool_search", "image_generation", "web_search", "web_search_preview", @@ -449,7 +448,7 @@ export function isCodexFreePlan(providerSpecificData: unknown): boolean { export function normalizeCodexTools( body: Record, - options?: { dropImageGeneration?: boolean } + options?: { dropImageGeneration?: boolean; preserveCustomTools?: boolean } ): void { if (!Array.isArray(body.tools)) return; @@ -477,6 +476,18 @@ export function normalizeCodexTools( return true; } + // Native Codex clients send Responses API custom tools such as apply_patch as: + // { type: "custom", name, format }. Preserve those only on native passthrough; + // translated/non-native requests can still contain provider-specific "custom" + // shapes that the Codex backend would reject. + if (toolType === "custom" && options?.preserveCustomTools === true) { + const name = typeof tool.name === "string" ? tool.name.trim().slice(0, 128) : ""; + if (!name) return false; + tool.name = name; + validToolNames.add(name); + return true; + } + if (toolType !== "function") { const hasFunctionObject = tool.function && typeof tool.function === "object"; const hasName = typeof tool.name === "string"; @@ -533,6 +544,12 @@ export function normalizeCodexTools( !Array.isArray(functionObject.parameters) ? functionObject.parameters : { type: "object", properties: {} }; + const strict = + typeof tool.strict === "boolean" + ? tool.strict + : typeof functionObject?.strict === "boolean" + ? functionObject.strict + : undefined; // Rewrite in-place to Responses format for (const key of Object.keys(tool)) { @@ -542,6 +559,7 @@ export function normalizeCodexTools( tool.name = name.slice(0, 128); if (description) tool.description = description; tool.parameters = parameters; + if (strict !== undefined) tool.strict = strict; validToolNames.add(name); return true; @@ -1216,7 +1234,9 @@ export class CodexExecutor extends BaseExecutor { } if (Array.isArray(body.input)) { - body.input = sanitizeResponsesInputItems(body.input, false); + body.input = sanitizeResponsesInputItems(body.input, false, { + dropInternalAssistantMessages: !nativeCodexPassthrough, + }); } repairMissingCodexFunctionCallOutputs(body); @@ -1284,6 +1304,7 @@ export class CodexExecutor extends BaseExecutor { // invalid upstream, and translation bugs can leave orphaned/empty tool_choice names. normalizeCodexTools(body, { dropImageGeneration: isCodexFreePlan(credentials?.providerSpecificData), + preserveCustomTools: nativeCodexPassthrough, }); // Strip stored response item references (rs_, resp_, msg_ IDs) from input. diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts index a01d63f4a8..679345c656 100644 --- a/open-sse/services/responsesInputSanitizer.ts +++ b/open-sse/services/responsesInputSanitizer.ts @@ -1,4 +1,7 @@ type JsonRecord = Record; +type SanitizeResponsesInputOptions = { + dropInternalAssistantMessages?: boolean; +}; const INTERNAL_ASSISTANT_PHASES = new Set(["commentary"]); const SERVER_ITEM_ID_PREFIX_BY_TYPE: Record = { function_call: "fc_", @@ -67,12 +70,17 @@ function sanitizeInputItem(item: unknown): unknown { return next; } -export function sanitizeResponsesInputItems(items: readonly unknown[], clone = true): unknown[] { +export function sanitizeResponsesInputItems( + items: readonly unknown[], + clone = true, + options: SanitizeResponsesInputOptions = {} +): unknown[] { + const dropInternalAssistantMessages = options.dropInternalAssistantMessages ?? true; const sanitized: unknown[] = []; for (const item of items) { const record = toRecord(item); - if (record && isInternalAssistantMessage(record)) { + if (dropInternalAssistantMessages && record && isInternalAssistantMessage(record)) { continue; } diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 399eaff5af..55bcde5e04 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -450,7 +450,7 @@ test("CodexExecutor.transformRequest strips store from compact requests even whe assert.equal(result.instructions, "keep this"); }); -test("CodexExecutor.transformRequest strips raw internal assistant commentary without dropping useful Responses items", () => { +test("CodexExecutor.transformRequest preserves native assistant commentary history", () => { const executor = new CodexExecutor(); const body = { _nativeCodexPassthrough: true, @@ -508,7 +508,7 @@ test("CodexExecutor.transformRequest strips raw internal assistant commentary wi assert.equal( result.input.some((item) => JSON.stringify(item).includes("Need maybe inspect tool output")), - false + true ); assert.equal( result.input.some((item) => JSON.stringify(item).includes("Visible final assistant answer")), @@ -540,6 +540,46 @@ test("CodexExecutor.transformRequest strips raw internal assistant commentary wi ); }); +test("CodexExecutor.transformRequest still strips assistant commentary outside native passthrough", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.5-low", + { + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Continue." }], + }, + { + type: "message", + role: "assistant", + phase: "commentary", + content: [{ type: "output_text", text: "Internal progress note." }], + }, + { + type: "message", + role: "assistant", + phase: "final_answer", + content: [{ type: "output_text", text: "Visible final answer." }], + }, + ], + stream: false, + }, + false, + { requestEndpointPath: "/responses" } + ); + + assert.equal( + result.input.some((item) => JSON.stringify(item).includes("Internal progress note")), + false + ); + assert.equal( + result.input.some((item) => JSON.stringify(item).includes("Visible final answer")), + true + ); +}); + test("CodexExecutor.transformRequest inserts missing function_call_output items", () => { const executor = new CodexExecutor(); const result = executor.transformRequest( @@ -587,7 +627,7 @@ test("CodexExecutor.transformRequest inserts missing function_call_output items" }); }); -test("CodexExecutor.transformRequest strips internal assistant commentary before mapping messages to input", () => { +test("CodexExecutor.transformRequest preserves native assistant commentary before mapping messages to input", () => { const executor = new CodexExecutor(); const result = executor.transformRequest( "gpt-5.5-low", @@ -614,7 +654,7 @@ test("CodexExecutor.transformRequest strips internal assistant commentary before assert.equal( result.input.some((item) => JSON.stringify(item).includes("Need maybe update PR body")), - false + true ); assert.equal( result.input.some((item) => JSON.stringify(item).includes("Visible final assistant answer")), @@ -973,6 +1013,7 @@ test("CodexExecutor.transformRequest preserves namespace MCP tools and hosted to ], }, { type: "image_generation", output_format: "png" }, + { type: "tool_search" }, { type: "web_search" }, { type: "unknown_hosted_tool" }, ], @@ -983,7 +1024,13 @@ test("CodexExecutor.transformRequest preserves namespace MCP tools and hosted to ); const types = (result.tools as Array>).map((tool) => tool.type); - assert.deepEqual(types, ["function", "namespace", "image_generation", "web_search"]); + assert.deepEqual(types, [ + "function", + "namespace", + "image_generation", + "tool_search", + "web_search", + ]); const namespaceTool = (result.tools as Array>).find( (tool) => tool.type === "namespace" @@ -996,6 +1043,76 @@ test("CodexExecutor.transformRequest preserves namespace MCP tools and hosted to assert.deepEqual(result.tool_choice, { type: "function", name: "jira_get_issue" }); }); +test("CodexExecutor.transformRequest preserves native Codex custom tools", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.5", + { + _nativeCodexPassthrough: true, + model: "gpt-5.5", + input: [], + tools: [ + { + type: "custom", + name: "apply_patch", + description: "Use the apply_patch tool to edit files.", + format: { + type: "grammar", + syntax: "lark", + definition: "start: /.+/", + }, + }, + { + type: "function", + name: "exec_command", + description: "Runs a command.", + parameters: { type: "object", properties: {} }, + strict: false, + }, + ], + }, + true, + { requestEndpointPath: "/responses" } + ); + + const tools = result.tools as Array>; + assert.equal(tools.length, 2); + assert.deepEqual(tools[0], { + type: "custom", + name: "apply_patch", + description: "Use the apply_patch tool to edit files.", + format: { + type: "grammar", + syntax: "lark", + definition: "start: /.+/", + }, + }); + assert.equal(tools[1].strict, false); +}); + +test("CodexExecutor.transformRequest still drops custom tools outside native passthrough", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.5", + { + model: "gpt-5.5", + input: [], + tools: [ + { type: "custom", name: "apply_patch", format: { type: "grammar" } }, + { type: "function", name: "exec_command", parameters: { type: "object" } }, + ], + }, + true, + { requestEndpointPath: "/responses" } + ); + + const tools = result.tools as Array>; + assert.deepEqual( + tools.map((tool) => tool.name), + ["exec_command"] + ); +}); + test("CodexExecutor maps Codex websocket error events to response.failed SSE", () => { const raw = JSON.stringify({ type: "error",