Skip to content

Commit e8bdaea

Browse files
committed
Merge branch 'main' into cl-6907-tui-fires-refreshcodexinstructions-un-awaited-request-prefix
2 parents 6bdae1c + 04bf0d0 commit e8bdaea

3 files changed

Lines changed: 268 additions & 0 deletions

File tree

src/provider/inference-dependencies.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as bifrostAdapter from "./bifrost-adapter.js";
77
import * as openaiResponses from "./openai-responses-adapter.js";
88
import { CODEX_RESPONSES_PROVIDER, withCodexContentTypeRepair } from "./codex-responses-adapter.js";
99
import { GROK_RESPONSES_PROVIDER } from "./grok-responses-adapter.js";
10+
import { withReplaySanitizer } from "./replay-sanitizer.js";
1011
import { BIFROST_PROVIDER } from "./bifrost-adapter.js";
1112
import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses-adapter.js";
1213

@@ -59,6 +60,7 @@ export function createInferenceDependencies(): Promise<Dependencies> {
5960
cached = loadAdapterRegistry(manifest, {
6061
import: (specifier) => Promise.resolve(localModules[specifier]),
6162
})
63+
.then(withReplaySanitizer)
6264
.then(createDependencies)
6365
.then((deps) => ({
6466
...deps,
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { createBuiltinRegistry } from "@intx/inference/providers";
3+
import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime";
4+
import { sanitizeReplayTurns, withReplaySanitizer } from "./replay-sanitizer.js";
5+
6+
const GROK_SIGNATURE = "grok-opaque-signature-blob";
7+
8+
function grokThinkingHistory(): ConversationTurn[] {
9+
return [
10+
{
11+
role: "user",
12+
content: [{ type: "text", text: "hello" }],
13+
timestamp: 1,
14+
},
15+
{
16+
role: "assistant",
17+
model: "grok-4",
18+
content: [
19+
{ type: "thinking", thinking: "pondering", signature: GROK_SIGNATURE },
20+
{ type: "text", text: "answer", signature: GROK_SIGNATURE },
21+
],
22+
timestamp: 2,
23+
},
24+
{
25+
role: "user",
26+
content: [{ type: "text", text: "continue" }],
27+
timestamp: 3,
28+
},
29+
];
30+
}
31+
32+
function resolveSanitized(source: LastCycleSource) {
33+
return withReplaySanitizer(createBuiltinRegistry()).resolve(source);
34+
}
35+
36+
describe("sanitizeReplayTurns", () => {
37+
it("strips foreign thinking blocks and signatures", () => {
38+
const turns = sanitizeReplayTurns(grokThinkingHistory(), "claude-opus-4");
39+
const assistant = turns.find((t) => t.role === "assistant");
40+
expect(assistant).toBeDefined();
41+
expect(assistant?.content.some((b) => b.type === "thinking")).toBe(false);
42+
expect(JSON.stringify(turns)).not.toContain(GROK_SIGNATURE);
43+
});
44+
45+
it("keeps thinking blocks for same-model replay", () => {
46+
const turns = sanitizeReplayTurns(grokThinkingHistory(), "grok-4");
47+
const assistant = turns.find((t) => t.role === "assistant");
48+
expect(assistant?.content.some((b) => b.type === "thinking")).toBe(true);
49+
});
50+
51+
it("converts foreign refusal blocks to text", () => {
52+
const turns = sanitizeReplayTurns(
53+
[
54+
{
55+
role: "assistant",
56+
model: "gpt-5",
57+
content: [{ type: "refusal", reason: "cannot comply" }],
58+
timestamp: 1,
59+
},
60+
],
61+
"claude-opus-4",
62+
);
63+
expect(turns[0]?.content).toEqual([{ type: "text", text: "cannot comply" }]);
64+
});
65+
66+
it("drops foreign redacted_thinking and citation blocks", () => {
67+
const turns = sanitizeReplayTurns(
68+
[
69+
{
70+
role: "assistant",
71+
model: "claude-opus-4",
72+
content: [
73+
{ type: "redacted_thinking", data: "opaque" },
74+
{ type: "text", text: "cited answer" },
75+
{ type: "citation", citedText: "quote", source: {} },
76+
],
77+
timestamp: 1,
78+
},
79+
],
80+
"gemini-2.5-pro",
81+
);
82+
expect(turns[0]?.content).toEqual([{ type: "text", text: "cited answer" }]);
83+
});
84+
85+
it("answers dangling tool_calls with a synthetic error result", () => {
86+
const turns = sanitizeReplayTurns(
87+
[
88+
{
89+
role: "assistant",
90+
model: "grok-4",
91+
content: [
92+
{ type: "text", text: "running tool" },
93+
{ type: "tool_call", id: "call_1", name: "ls", arguments: {} },
94+
],
95+
timestamp: 1,
96+
},
97+
],
98+
"claude-opus-4",
99+
);
100+
const results = turns.flatMap((t) => t.content.filter((b) => b.type === "tool_result"));
101+
expect(results).toHaveLength(1);
102+
expect(results[0]).toMatchObject({ callId: "call_1", isError: true });
103+
});
104+
});
105+
106+
describe("withReplaySanitizer", () => {
107+
it("builds an Anthropic request from a grok-signed thinking turn", () => {
108+
const adapter = resolveSanitized({
109+
sourceId: "s1",
110+
provider: "anthropic",
111+
model: "claude-opus-4",
112+
});
113+
const request = adapter.buildRequest(grokThinkingHistory(), "claude-opus-4", {});
114+
expect(request.body).not.toContain(GROK_SIGNATURE);
115+
expect(request.body).not.toContain('"thinking"');
116+
});
117+
118+
it("builds a Google request from a grok-signed thinking turn", () => {
119+
const adapter = resolveSanitized({
120+
sourceId: "s1",
121+
provider: "google-genai",
122+
model: "gemini-2.5-pro",
123+
});
124+
const request = adapter.buildRequest(grokThinkingHistory(), "gemini-2.5-pro", {});
125+
expect(request.body).not.toContain(GROK_SIGNATURE);
126+
expect(request.body).not.toContain("thoughtSignature");
127+
});
128+
129+
it("builds an Anthropic request from a persisted refusal block", () => {
130+
const adapter = resolveSanitized({
131+
sourceId: "s1",
132+
provider: "anthropic",
133+
model: "claude-opus-4",
134+
});
135+
const request = adapter.buildRequest(
136+
[
137+
{
138+
role: "user",
139+
content: [{ type: "text", text: "do it" }],
140+
timestamp: 1,
141+
},
142+
{
143+
role: "assistant",
144+
model: "gpt-5",
145+
content: [{ type: "refusal", reason: "cannot comply" }],
146+
timestamp: 2,
147+
},
148+
{
149+
role: "user",
150+
content: [{ type: "text", text: "why not" }],
151+
timestamp: 3,
152+
},
153+
],
154+
"claude-opus-4",
155+
{},
156+
);
157+
expect(request.body).toContain("cannot comply");
158+
});
159+
160+
it("builds an Anthropic request from a dangling tool_call", () => {
161+
const adapter = resolveSanitized({
162+
sourceId: "s1",
163+
provider: "anthropic",
164+
model: "claude-opus-4",
165+
});
166+
const request = adapter.buildRequest(
167+
[
168+
{
169+
role: "user",
170+
content: [{ type: "text", text: "list files" }],
171+
timestamp: 1,
172+
},
173+
{
174+
role: "assistant",
175+
model: "grok-4",
176+
content: [
177+
{ type: "text", text: "running tool" },
178+
{ type: "tool_call", id: "call_1", name: "ls", arguments: {} },
179+
],
180+
timestamp: 2,
181+
},
182+
],
183+
"claude-opus-4",
184+
{},
185+
);
186+
expect(request.body).toContain("tool_result");
187+
expect(request.body).toContain("call_1");
188+
});
189+
});

src/provider/replay-sanitizer.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { transformMessages, type AdapterRegistry } from "@intx/inference";
2+
import type { ContentBlock, ConversationTurn } from "@intx/types/runtime";
3+
4+
// Repairs persisted history at the request-build boundary so a turn produced
5+
// by one provider replays safely against another. transformMessages (vendored)
6+
// strips thinking blocks for foreign-model turns, rewrites safety_rating to
7+
// text, and answers dangling tool_calls with synthetic error results — the
8+
// same tool_result/isError shape the reactor's gate-timeout path appends.
9+
// What it does not cover, this module handles first: output-only block types
10+
// with no cross-provider wire shape (refusal, citation, redacted_thinking,
11+
// audio, video, code execution) that make adapter builders throw, and opaque
12+
// provider signatures that a foreign provider rejects when echoed back.
13+
14+
// Output-only shapes a foreign provider cannot round-trip; adapter builders
15+
// throw on them, so they are dropped from foreign-model turns before build.
16+
const FOREIGN_UNMAPPABLE_TYPES = new Set<ContentBlock["type"]>([
17+
"redacted_thinking",
18+
"citation",
19+
"audio",
20+
"video",
21+
"code_execution_request",
22+
"code_execution_result",
23+
]);
24+
25+
function stripForeignBlocks(turn: ConversationTurn): ConversationTurn {
26+
const content = turn.content.flatMap((block): ContentBlock[] => {
27+
if (block.type === "refusal") {
28+
return [{ type: "text", text: block.reason }];
29+
}
30+
if (FOREIGN_UNMAPPABLE_TYPES.has(block.type)) {
31+
return [];
32+
}
33+
// Signatures authenticate a block to the provider that signed it; a
34+
// foreign provider 400s when one is echoed back (Gemini replays them
35+
// as thoughtSignature verbatim).
36+
if ("signature" in block && block.signature !== undefined) {
37+
const unsigned = { ...block };
38+
delete unsigned.signature;
39+
return [unsigned];
40+
}
41+
return [block];
42+
});
43+
return { ...turn, content };
44+
}
45+
46+
/**
47+
* Repair persisted turns for replay against `targetModel`. Assistant turns
48+
* produced by a different model lose blocks the target provider cannot
49+
* accept; dangling tool_calls are answered with synthetic error results.
50+
*/
51+
export function sanitizeReplayTurns(
52+
turns: ConversationTurn[],
53+
targetModel: string,
54+
): ConversationTurn[] {
55+
const repaired = turns.map((turn) =>
56+
turn.role === "assistant" && turn.model !== targetModel ? stripForeignBlocks(turn) : turn,
57+
);
58+
return transformMessages(repaired, { targetModel });
59+
}
60+
61+
/**
62+
* Wrap an adapter registry so every resolved adapter sanitizes replayed
63+
* turns before building its request.
64+
*/
65+
export function withReplaySanitizer(adapters: AdapterRegistry): AdapterRegistry {
66+
return {
67+
has: (provider) => adapters.has(provider),
68+
resolve(source, quirks) {
69+
const adapter = adapters.resolve(source, quirks);
70+
return {
71+
...adapter,
72+
buildRequest: (turns, model, options) =>
73+
adapter.buildRequest(sanitizeReplayTurns(turns, model), model, options),
74+
};
75+
},
76+
};
77+
}

0 commit comments

Comments
 (0)