Skip to content

Commit 98df6a2

Browse files
committed
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.
1 parent feb50c4 commit 98df6a2

2 files changed

Lines changed: 57 additions & 1 deletion

File tree

src/provider/replay-sanitizer.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { describe, expect, it } from "bun:test";
22
import type { AdapterRegistry } from "@intx/inference";
33
import { createBuiltinRegistry } from "@intx/inference/providers";
44
import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime";
5+
import {
6+
CODEX_RESPONSES_PROVIDER,
7+
createCodexResponsesAdapter,
8+
tagSignature,
9+
} from "./codex-responses-adapter.js";
510
import { createGrokResponsesAdapter } from "./grok-responses-adapter.js";
611
import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js";
712
import {
@@ -57,6 +62,13 @@ function corbitsRegistry(): AdapterRegistry {
5762
};
5863
}
5964

65+
function codexRegistry(): AdapterRegistry {
66+
return {
67+
has: (provider) => provider === CODEX_RESPONSES_PROVIDER,
68+
resolve: (source) => createCodexResponsesAdapter(source),
69+
};
70+
}
71+
6072
function thinkingOnlyHistory(): ConversationTurn[] {
6173
return [
6274
{
@@ -357,4 +369,35 @@ describe("withReplaySanitizer", () => {
357369
expect(() => adapter.buildRequest(thinkingOnlyHistory(), "claude-opus-4", {})).not.toThrow();
358370
expect(() => adapter.buildRequest(leftoverHistory, "claude-opus-4", {})).not.toThrow();
359371
});
372+
373+
// Regression for CL-6912: sanitizeReplayTurns runs INSIDE buildRequest,
374+
// before the adapter's own toResponsesItems ever sees a turn. A turn
375+
// missing `model` must survive stripForeignBlocks's foreign-turn gate, not
376+
// just signatureForModel's gate inside the adapter — otherwise the
377+
// signature never reaches the adapter's own (correctly fixed) check.
378+
it("carries a reasoning signature through the real buildRequest path when the turn has no model", () => {
379+
const adapter = withReplaySanitizer(codexRegistry()).resolve({
380+
sourceId: "s1",
381+
provider: CODEX_RESPONSES_PROVIDER,
382+
model: "gpt-5.1-codex",
383+
});
384+
const signature = tagSignature(CODEX_RESPONSES_PROVIDER, "cipher");
385+
const turns: ConversationTurn[] = [
386+
{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 1 },
387+
{
388+
role: "assistant",
389+
content: [
390+
{ type: "thinking", thinking: "ponder", signature },
391+
{ type: "tool_call", id: "call_1", name: "shell", arguments: {} },
392+
],
393+
timestamp: 2,
394+
} as unknown as ConversationTurn,
395+
];
396+
397+
const request = adapter.buildRequest(turns, "gpt-5.1-codex", {});
398+
const body = JSON.parse(request.body) as { input: { type: string }[] };
399+
400+
expect(body.input.some((item) => item.type === "reasoning")).toBe(true);
401+
expect(body.input.some((item) => item.type === "function_call")).toBe(true);
402+
});
360403
});

src/provider/replay-sanitizer.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,20 @@ export function sanitizeReplayTurns(
6969
turns: ConversationTurn[],
7070
targetModel: string,
7171
): ConversationTurn[] {
72-
const stripped = turns.map((turn) =>
72+
// A turn with no `model` recorded (an optional field on the persisted
73+
// schema) is not evidence it came from a foreign provider. Both this
74+
// module's own foreign-turn gate below AND the vendored transformMessages'
75+
// same-model check key off exact `model` equality — transformMessages is
76+
// not ours to change, so a model-less turn is stamped with the target
77+
// model before either stage runs. That reads as "this model", not
78+
// "foreign", to both stages; without it transformMessages strips the
79+
// turn's thinking blocks outright regardless of what this module decides.
80+
const modelFilled = turns.map((turn) =>
81+
turn.role === "assistant" && turn.model === undefined
82+
? { ...turn, model: targetModel }
83+
: turn,
84+
);
85+
const stripped = modelFilled.map((turn) =>
7386
turn.role === "assistant" && turn.model !== targetModel ? stripForeignBlocks(turn) : turn,
7487
);
7588
const marked = stripped.map(replaceUnusableAssistantTurn);

0 commit comments

Comments
 (0)