Skip to content

Commit 91d820f

Browse files
committed
Replace thinking-only replay turns with a stable marker
transformMessages only drops an assistant turn when stripping thinking leaves empty content. Leftover thinking-only turns then replay as an identical request. Mark those turns at the sanitizer instead of dropping them.
1 parent e92a55e commit 91d820f

4 files changed

Lines changed: 195 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1515

1616
### Agent
1717

18+
- **Thinking-only replay no longer collapses into an identical request.** Assistant turns with no text or tool_call (empty content, leftover thinking/citation) are replaced with a stable `[thinking-only turn omitted]` marker so the turn is kept, roles still alternate, and the next `buildRequest` body differs from the previous one.
19+
1820
- **Compaction keeps scored work, not retry loops.** Errored tool results are no
1921
longer auto-pinned; identical errors collapse to one representative. Anchors
2022
are scored (writes, successful task completions, plan updates) and pair

src/provider/openai-compatible-adapter.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,7 @@ export function createOpenAICompatibleAdapter(source: AdapterSource): ProviderAd
2828
};
2929

3030
const buildRequest: ProviderAdapter["buildRequest"] = (messages, model, options) => {
31-
// Strip assistant turns with no text or tool_call content (e.g. a turn that
32-
// produced only thinking blocks). transform.ts should handle this but misses
33-
// the case where filteredContent is non-empty; the API rejects such turns
34-
// with HTTP 400. Fast-path: only allocate when a bad turn is actually found.
35-
const needsSanitize = messages.some(
36-
(msg) =>
37-
msg.role === "assistant" &&
38-
!msg.content.some((b) => b.type === "text" || b.type === "tool_call"),
39-
);
40-
const sanitized = needsSanitize
41-
? messages.filter(
42-
(msg) =>
43-
msg.role !== "assistant" ||
44-
msg.content.some((b) => b.type === "text" || b.type === "tool_call"),
45-
)
46-
: messages;
47-
const built = base.buildRequest(sanitized, model, options);
31+
const built = base.buildRequest(messages, model, options);
4832
const providerOptions = options.providerOptions;
4933
const hasProviderOptions =
5034
providerOptions !== undefined && Object.keys(providerOptions).length > 0;

src/provider/replay-sanitizer.test.ts

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { describe, expect, it } from "bun:test";
2+
import type { AdapterRegistry } from "@intx/inference";
23
import { createBuiltinRegistry } from "@intx/inference/providers";
34
import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime";
4-
import { sanitizeReplayTurns, withReplaySanitizer } from "./replay-sanitizer.js";
5+
import { createGrokResponsesAdapter } from "./grok-responses-adapter.js";
6+
import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js";
7+
import {
8+
sanitizeReplayTurns,
9+
THINKING_ONLY_OMITTED,
10+
withReplaySanitizer,
11+
} from "./replay-sanitizer.js";
512

613
const GROK_SIGNATURE = "grok-opaque-signature-blob";
714

@@ -33,6 +40,59 @@ function resolveSanitized(source: LastCycleSource) {
3340
return withReplaySanitizer(createBuiltinRegistry()).resolve(source);
3441
}
3542

43+
function corbitsRegistry(): AdapterRegistry {
44+
const builtin = createBuiltinRegistry();
45+
return {
46+
has: (provider) =>
47+
provider === "openai-compatible" || provider === "grok-responses" || builtin.has(provider),
48+
resolve(source, quirks) {
49+
if (source.provider === "openai-compatible") {
50+
return createOpenAICompatibleAdapter(source);
51+
}
52+
if (source.provider === "grok-responses") {
53+
return createGrokResponsesAdapter(source);
54+
}
55+
return builtin.resolve(source, quirks);
56+
},
57+
};
58+
}
59+
60+
function thinkingOnlyHistory(): ConversationTurn[] {
61+
return [
62+
{
63+
role: "user",
64+
content: [{ type: "text", text: "hello" }],
65+
timestamp: 1,
66+
},
67+
{
68+
role: "assistant",
69+
model: "grok-4",
70+
content: [{ type: "thinking", thinking: "pondering" }],
71+
timestamp: 2,
72+
},
73+
{
74+
role: "user",
75+
content: [{ type: "text", text: "continue" }],
76+
timestamp: 3,
77+
},
78+
];
79+
}
80+
81+
const USER_ONLY: ConversationTurn[] = [
82+
{
83+
role: "user",
84+
content: [{ type: "text", text: "hello" }],
85+
timestamp: 1,
86+
},
87+
];
88+
89+
const THINKING_ONLY_TAIL: ConversationTurn = {
90+
role: "assistant",
91+
model: "grok-4",
92+
content: [{ type: "thinking", thinking: "pondering" }],
93+
timestamp: 2,
94+
};
95+
3696
describe("sanitizeReplayTurns", () => {
3797
it("strips foreign thinking blocks and signatures", () => {
3898
const turns = sanitizeReplayTurns(grokThinkingHistory(), "claude-opus-4");
@@ -101,6 +161,68 @@ describe("sanitizeReplayTurns", () => {
101161
expect(results).toHaveLength(1);
102162
expect(results[0]).toMatchObject({ callId: "call_1", isError: true });
103163
});
164+
165+
it("replaces a thinking-only assistant between users with a marker and keeps roles", () => {
166+
const turns = sanitizeReplayTurns(thinkingOnlyHistory(), "claude-opus-4");
167+
expect(turns.map((t) => t.role)).toEqual(["user", "assistant", "user"]);
168+
expect(turns[1]?.content).toEqual([{ type: "text", text: THINKING_ONLY_OMITTED }]);
169+
});
170+
171+
it("replaces empty and leftover-only assistant turns with the same marker", () => {
172+
const empty = sanitizeReplayTurns(
173+
[
174+
{
175+
role: "assistant",
176+
model: "grok-4",
177+
content: [],
178+
timestamp: 1,
179+
},
180+
],
181+
"claude-opus-4",
182+
);
183+
expect(empty[0]?.content).toEqual([{ type: "text", text: THINKING_ONLY_OMITTED }]);
184+
185+
const leftovers = sanitizeReplayTurns(
186+
[
187+
{
188+
role: "assistant",
189+
model: "claude-opus-4",
190+
content: [
191+
{ type: "thinking", thinking: "pondering" },
192+
{ type: "redacted_thinking", data: "opaque" },
193+
{ type: "citation", citedText: "quote", source: {} },
194+
],
195+
timestamp: 1,
196+
},
197+
],
198+
"gemini-2.5-pro",
199+
);
200+
expect(leftovers[0]?.content).toEqual([{ type: "text", text: THINKING_ONLY_OMITTED }]);
201+
expect(JSON.stringify(leftovers)).not.toContain("pondering");
202+
expect(JSON.stringify(leftovers)).not.toContain("opaque");
203+
});
204+
205+
it("leaves assistant turns with text and/or tool_call unchanged", () => {
206+
const withText = sanitizeReplayTurns(grokThinkingHistory(), "grok-4");
207+
expect(withText[1]?.content).toEqual([
208+
{ type: "thinking", thinking: "pondering", signature: GROK_SIGNATURE },
209+
{ type: "text", text: "answer", signature: GROK_SIGNATURE },
210+
]);
211+
212+
const withTool = [
213+
{
214+
role: "assistant" as const,
215+
model: "grok-4",
216+
content: [
217+
{ type: "thinking" as const, thinking: "need a tool" },
218+
{ type: "tool_call" as const, id: "call_1", name: "ls", arguments: {} },
219+
],
220+
timestamp: 1,
221+
},
222+
];
223+
const kept = sanitizeReplayTurns(withTool, "grok-4");
224+
expect(kept[0]?.content).toEqual(withTool[0]?.content);
225+
});
104226
});
105227

106228
describe("withReplaySanitizer", () => {
@@ -186,4 +308,53 @@ describe("withReplaySanitizer", () => {
186308
expect(request.body).toContain("tool_result");
187309
expect(request.body).toContain("call_1");
188310
});
311+
312+
it("changes buildRequest bodies after a thinking-only turn for builtin and Corbits adapters", () => {
313+
const sanitized = withReplaySanitizer(corbitsRegistry());
314+
const cases: LastCycleSource[] = [
315+
{ sourceId: "s1", provider: "anthropic", model: "claude-opus-4" },
316+
{ sourceId: "s1", provider: "google-genai", model: "gemini-2.5-pro" },
317+
{ sourceId: "s1", provider: "openai", model: "gpt-5" },
318+
{ sourceId: "s1", provider: "openai-compatible", model: "kimi-k2" },
319+
{ sourceId: "s1", provider: "grok-responses", model: "grok-4.5" },
320+
];
321+
for (const source of cases) {
322+
const adapter = sanitized.resolve(source);
323+
const without = adapter.buildRequest(USER_ONLY, source.model, {});
324+
const withThinking = adapter.buildRequest(
325+
[...USER_ONLY, THINKING_ONLY_TAIL],
326+
source.model,
327+
{},
328+
);
329+
expect(withThinking.body).not.toEqual(without.body);
330+
expect(withThinking.body).toContain(THINKING_ONLY_OMITTED);
331+
}
332+
});
333+
334+
it("builds requests for thinking-only and leftover-only assistant turns", () => {
335+
const adapter = resolveSanitized({
336+
sourceId: "s1",
337+
provider: "anthropic",
338+
model: "claude-opus-4",
339+
});
340+
const leftoverHistory: ConversationTurn[] = [
341+
{
342+
role: "user",
343+
content: [{ type: "text", text: "hello" }],
344+
timestamp: 1,
345+
},
346+
{
347+
role: "assistant",
348+
model: "grok-4",
349+
content: [
350+
{ type: "thinking", thinking: "pondering" },
351+
{ type: "redacted_thinking", data: "opaque" },
352+
{ type: "citation", citedText: "quote", source: {} },
353+
],
354+
timestamp: 2,
355+
},
356+
];
357+
expect(() => adapter.buildRequest(thinkingOnlyHistory(), "claude-opus-4", {})).not.toThrow();
358+
expect(() => adapter.buildRequest(leftoverHistory, "claude-opus-4", {})).not.toThrow();
359+
});
189360
});

src/provider/replay-sanitizer.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type { ContentBlock, ConversationTurn } from "@intx/types/runtime";
1111
// audio, video, code execution) that make adapter builders throw, and opaque
1212
// provider signatures that a foreign provider rejects when echoed back.
1313

14+
export const THINKING_ONLY_OMITTED = "[thinking-only turn omitted]";
15+
1416
// Output-only shapes a foreign provider cannot round-trip; adapter builders
1517
// throw on them, so they are dropped from foreign-model turns before build.
1618
const FOREIGN_UNMAPPABLE_TYPES = new Set<ContentBlock["type"]>([
@@ -43,6 +45,21 @@ function stripForeignBlocks(turn: ConversationTurn): ConversationTurn {
4345
return { ...turn, content };
4446
}
4547

48+
function hasTextOrToolCall(content: ContentBlock[]): boolean {
49+
return content.some((block) => block.type === "text" || block.type === "tool_call");
50+
}
51+
52+
// transformMessages drops an assistant turn only when stripping thinking
53+
// leaves empty content. Same-model thinking-only and leftover-only turns
54+
// survive with no text/tool_call; adapters then 400 or used to drop them,
55+
// producing an identical next request and a thinking-only loop. Replace
56+
// the unusable turn with a stable text marker so the turn stays, roles
57+
// alternate, and the wire body changes.
58+
function replaceUnusableAssistantTurn(turn: ConversationTurn): ConversationTurn {
59+
if (turn.role !== "assistant" || hasTextOrToolCall(turn.content)) return turn;
60+
return { ...turn, content: [{ type: "text", text: THINKING_ONLY_OMITTED }] };
61+
}
62+
4663
/**
4764
* Repair persisted turns for replay against `targetModel`. Assistant turns
4865
* produced by a different model lose blocks the target provider cannot
@@ -52,10 +69,11 @@ export function sanitizeReplayTurns(
5269
turns: ConversationTurn[],
5370
targetModel: string,
5471
): ConversationTurn[] {
55-
const repaired = turns.map((turn) =>
72+
const stripped = turns.map((turn) =>
5673
turn.role === "assistant" && turn.model !== targetModel ? stripForeignBlocks(turn) : turn,
5774
);
58-
return transformMessages(repaired, { targetModel });
75+
const marked = stripped.map(replaceUnusableAssistantTurn);
76+
return transformMessages(marked, { targetModel });
5977
}
6078

6179
/**

0 commit comments

Comments
 (0)