From feb50c4934f85eace78a9316a720b3c6c5727b34 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 12:45:16 -0700 Subject: [PATCH 1/3] Fix Responses adapter hygiene defects (CL-6912) - signatureForModel no longer drops reasoning when a persisted turn is missing its model field; a genuine model mismatch now also drops the function_call items that reasoning produced, avoiding the orphaned shape that degenerates reasoning models - dedupeToolOutputs (renamed dedupeToolItems) keeps the latest function_call/function_call_output on a duplicate call_id instead of the stale first one, and now covers duplicate function_call items too - the Responses block indexer is recreated per buildRequest instead of once per adapter instance, fixing an unbounded per-conversation leak - all three Responses adapters route tool names through the shared encode/decode codec instead of sending raw package-qualified ids - openai-compatible only re-parses SSE frames for DeepSeek/NIM models; every other frame hits the base parser once instead of twice --- src/provider/codex-responses-adapter.test.ts | 154 ++++++++++++++++++ src/provider/codex-responses-adapter.ts | 51 ++++-- src/provider/grok-responses-adapter.test.ts | 46 ++++++ src/provider/grok-responses-adapter.ts | 53 ++++-- .../openai-compatible-adapter.test.ts | 24 +++ src/provider/openai-compatible-adapter.ts | 7 + src/provider/openai-responses-adapter.ts | 53 ++++-- tests/unit/grok-responses-adapter.test.ts | 4 +- 8 files changed, 348 insertions(+), 44 deletions(-) diff --git a/src/provider/codex-responses-adapter.test.ts b/src/provider/codex-responses-adapter.test.ts index c22cbeb72..35314c619 100644 --- a/src/provider/codex-responses-adapter.test.ts +++ b/src/provider/codex-responses-adapter.test.ts @@ -4,6 +4,8 @@ import { PRODUCT_NAME } from "../branding.js"; import { createCodexResponsesAdapter, isResponsesStreamTerminal, + signatureForModel, + tagSignature, } from "./codex-responses-adapter.js"; const source: LastCycleSource = { @@ -134,6 +136,158 @@ describe("createCodexResponsesAdapter usage parsing", () => { }); }); +describe("signatureForModel", () => { + const turnWithModel = (model: string | undefined): ConversationTurn => + ({ + role: "assistant", + model, + content: [], + timestamp: 0, + }) as unknown as ConversationTurn; + + test("replays a signature on a turn with no persisted model", () => { + const signature = tagSignature("codex-responses", "cipher"); + const result = signatureForModel( + turnWithModel(undefined), + "gpt-5.1-codex", + "codex-responses", + signature, + ); + expect(result).toBe("cipher"); + }); + + test("drops a signature when the turn's model genuinely differs", () => { + const signature = tagSignature("codex-responses", "cipher"); + const result = signatureForModel( + turnWithModel("gpt-5.0-codex"), + "gpt-5.1-codex", + "codex-responses", + signature, + ); + expect(result).toBeUndefined(); + }); + + test("replays a signature when the model matches", () => { + const signature = tagSignature("codex-responses", "cipher"); + const result = signatureForModel( + turnWithModel("gpt-5.1-codex"), + "gpt-5.1-codex", + "codex-responses", + signature, + ); + expect(result).toBe("cipher"); + }); +}); + +describe("createCodexResponsesAdapter orphaned function_call suppression", () => { + test("drops a function_call whose reasoning signature could not be replayed", () => { + const adapter = createCodexResponsesAdapter(source); + const turns: ConversationTurn[] = [ + { role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + model: "gpt-5.0-codex", + timestamp: 0, + content: [ + { type: "thinking", thinking: "ponder", signature: tagSignature("codex-responses", "c") }, + { type: "tool_call", id: "call_1", name: "shell", arguments: {} }, + ], + }, + ] as unknown as ConversationTurn[]; + + const request = adapter.buildRequest(turns, "gpt-5.1-codex", {}); + const body = JSON.parse(request.body) as { input: { type: string }[] }; + + expect(body.input.some((item) => item.type === "reasoning")).toBe(false); + expect(body.input.some((item) => item.type === "function_call")).toBe(false); + }); + + test("keeps the function_call when its reasoning signature replays cleanly", () => { + const adapter = createCodexResponsesAdapter(source); + const turns: ConversationTurn[] = [ + { role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + model: "gpt-5.1-codex", + timestamp: 0, + content: [ + { type: "thinking", thinking: "ponder", signature: tagSignature("codex-responses", "c") }, + { type: "tool_call", id: "call_1", name: "shell", arguments: {} }, + ], + }, + ] as unknown as ConversationTurn[]; + + const request = adapter.buildRequest(turns, "gpt-5.1-codex", {}); + const body = JSON.parse(request.body) as { input: { type: string }[] }; + + expect(body.input.some((item) => item.type === "reasoning")).toBe(true); + expect(body.input.some((item) => item.type === "function_call")).toBe(true); + }); +}); + +describe("createCodexResponsesAdapter tool-name codec", () => { + test("encodes a non-wire-safe tool name on the outgoing function tool definition", () => { + const adapter = createCodexResponsesAdapter(source); + const turns: ConversationTurn[] = [ + { role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] }, + ]; + + const request = adapter.buildRequest(turns, "gpt-5.1-codex", { + tools: [ + { + name: "@intx/tools-posix/sidecar-bundle:run_shell", + description: "run a shell command", + inputSchema: {}, + }, + ], + } as never); + const body = JSON.parse(request.body) as { tools: { name: string }[] }; + + expect(body.tools[0]?.name).toMatch(/^[A-Za-z_][A-Za-z0-9_-]*$/); + expect(body.tools[0]?.name).not.toBe("@intx/tools-posix/sidecar-bundle:run_shell"); + }); + + test("decodes an encoded tool_call.start name back to the internal id", () => { + const adapter = createCodexResponsesAdapter(source); + const encoded = "IX_-40intx-2Ftools-2Dposix-2Fsidecar-2Dbundle-3Arun_shell"; + const sseData = JSON.stringify({ + type: "response.output_item.added", + item: { type: "function_call", id: "item_1", call_id: "call_1", name: encoded }, + }); + + const events = adapter.parseResponse(sseData); + const start = events.find((e) => e.type === "inference.tool_call.start"); + + expect((start?.data as { name?: string })?.name).not.toBe(encoded); + }); +}); + +describe("createCodexResponsesAdapter block indexer reset", () => { + test("resets block indices on a new buildRequest instead of accumulating across requests", () => { + const adapter = createCodexResponsesAdapter(source); + const turns: ConversationTurn[] = [ + { role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] }, + ]; + + adapter.buildRequest(turns, "gpt-5.1-codex", {}); + adapter.parseResponse( + JSON.stringify({ type: "response.output_text.delta", item_id: "item_1", delta: "a" }), + ); + adapter.parseResponse( + JSON.stringify({ type: "response.output_text.delta", item_id: "item_2", delta: "b" }), + ); + + // A new request (a fresh HTTP round trip) with a brand-new item id should + // start indexing from 0 again, not continue accumulating from the prior + // request's indexer state. + adapter.buildRequest(turns, "gpt-5.1-codex", {}); + const secondRequestDelta = adapter.parseResponse( + JSON.stringify({ type: "response.output_text.delta", item_id: "item_3", delta: "c" }), + ); + expect((secondRequestDelta[0]?.data as { index?: number })?.index).toBe(0); + }); +}); + describe("isResponsesStreamTerminal", () => { test("is true for the Responses end-of-turn events", () => { for (const type of ["response.completed", "response.incomplete", "response.done"]) { diff --git a/src/provider/codex-responses-adapter.ts b/src/provider/codex-responses-adapter.ts index 32dfe1a55..73339ef2a 100644 --- a/src/provider/codex-responses-adapter.ts +++ b/src/provider/codex-responses-adapter.ts @@ -1,8 +1,11 @@ import { BEARER_CREDENTIAL_SENTINEL, ProtocolMismatchError, + decodeToolName, + encodeToolName, type BuiltRequest, type ProviderAdapter, + type ToolNameLimit, } from "@intx/inference"; import type { ContentBlock, @@ -157,7 +160,11 @@ export function signatureForModel( requestProvider: string, signature: string, ): string | undefined { - if (turn.model !== requestModel) return undefined; + // `model` is optional on the persisted turn schema; a turn saved before that + // field existed (or otherwise missing it) is not evidence of a model + // switch — treat the absence as benign and fall through to the provider + // check, rather than dropping reasoning that never actually crossed models. + if (turn.model !== undefined && turn.model !== requestModel) return undefined; const tagged = untagSignature(signature); if (tagged === undefined) return undefined; return tagged.provider === requestProvider ? tagged.encryptedContent : undefined; @@ -171,6 +178,14 @@ export function signatureForModel( // (held in a thinking block's signature) AND that backend is the one this // request is going to — replaying it to a different provider gets a 400 it // cannot recover from. +// Wire-charset limit for function names on the Responses surface (Codex, +// Grok, and the generic OpenAI Responses adapter all share OpenAI's +// `^[a-zA-Z0-9_-]{1,64}$` function-name charset). +export const RESPONSES_TOOL_NAME_LIMIT: ToolNameLimit = { + provider: "responses", + maxLength: 64, +}; + function toResponsesItems( turn: ConversationTurn, requestModel: string, @@ -180,11 +195,20 @@ function toResponsesItems( const textKind: "input_text" | "output_text" = turn.role === "assistant" ? "output_text" : "input_text"; const textParts: ResponsesContentPart[] = []; + // A reasoning block whose signature we could not replay (foreign provider, + // model switch, or a missing/untagged signature) leaves any function_call + // it produced without the reasoning item the Responses API expects to + // precede it — the exact orphaned shape that degenerates reasoning models. + // Suppress function_call items until the next text or successfully-replayed + // reasoning item re-establishes a clean turn shape; tool results are + // unaffected since they never need a preceding reasoning item. + let suppressOrphanedCalls = false; const flushText = (): void => { if (textParts.length > 0) { items.push({ type: "message", role: turn.role, content: [...textParts] }); textParts.length = 0; + suppressOrphanedCalls = false; } }; @@ -206,15 +230,17 @@ function toResponsesItems( } as ResponsesContentPart); } } else if (block.type === "tool_call") { + if (suppressOrphanedCalls) continue; flushText(); items.push({ type: "function_call", - name: block.name, + name: encodeToolName(block.name, RESPONSES_TOOL_NAME_LIMIT), arguments: JSON.stringify(block.arguments ?? {}), call_id: block.id, }); } else if (block.type === "tool_result") { flushText(); + suppressOrphanedCalls = false; items.push({ type: "function_call_output", call_id: block.callId, @@ -234,6 +260,9 @@ function toResponsesItems( ); if (encryptedContent !== undefined) { items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); + suppressOrphanedCalls = false; + } else { + suppressOrphanedCalls = true; } } } @@ -260,7 +289,7 @@ function toResponsesTools(options: InferenceOptions): unknown[] | undefined { // `type`, not nested under a `function` key (unlike Chat Completions). return options.tools.map((t) => ({ type: "function", - name: t.name, + name: encodeToolName(t.name, RESPONSES_TOOL_NAME_LIMIT), description: t.description, parameters: t.inputSchema, })); @@ -466,7 +495,7 @@ export function parseResponse( seq, data: { callId, - name, + name: decodeToolName(name), partial: EMPTY_PARTIAL, index: blockIndexFor(indexer, itemId, "tool_call"), }, @@ -608,13 +637,15 @@ export function isResponsesStreamTerminal(sseData: string): boolean { } export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAdapter { - const indexer: CodexBlockIndexer = { - nextIndex: 0, - items: new Map(), - }; + // Re-created per request in buildRequest, not just once here — otherwise + // block indices accumulate across every request the adapter instance ever + // serves, growing the map for the life of the conversation. + let indexer: CodexBlockIndexer = createResponsesBlockIndexer(); return { - buildRequest: (messages, model, options) => - buildRequest(messages, model, options, source.provider), + buildRequest: (messages, model, options) => { + indexer = createResponsesBlockIndexer(); + return buildRequest(messages, model, options, source.provider); + }, parseResponse: (sseData) => parseResponse(sseData, indexer, source), parseJSONResponse, isStreamTerminal: isResponsesStreamTerminal, diff --git a/src/provider/grok-responses-adapter.test.ts b/src/provider/grok-responses-adapter.test.ts index 90cf97349..26a44bb17 100644 --- a/src/provider/grok-responses-adapter.test.ts +++ b/src/provider/grok-responses-adapter.test.ts @@ -98,6 +98,52 @@ describe("createGrokResponsesAdapter", () => { expect(body.reasoning).toEqual({ effort: "low", summary: "detailed" }); }); + test("keeps the latest function_call_output on a duplicate call_id", () => { + const adapter = createGrokResponsesAdapter(source); + const turns: ConversationTurn[] = [ + { + role: "user", + timestamp: 0, + content: [ + { type: "tool_result", callId: "call_1", content: [{ type: "text", text: "stale" }] }, + { type: "tool_result", callId: "call_1", content: [{ type: "text", text: "fresh" }] }, + ], + }, + ] as unknown as ConversationTurn[]; + + const request = adapter.buildRequest(turns, "grok-4.5", {}); + const body = JSON.parse(request.body) as { + input: { type: string; call_id?: string; output?: string }[]; + }; + const outputs = body.input.filter((item) => item.type === "function_call_output"); + + expect(outputs).toHaveLength(1); + expect(outputs[0]?.output).toBe("fresh"); + }); + + test("dedupes a duplicate function_call on the same call_id", () => { + const adapter = createGrokResponsesAdapter(source); + const turns: ConversationTurn[] = [ + { + role: "assistant", + timestamp: 0, + content: [ + { type: "tool_call", id: "call_1", name: "shell", arguments: { a: 1 } }, + { type: "tool_call", id: "call_1", name: "shell", arguments: { a: 2 } }, + ], + }, + ] as unknown as ConversationTurn[]; + + const request = adapter.buildRequest(turns, "grok-4.5", {}); + const body = JSON.parse(request.body) as { + input: { type: string; call_id?: string; arguments?: string }[]; + }; + const calls = body.input.filter((item) => item.type === "function_call"); + + expect(calls).toHaveLength(1); + expect(calls[0]?.arguments).toBe(JSON.stringify({ a: 2 })); + }); + test("does not invent high when no reasoning_effort is set", () => { const adapter = createGrokResponsesAdapter(source); const turns: ConversationTurn[] = [ diff --git a/src/provider/grok-responses-adapter.ts b/src/provider/grok-responses-adapter.ts index 14f56dcdf..2cca85ae8 100644 --- a/src/provider/grok-responses-adapter.ts +++ b/src/provider/grok-responses-adapter.ts @@ -1,5 +1,6 @@ import { BEARER_CREDENTIAL_SENTINEL, + encodeToolName, type BuiltRequest, type ProviderAdapter, } from "@intx/inference"; @@ -16,6 +17,7 @@ import { XAI_USER_AGENT, } from "../auth/xai/constants.js"; import { + RESPONSES_TOOL_NAME_LIMIT, createResponsesBlockIndexer, parseJSONResponse, parseResponse, @@ -70,6 +72,9 @@ function toResponsesItems( const role = turn.role; const parts: ResponsesInputContentPart[] = []; let hasImage = false; + // See codex-responses-adapter.ts toResponsesItems for why an unreplayed + // reasoning item suppresses the function_call(s) it produced. + let suppressOrphanedCalls = false; const flushMessage = (): void => { if (parts.length === 0) return; @@ -82,6 +87,7 @@ function toResponsesItems( }); parts.length = 0; hasImage = false; + suppressOrphanedCalls = false; }; for (const block of turn.content) { @@ -104,15 +110,17 @@ function toResponsesItems( }); } } else if (block.type === "tool_call") { + if (suppressOrphanedCalls) continue; flushMessage(); items.push({ type: "function_call", - name: block.name, + name: encodeToolName(block.name, RESPONSES_TOOL_NAME_LIMIT), arguments: JSON.stringify(block.arguments ?? {}), call_id: block.id, }); } else if (block.type === "tool_result") { flushMessage(); + suppressOrphanedCalls = false; items.push({ type: "function_call_output", call_id: block.callId, @@ -132,6 +140,9 @@ function toResponsesItems( ); if (encryptedContent !== undefined) { items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); + suppressOrphanedCalls = false; + } else { + suppressOrphanedCalls = true; } } } @@ -143,7 +154,7 @@ function toResponsesTools(options: InferenceOptions): unknown[] | undefined { if (options.tools === undefined || options.tools.length === 0) return undefined; return options.tools.map((t) => ({ type: "function", - name: t.name, + name: encodeToolName(t.name, RESPONSES_TOOL_NAME_LIMIT), description: t.description, parameters: t.inputSchema, })); @@ -154,17 +165,24 @@ function optionString(options: InferenceOptions, key: string): string | undefine return typeof value === "string" && value.length > 0 ? value : undefined; } -function dedupeToolOutputs(items: ResponsesInputItem[]): ResponsesInputItem[] { - const seen = new Set(); - const deduped: ResponsesInputItem[] = []; - for (const item of items) { - if (item.type === "function_call_output") { - if (seen.has(item.call_id)) continue; - seen.add(item.call_id); +// Keeps the LAST occurrence of each duplicate function_call / function_call_output +// call_id, not the first: a duplicate is most often a corrected retry, and +// discarding the retry in favor of the stale original silently replays the +// wrong tool result. Both item types are covered — a duplicated function_call +// is just as invalid on the wire as a duplicated output. +function dedupeToolItems(items: ResponsesInputItem[]): ResponsesInputItem[] { + const lastIndexForCall = new Map(); + items.forEach((item, i) => { + if (item.type === "function_call" || item.type === "function_call_output") { + lastIndexForCall.set(`${item.type}:${item.call_id}`, i); } - deduped.push(item); - } - return deduped; + }); + return items.filter((item, i) => { + if (item.type === "function_call" || item.type === "function_call_output") { + return lastIndexForCall.get(`${item.type}:${item.call_id}`) === i; + } + return true; + }); } function buildRequest( @@ -173,7 +191,7 @@ function buildRequest( options: InferenceOptions, requestProvider: string, ): BuiltRequest { - const conversation = dedupeToolOutputs( + const conversation = dedupeToolItems( messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)), ); const systemMessage: ResponsesInputItem | undefined = @@ -224,10 +242,13 @@ function buildRequest( } export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAdapter { - const indexer = createResponsesBlockIndexer(); + // Re-created per request in buildRequest — see codex-responses-adapter.ts. + let indexer = createResponsesBlockIndexer(); return { - buildRequest: (messages, model, options) => - buildRequest(messages, model, options, source.provider), + buildRequest: (messages, model, options) => { + indexer = createResponsesBlockIndexer(); + return buildRequest(messages, model, options, source.provider); + }, parseResponse: (sseData) => parseResponse(sseData, indexer, source, GROK_RESPONSES_PROVIDER), parseJSONResponse, }; diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index a5924a6f8..29f628820 100644 --- a/src/provider/openai-compatible-adapter.test.ts +++ b/src/provider/openai-compatible-adapter.test.ts @@ -56,6 +56,30 @@ describe("openai-compatible adapter providerOptions passthrough", () => { }); }); +describe("openai-compatible adapter SSE parse count", () => { + test("parses a non-DeepSeek frame with JSON.parse exactly once", () => { + const adapter = createOpenAICompatibleAdapter(source); + adapter.buildRequest(messages, "gpt-5.1", {} as InferenceOptions); + + const sseData = JSON.stringify({ + choices: [{ delta: { role: "assistant", content: "hi" } }], + }); + const originalParse = JSON.parse; + let calls = 0; + JSON.parse = ((text: string, reviver?: unknown) => { + calls += 1; + return (originalParse as (t: string, r?: unknown) => unknown)(text, reviver); + }) as typeof JSON.parse; + try { + adapter.parseResponse(sseData); + } finally { + JSON.parse = originalParse; + } + + expect(calls).toBe(1); + }); +}); + describe("openai-compatible adapter reasoning_content handling", () => { const withThinking: ConversationTurn[] = [ { role: "user", content: [{ type: "text", text: "hi" }] }, diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index ed6bbf7aa..e752d9794 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -17,6 +17,11 @@ type AdapterSource = Parameters[0]; export function createOpenAICompatibleAdapter(source: AdapterSource): ProviderAdapter { const base = createOpenAIAdapter(source); + // Set by buildRequest for the model the current request targets; only + // DeepSeek/NIM streams need the null-delta-field patch below, so every + // other provider's frames skip the reparse and hit base.parseResponse + // exactly once instead of twice. + let needsDeepSeekPatch = false; const ensureAccept = (req: BuiltRequest): BuiltRequest => { const has = req.headers.Accept || req.headers.accept; @@ -35,6 +40,7 @@ export function createOpenAICompatibleAdapter(source: AdapterSource): ProviderAd // DeepSeek returns HTTP 400 if `reasoning_content` appears in input messages, // whereas the base adapter emits it for any model with thinking enabled. const stripReasoning = model.toLowerCase().includes("deepseek"); + needsDeepSeekPatch = stripReasoning; if (!hasProviderOptions && !stripReasoning) return ensureAccept(built); const body = JSON.parse(built.body) as Record; @@ -54,6 +60,7 @@ export function createOpenAICompatibleAdapter(source: AdapterSource): ProviderAd // legitimately accept null (content, reasoning_content, etc.) are left alone. const NULL_REJECTED_DELTA_FIELDS = new Set(["role", "tool_calls"]); const parseResponse: ProviderAdapter["parseResponse"] = (sseData: string) => { + if (!needsDeepSeekPatch) return base.parseResponse(sseData); let data = sseData; try { const parsed = JSON.parse(sseData) as Record; diff --git a/src/provider/openai-responses-adapter.ts b/src/provider/openai-responses-adapter.ts index b0092d927..19490e47c 100644 --- a/src/provider/openai-responses-adapter.ts +++ b/src/provider/openai-responses-adapter.ts @@ -1,5 +1,6 @@ import { BEARER_CREDENTIAL_SENTINEL, + encodeToolName, type BuiltRequest, type ProviderAdapter, } from "@intx/inference"; @@ -10,6 +11,7 @@ import type { LastCycleSource, } from "@intx/types/runtime"; import { + RESPONSES_TOOL_NAME_LIMIT, createResponsesBlockIndexer, isResponsesStreamTerminal, parseJSONResponse, @@ -58,6 +60,9 @@ function toResponsesItems( const role = turn.role; const parts: ResponsesInputContentPart[] = []; let hasImage = false; + // See codex-responses-adapter.ts toResponsesItems for why an unreplayed + // reasoning item suppresses the function_call(s) it produced. + let suppressOrphanedCalls = false; const flushMessage = (): void => { if (parts.length === 0) return; @@ -70,6 +75,7 @@ function toResponsesItems( }); parts.length = 0; hasImage = false; + suppressOrphanedCalls = false; }; for (const block of turn.content) { @@ -92,15 +98,17 @@ function toResponsesItems( }); } } else if (block.type === "tool_call") { + if (suppressOrphanedCalls) continue; flushMessage(); items.push({ type: "function_call", - name: block.name, + name: encodeToolName(block.name, RESPONSES_TOOL_NAME_LIMIT), arguments: JSON.stringify(block.arguments ?? {}), call_id: block.id, }); } else if (block.type === "tool_result") { flushMessage(); + suppressOrphanedCalls = false; items.push({ type: "function_call_output", call_id: block.callId, @@ -120,6 +128,9 @@ function toResponsesItems( ); if (encryptedContent !== undefined) { items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); + suppressOrphanedCalls = false; + } else { + suppressOrphanedCalls = true; } } } @@ -131,7 +142,7 @@ function toResponsesTools(options: InferenceOptions): unknown[] | undefined { if (options.tools === undefined || options.tools.length === 0) return undefined; return options.tools.map((t) => ({ type: "function", - name: t.name, + name: encodeToolName(t.name, RESPONSES_TOOL_NAME_LIMIT), description: t.description, parameters: t.inputSchema, })); @@ -142,17 +153,24 @@ function optionString(options: InferenceOptions, key: string): string | undefine return typeof value === "string" && value.length > 0 ? value : undefined; } -function dedupeToolOutputs(items: ResponsesInputItem[]): ResponsesInputItem[] { - const seen = new Set(); - const deduped: ResponsesInputItem[] = []; - for (const item of items) { - if (item.type === "function_call_output") { - if (seen.has(item.call_id)) continue; - seen.add(item.call_id); +// Keeps the LAST occurrence of each duplicate function_call / function_call_output +// call_id, not the first: a duplicate is most often a corrected retry, and +// discarding the retry in favor of the stale original silently replays the +// wrong tool result. Both item types are covered — a duplicated function_call +// is just as invalid on the wire as a duplicated output. +function dedupeToolItems(items: ResponsesInputItem[]): ResponsesInputItem[] { + const lastIndexForCall = new Map(); + items.forEach((item, i) => { + if (item.type === "function_call" || item.type === "function_call_output") { + lastIndexForCall.set(`${item.type}:${item.call_id}`, i); } - deduped.push(item); - } - return deduped; + }); + return items.filter((item, i) => { + if (item.type === "function_call" || item.type === "function_call_output") { + return lastIndexForCall.get(`${item.type}:${item.call_id}`) === i; + } + return true; + }); } function buildRequest( @@ -161,7 +179,7 @@ function buildRequest( options: InferenceOptions, requestProvider: string, ): BuiltRequest { - const conversation = dedupeToolOutputs( + const conversation = dedupeToolItems( messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)), ); const systemMessage: ResponsesInputItem | undefined = @@ -202,10 +220,13 @@ function buildRequest( } export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderAdapter { - const indexer = createResponsesBlockIndexer(); + // Re-created per request in buildRequest — see codex-responses-adapter.ts. + let indexer = createResponsesBlockIndexer(); return { - buildRequest: (messages, model, options) => - buildRequest(messages, model, options, source.provider), + buildRequest: (messages, model, options) => { + indexer = createResponsesBlockIndexer(); + return buildRequest(messages, model, options, source.provider); + }, parseResponse: (sseData) => parseResponse(sseData, indexer, source, OPENAI_RESPONSES_PROVIDER), parseJSONResponse, isStreamTerminal: isResponsesStreamTerminal, diff --git a/tests/unit/grok-responses-adapter.test.ts b/tests/unit/grok-responses-adapter.test.ts index 43b9cb3b9..57c4b61d6 100644 --- a/tests/unit/grok-responses-adapter.test.ts +++ b/tests/unit/grok-responses-adapter.test.ts @@ -133,7 +133,7 @@ describe("grok-responses buildRequest", () => { expect(body).not.toHaveProperty("prompt_cache_key"); }); - test("drops duplicate tool results for a call id", () => { + test("keeps the latest tool result for a duplicated call id", () => { const turns: ConversationTurn[] = [ { role: "assistant", @@ -168,7 +168,7 @@ describe("grok-responses buildRequest", () => { arguments: JSON.stringify({ path: "a.ts" }), call_id: "call-1", }, - { type: "function_call_output", call_id: "call-1", output: "first" }, + { type: "function_call_output", call_id: "call-1", output: "duplicate" }, ]); }); }); From 98df6a209d89d82a1ad6e01dbdc0c22ce008b659 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 13:17:58 -0700 Subject: [PATCH 2/3] Fix the real defect #1 site: replay-sanitizer strips model-less turns too sanitizeReplayTurns runs before the adapter's own buildRequest via withReplaySanitizer, and its stripForeignBlocks gate had the identical undefined-model-is-foreign bug as signatureForModel, deleting the signature before the adapter ever saw it. The vendored transformMessages has the same same-model check and is not ours to change, so a model-less assistant turn is now stamped with the target model before either stage runs, instead of loosening either stage's foreign check directly. Genuine cross-provider turns (a turn with a real, different model) are untouched -- still stripped, still fail closed. --- src/provider/replay-sanitizer.test.ts | 43 +++++++++++++++++++++++++++ src/provider/replay-sanitizer.ts | 15 +++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/provider/replay-sanitizer.test.ts b/src/provider/replay-sanitizer.test.ts index aa82a95c4..0d3f13aef 100644 --- a/src/provider/replay-sanitizer.test.ts +++ b/src/provider/replay-sanitizer.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from "bun:test"; import type { AdapterRegistry } from "@intx/inference"; import { createBuiltinRegistry } from "@intx/inference/providers"; import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime"; +import { + CODEX_RESPONSES_PROVIDER, + createCodexResponsesAdapter, + tagSignature, +} from "./codex-responses-adapter.js"; import { createGrokResponsesAdapter } from "./grok-responses-adapter.js"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; import { @@ -57,6 +62,13 @@ function corbitsRegistry(): AdapterRegistry { }; } +function codexRegistry(): AdapterRegistry { + return { + has: (provider) => provider === CODEX_RESPONSES_PROVIDER, + resolve: (source) => createCodexResponsesAdapter(source), + }; +} + function thinkingOnlyHistory(): ConversationTurn[] { return [ { @@ -357,4 +369,35 @@ describe("withReplaySanitizer", () => { expect(() => adapter.buildRequest(thinkingOnlyHistory(), "claude-opus-4", {})).not.toThrow(); expect(() => adapter.buildRequest(leftoverHistory, "claude-opus-4", {})).not.toThrow(); }); + + // Regression for CL-6912: sanitizeReplayTurns runs INSIDE buildRequest, + // before the adapter's own toResponsesItems ever sees a turn. A turn + // missing `model` must survive stripForeignBlocks's foreign-turn gate, not + // just signatureForModel's gate inside the adapter — otherwise the + // signature never reaches the adapter's own (correctly fixed) check. + it("carries a reasoning signature through the real buildRequest path when the turn has no model", () => { + const adapter = withReplaySanitizer(codexRegistry()).resolve({ + sourceId: "s1", + provider: CODEX_RESPONSES_PROVIDER, + model: "gpt-5.1-codex", + }); + const signature = tagSignature(CODEX_RESPONSES_PROVIDER, "cipher"); + const turns: ConversationTurn[] = [ + { role: "user", content: [{ type: "text", text: "hi" }], timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "ponder", signature }, + { type: "tool_call", id: "call_1", name: "shell", arguments: {} }, + ], + timestamp: 2, + } as unknown as ConversationTurn, + ]; + + const request = adapter.buildRequest(turns, "gpt-5.1-codex", {}); + const body = JSON.parse(request.body) as { input: { type: string }[] }; + + expect(body.input.some((item) => item.type === "reasoning")).toBe(true); + expect(body.input.some((item) => item.type === "function_call")).toBe(true); + }); }); diff --git a/src/provider/replay-sanitizer.ts b/src/provider/replay-sanitizer.ts index 9da27f019..a17f2c9d5 100644 --- a/src/provider/replay-sanitizer.ts +++ b/src/provider/replay-sanitizer.ts @@ -69,7 +69,20 @@ export function sanitizeReplayTurns( turns: ConversationTurn[], targetModel: string, ): ConversationTurn[] { - const stripped = turns.map((turn) => + // A turn with no `model` recorded (an optional field on the persisted + // schema) is not evidence it came from a foreign provider. Both this + // module's own foreign-turn gate below AND the vendored transformMessages' + // same-model check key off exact `model` equality — transformMessages is + // not ours to change, so a model-less turn is stamped with the target + // model before either stage runs. That reads as "this model", not + // "foreign", to both stages; without it transformMessages strips the + // turn's thinking blocks outright regardless of what this module decides. + const modelFilled = turns.map((turn) => + turn.role === "assistant" && turn.model === undefined + ? { ...turn, model: targetModel } + : turn, + ); + const stripped = modelFilled.map((turn) => turn.role === "assistant" && turn.model !== targetModel ? stripForeignBlocks(turn) : turn, ); const marked = stripped.map(replaceUnusableAssistantTurn); From c4a448d6a5bff4268ed6f92595bc509e9b7a4179 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 13:34:07 -0700 Subject: [PATCH 3/3] Run prettier on replay-sanitizer.ts --- src/provider/replay-sanitizer.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/provider/replay-sanitizer.ts b/src/provider/replay-sanitizer.ts index a17f2c9d5..b8303f36b 100644 --- a/src/provider/replay-sanitizer.ts +++ b/src/provider/replay-sanitizer.ts @@ -78,9 +78,7 @@ export function sanitizeReplayTurns( // "foreign", to both stages; without it transformMessages strips the // turn's thinking blocks outright regardless of what this module decides. const modelFilled = turns.map((turn) => - turn.role === "assistant" && turn.model === undefined - ? { ...turn, model: targetModel } - : turn, + turn.role === "assistant" && turn.model === undefined ? { ...turn, model: targetModel } : turn, ); const stripped = modelFilled.map((turn) => turn.role === "assistant" && turn.model !== targetModel ? stripForeignBlocks(turn) : turn,