Skip to content

Commit c1ac433

Browse files
Merge pull request #566 from corbitsdev/cl-6912-responses-adapter-hygiene
Responses adapter hygiene: reasoning drop, dedupe, indexer leak, tool-name codec, double SSE parse (CL-6912)
2 parents e97130f + c4a448d commit c1ac433

10 files changed

Lines changed: 403 additions & 45 deletions

src/provider/codex-responses-adapter.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { PRODUCT_NAME } from "../branding.js";
44
import {
55
createCodexResponsesAdapter,
66
isResponsesStreamTerminal,
7+
signatureForModel,
8+
tagSignature,
79
} from "./codex-responses-adapter.js";
810

911
const source: LastCycleSource = {
@@ -134,6 +136,158 @@ describe("createCodexResponsesAdapter usage parsing", () => {
134136
});
135137
});
136138

139+
describe("signatureForModel", () => {
140+
const turnWithModel = (model: string | undefined): ConversationTurn =>
141+
({
142+
role: "assistant",
143+
model,
144+
content: [],
145+
timestamp: 0,
146+
}) as unknown as ConversationTurn;
147+
148+
test("replays a signature on a turn with no persisted model", () => {
149+
const signature = tagSignature("codex-responses", "cipher");
150+
const result = signatureForModel(
151+
turnWithModel(undefined),
152+
"gpt-5.1-codex",
153+
"codex-responses",
154+
signature,
155+
);
156+
expect(result).toBe("cipher");
157+
});
158+
159+
test("drops a signature when the turn's model genuinely differs", () => {
160+
const signature = tagSignature("codex-responses", "cipher");
161+
const result = signatureForModel(
162+
turnWithModel("gpt-5.0-codex"),
163+
"gpt-5.1-codex",
164+
"codex-responses",
165+
signature,
166+
);
167+
expect(result).toBeUndefined();
168+
});
169+
170+
test("replays a signature when the model matches", () => {
171+
const signature = tagSignature("codex-responses", "cipher");
172+
const result = signatureForModel(
173+
turnWithModel("gpt-5.1-codex"),
174+
"gpt-5.1-codex",
175+
"codex-responses",
176+
signature,
177+
);
178+
expect(result).toBe("cipher");
179+
});
180+
});
181+
182+
describe("createCodexResponsesAdapter orphaned function_call suppression", () => {
183+
test("drops a function_call whose reasoning signature could not be replayed", () => {
184+
const adapter = createCodexResponsesAdapter(source);
185+
const turns: ConversationTurn[] = [
186+
{ role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] },
187+
{
188+
role: "assistant",
189+
model: "gpt-5.0-codex",
190+
timestamp: 0,
191+
content: [
192+
{ type: "thinking", thinking: "ponder", signature: tagSignature("codex-responses", "c") },
193+
{ type: "tool_call", id: "call_1", name: "shell", arguments: {} },
194+
],
195+
},
196+
] as unknown as ConversationTurn[];
197+
198+
const request = adapter.buildRequest(turns, "gpt-5.1-codex", {});
199+
const body = JSON.parse(request.body) as { input: { type: string }[] };
200+
201+
expect(body.input.some((item) => item.type === "reasoning")).toBe(false);
202+
expect(body.input.some((item) => item.type === "function_call")).toBe(false);
203+
});
204+
205+
test("keeps the function_call when its reasoning signature replays cleanly", () => {
206+
const adapter = createCodexResponsesAdapter(source);
207+
const turns: ConversationTurn[] = [
208+
{ role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] },
209+
{
210+
role: "assistant",
211+
model: "gpt-5.1-codex",
212+
timestamp: 0,
213+
content: [
214+
{ type: "thinking", thinking: "ponder", signature: tagSignature("codex-responses", "c") },
215+
{ type: "tool_call", id: "call_1", name: "shell", arguments: {} },
216+
],
217+
},
218+
] as unknown as ConversationTurn[];
219+
220+
const request = adapter.buildRequest(turns, "gpt-5.1-codex", {});
221+
const body = JSON.parse(request.body) as { input: { type: string }[] };
222+
223+
expect(body.input.some((item) => item.type === "reasoning")).toBe(true);
224+
expect(body.input.some((item) => item.type === "function_call")).toBe(true);
225+
});
226+
});
227+
228+
describe("createCodexResponsesAdapter tool-name codec", () => {
229+
test("encodes a non-wire-safe tool name on the outgoing function tool definition", () => {
230+
const adapter = createCodexResponsesAdapter(source);
231+
const turns: ConversationTurn[] = [
232+
{ role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] },
233+
];
234+
235+
const request = adapter.buildRequest(turns, "gpt-5.1-codex", {
236+
tools: [
237+
{
238+
name: "@intx/tools-posix/sidecar-bundle:run_shell",
239+
description: "run a shell command",
240+
inputSchema: {},
241+
},
242+
],
243+
} as never);
244+
const body = JSON.parse(request.body) as { tools: { name: string }[] };
245+
246+
expect(body.tools[0]?.name).toMatch(/^[A-Za-z_][A-Za-z0-9_-]*$/);
247+
expect(body.tools[0]?.name).not.toBe("@intx/tools-posix/sidecar-bundle:run_shell");
248+
});
249+
250+
test("decodes an encoded tool_call.start name back to the internal id", () => {
251+
const adapter = createCodexResponsesAdapter(source);
252+
const encoded = "IX_-40intx-2Ftools-2Dposix-2Fsidecar-2Dbundle-3Arun_shell";
253+
const sseData = JSON.stringify({
254+
type: "response.output_item.added",
255+
item: { type: "function_call", id: "item_1", call_id: "call_1", name: encoded },
256+
});
257+
258+
const events = adapter.parseResponse(sseData);
259+
const start = events.find((e) => e.type === "inference.tool_call.start");
260+
261+
expect((start?.data as { name?: string })?.name).not.toBe(encoded);
262+
});
263+
});
264+
265+
describe("createCodexResponsesAdapter block indexer reset", () => {
266+
test("resets block indices on a new buildRequest instead of accumulating across requests", () => {
267+
const adapter = createCodexResponsesAdapter(source);
268+
const turns: ConversationTurn[] = [
269+
{ role: "user", timestamp: 0, content: [{ type: "text", text: "hi" }] },
270+
];
271+
272+
adapter.buildRequest(turns, "gpt-5.1-codex", {});
273+
adapter.parseResponse(
274+
JSON.stringify({ type: "response.output_text.delta", item_id: "item_1", delta: "a" }),
275+
);
276+
adapter.parseResponse(
277+
JSON.stringify({ type: "response.output_text.delta", item_id: "item_2", delta: "b" }),
278+
);
279+
280+
// A new request (a fresh HTTP round trip) with a brand-new item id should
281+
// start indexing from 0 again, not continue accumulating from the prior
282+
// request's indexer state.
283+
adapter.buildRequest(turns, "gpt-5.1-codex", {});
284+
const secondRequestDelta = adapter.parseResponse(
285+
JSON.stringify({ type: "response.output_text.delta", item_id: "item_3", delta: "c" }),
286+
);
287+
expect((secondRequestDelta[0]?.data as { index?: number })?.index).toBe(0);
288+
});
289+
});
290+
137291
describe("isResponsesStreamTerminal", () => {
138292
test("is true for the Responses end-of-turn events", () => {
139293
for (const type of ["response.completed", "response.incomplete", "response.done"]) {

src/provider/codex-responses-adapter.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import {
22
BEARER_CREDENTIAL_SENTINEL,
33
ProtocolMismatchError,
4+
decodeToolName,
5+
encodeToolName,
46
type BuiltRequest,
57
type ProviderAdapter,
8+
type ToolNameLimit,
69
} from "@intx/inference";
710
import type {
811
ContentBlock,
@@ -157,7 +160,11 @@ export function signatureForModel(
157160
requestProvider: string,
158161
signature: string,
159162
): string | undefined {
160-
if (turn.model !== requestModel) return undefined;
163+
// `model` is optional on the persisted turn schema; a turn saved before that
164+
// field existed (or otherwise missing it) is not evidence of a model
165+
// switch — treat the absence as benign and fall through to the provider
166+
// check, rather than dropping reasoning that never actually crossed models.
167+
if (turn.model !== undefined && turn.model !== requestModel) return undefined;
161168
const tagged = untagSignature(signature);
162169
if (tagged === undefined) return undefined;
163170
return tagged.provider === requestProvider ? tagged.encryptedContent : undefined;
@@ -171,6 +178,14 @@ export function signatureForModel(
171178
// (held in a thinking block's signature) AND that backend is the one this
172179
// request is going to — replaying it to a different provider gets a 400 it
173180
// cannot recover from.
181+
// Wire-charset limit for function names on the Responses surface (Codex,
182+
// Grok, and the generic OpenAI Responses adapter all share OpenAI's
183+
// `^[a-zA-Z0-9_-]{1,64}$` function-name charset).
184+
export const RESPONSES_TOOL_NAME_LIMIT: ToolNameLimit = {
185+
provider: "responses",
186+
maxLength: 64,
187+
};
188+
174189
function toResponsesItems(
175190
turn: ConversationTurn,
176191
requestModel: string,
@@ -180,11 +195,20 @@ function toResponsesItems(
180195
const textKind: "input_text" | "output_text" =
181196
turn.role === "assistant" ? "output_text" : "input_text";
182197
const textParts: ResponsesContentPart[] = [];
198+
// A reasoning block whose signature we could not replay (foreign provider,
199+
// model switch, or a missing/untagged signature) leaves any function_call
200+
// it produced without the reasoning item the Responses API expects to
201+
// precede it — the exact orphaned shape that degenerates reasoning models.
202+
// Suppress function_call items until the next text or successfully-replayed
203+
// reasoning item re-establishes a clean turn shape; tool results are
204+
// unaffected since they never need a preceding reasoning item.
205+
let suppressOrphanedCalls = false;
183206

184207
const flushText = (): void => {
185208
if (textParts.length > 0) {
186209
items.push({ type: "message", role: turn.role, content: [...textParts] });
187210
textParts.length = 0;
211+
suppressOrphanedCalls = false;
188212
}
189213
};
190214

@@ -206,15 +230,17 @@ function toResponsesItems(
206230
} as ResponsesContentPart);
207231
}
208232
} else if (block.type === "tool_call") {
233+
if (suppressOrphanedCalls) continue;
209234
flushText();
210235
items.push({
211236
type: "function_call",
212-
name: block.name,
237+
name: encodeToolName(block.name, RESPONSES_TOOL_NAME_LIMIT),
213238
arguments: JSON.stringify(block.arguments ?? {}),
214239
call_id: block.id,
215240
});
216241
} else if (block.type === "tool_result") {
217242
flushText();
243+
suppressOrphanedCalls = false;
218244
items.push({
219245
type: "function_call_output",
220246
call_id: block.callId,
@@ -234,6 +260,9 @@ function toResponsesItems(
234260
);
235261
if (encryptedContent !== undefined) {
236262
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
263+
suppressOrphanedCalls = false;
264+
} else {
265+
suppressOrphanedCalls = true;
237266
}
238267
}
239268
}
@@ -260,7 +289,7 @@ function toResponsesTools(options: InferenceOptions): unknown[] | undefined {
260289
// `type`, not nested under a `function` key (unlike Chat Completions).
261290
return options.tools.map((t) => ({
262291
type: "function",
263-
name: t.name,
292+
name: encodeToolName(t.name, RESPONSES_TOOL_NAME_LIMIT),
264293
description: t.description,
265294
parameters: t.inputSchema,
266295
}));
@@ -466,7 +495,7 @@ export function parseResponse(
466495
seq,
467496
data: {
468497
callId,
469-
name,
498+
name: decodeToolName(name),
470499
partial: EMPTY_PARTIAL,
471500
index: blockIndexFor(indexer, itemId, "tool_call"),
472501
},
@@ -608,13 +637,15 @@ export function isResponsesStreamTerminal(sseData: string): boolean {
608637
}
609638

610639
export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAdapter {
611-
const indexer: CodexBlockIndexer = {
612-
nextIndex: 0,
613-
items: new Map<string, { index: number; kind: CodexBlockKind }>(),
614-
};
640+
// Re-created per request in buildRequest, not just once here — otherwise
641+
// block indices accumulate across every request the adapter instance ever
642+
// serves, growing the map for the life of the conversation.
643+
let indexer: CodexBlockIndexer = createResponsesBlockIndexer();
615644
return {
616-
buildRequest: (messages, model, options) =>
617-
buildRequest(messages, model, options, source.provider),
645+
buildRequest: (messages, model, options) => {
646+
indexer = createResponsesBlockIndexer();
647+
return buildRequest(messages, model, options, source.provider);
648+
},
618649
parseResponse: (sseData) => parseResponse(sseData, indexer, source),
619650
parseJSONResponse,
620651
isStreamTerminal: isResponsesStreamTerminal,

src/provider/grok-responses-adapter.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,52 @@ describe("createGrokResponsesAdapter", () => {
9898
expect(body.reasoning).toEqual({ effort: "low", summary: "detailed" });
9999
});
100100

101+
test("keeps the latest function_call_output on a duplicate call_id", () => {
102+
const adapter = createGrokResponsesAdapter(source);
103+
const turns: ConversationTurn[] = [
104+
{
105+
role: "user",
106+
timestamp: 0,
107+
content: [
108+
{ type: "tool_result", callId: "call_1", content: [{ type: "text", text: "stale" }] },
109+
{ type: "tool_result", callId: "call_1", content: [{ type: "text", text: "fresh" }] },
110+
],
111+
},
112+
] as unknown as ConversationTurn[];
113+
114+
const request = adapter.buildRequest(turns, "grok-4.5", {});
115+
const body = JSON.parse(request.body) as {
116+
input: { type: string; call_id?: string; output?: string }[];
117+
};
118+
const outputs = body.input.filter((item) => item.type === "function_call_output");
119+
120+
expect(outputs).toHaveLength(1);
121+
expect(outputs[0]?.output).toBe("fresh");
122+
});
123+
124+
test("dedupes a duplicate function_call on the same call_id", () => {
125+
const adapter = createGrokResponsesAdapter(source);
126+
const turns: ConversationTurn[] = [
127+
{
128+
role: "assistant",
129+
timestamp: 0,
130+
content: [
131+
{ type: "tool_call", id: "call_1", name: "shell", arguments: { a: 1 } },
132+
{ type: "tool_call", id: "call_1", name: "shell", arguments: { a: 2 } },
133+
],
134+
},
135+
] as unknown as ConversationTurn[];
136+
137+
const request = adapter.buildRequest(turns, "grok-4.5", {});
138+
const body = JSON.parse(request.body) as {
139+
input: { type: string; call_id?: string; arguments?: string }[];
140+
};
141+
const calls = body.input.filter((item) => item.type === "function_call");
142+
143+
expect(calls).toHaveLength(1);
144+
expect(calls[0]?.arguments).toBe(JSON.stringify({ a: 2 }));
145+
});
146+
101147
test("does not invent high when no reasoning_effort is set", () => {
102148
const adapter = createGrokResponsesAdapter(source);
103149
const turns: ConversationTurn[] = [

0 commit comments

Comments
 (0)