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
154 changes: 154 additions & 0 deletions src/provider/codex-responses-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { PRODUCT_NAME } from "../branding.js";
import {
createCodexResponsesAdapter,
isResponsesStreamTerminal,
signatureForModel,
tagSignature,
} from "./codex-responses-adapter.js";

const source: LastCycleSource = {
Expand Down Expand Up @@ -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"]) {
Expand Down
51 changes: 41 additions & 10 deletions src/provider/codex-responses-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {
BEARER_CREDENTIAL_SENTINEL,
ProtocolMismatchError,
decodeToolName,
encodeToolName,
type BuiltRequest,
type ProviderAdapter,
type ToolNameLimit,
} from "@intx/inference";
import type {
ContentBlock,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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;
}
};

Expand All @@ -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,
Expand All @@ -234,6 +260,9 @@ function toResponsesItems(
);
if (encryptedContent !== undefined) {
items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent });
suppressOrphanedCalls = false;
} else {
suppressOrphanedCalls = true;
}
}
}
Expand All @@ -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,
}));
Expand Down Expand Up @@ -466,7 +495,7 @@ export function parseResponse(
seq,
data: {
callId,
name,
name: decodeToolName(name),
partial: EMPTY_PARTIAL,
index: blockIndexFor(indexer, itemId, "tool_call"),
},
Expand Down Expand Up @@ -608,13 +637,15 @@ export function isResponsesStreamTerminal(sseData: string): boolean {
}

export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAdapter {
const indexer: CodexBlockIndexer = {
nextIndex: 0,
items: new Map<string, { index: number; kind: CodexBlockKind }>(),
};
// 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,
Expand Down
46 changes: 46 additions & 0 deletions src/provider/grok-responses-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
Loading
Loading