Skip to content

Commit 8ce0ae7

Browse files
committed
fix(provider): count only wire-surviving ephemeral turns for cache suffix
System-role ephemeral turns are hoisted into the head system block and never reach wire messages, so counting them in the suffix undercounts the split and moves the breakpoint onto q1 instead of q2. Filter role!=system and add a q1/a1/q2 + system-ephemeral regression.
1 parent 8b5f33c commit 8ce0ae7

2 files changed

Lines changed: 39 additions & 4 deletions

File tree

‎src/provider/anthropic-cache-breakpoint.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ function assistantTurn(text: string): ConversationTurn {
4141
};
4242
}
4343

44+
function systemTurn(text: string): ConversationTurn {
45+
return {
46+
role: "system",
47+
timestamp: 0,
48+
content: [{ type: "text", text }],
49+
};
50+
}
51+
4452
type WireBlock = {
4553
cache_control?: unknown;
4654
text?: string;
@@ -121,6 +129,30 @@ describe("anthropic cache breakpoint with ephemeral turns", () => {
121129
).toEqual([]);
122130
});
123131

132+
test("system-role ephemeral is hoisted, so the breakpoint stays on q2 not q1", () => {
133+
const persisted = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")];
134+
const sysEphemeral = systemTurn("sys-nudge");
135+
const request = adapters
136+
.resolve(sourceFor("anthropic"))
137+
.buildRequest([...persisted, sysEphemeral], "test-model", {
138+
ephemeralTurns: [sysEphemeral],
139+
} as InferenceOptions);
140+
const body = wireBody(request.body);
141+
142+
// Hoisted system turns never reach wire messages, so only q1/a1/q2
143+
// remain and the breakpoint must sit on the last persisted user turn.
144+
expect(body.messages).toHaveLength(3);
145+
expect(
146+
body.messages[0]?.content.filter(
147+
(block) => block.cache_control !== undefined,
148+
),
149+
).toEqual([]);
150+
expect(
151+
body.messages[2]?.content[body.messages[2].content.length - 1]
152+
?.cache_control,
153+
).toEqual({ type: "ephemeral" });
154+
});
155+
124156
test("without ephemeral turns the request is byte-identical to the base adapter", () => {
125157
const turns = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")];
126158
const base = inner

‎src/provider/anthropic-cache-breakpoint.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ type WireMessage = {
2323
};
2424

2525
function ephemeralSuffixLength(options: ExtendedInferenceOptions): number {
26-
// Ephemeral turns are appended as the request suffix (see reactor.ts), so
27-
// every entry counts — including system-role turns, which the Anthropic
28-
// wire adapter hoists out of the message list rather than leaving in place.
26+
// Ephemeral turns are appended as the request suffix (see reactor.ts), but
27+
// system-role turns never survive into wire messages — the Anthropic wire
28+
// adapter hoists them into the head `system` block (or drops them when
29+
// `systemPrompt` is set). Only turns that survive to wire messages count
30+
// toward the suffix, otherwise the split undercounts and the breakpoint
31+
// lands on q1 instead of the last persisted user turn q2.
2932
const turns = options.ephemeralTurns;
3033
if (turns === undefined) return 0;
31-
return turns.length;
34+
return turns.filter((turn) => turn.role !== "system").length;
3235
}
3336

3437
function isWireBlock(block: unknown): block is WireBlock {

0 commit comments

Comments
 (0)