Skip to content

Commit b36bcf9

Browse files
committed
test(provider): cover mid-conversation system wire and refine inject-role markers
1 parent 8ce0ae7 commit b36bcf9

4 files changed

Lines changed: 138 additions & 196 deletions

File tree

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

Lines changed: 86 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import type { AdapterRegistry } from "@intx/inference";
2+
import type { AdapterRegistry, ProviderAdapter } from "@intx/inference";
33
import { createBuiltinRegistry } from "@intx/inference/providers";
44
import type {
55
ConversationTurn,
@@ -25,6 +25,41 @@ const inner: AdapterRegistry = {
2525

2626
const adapters = withAnthropicCacheBreakpoint(inner);
2727

28+
// Builtin Anthropic still hoists `role: "system"` into `body.system`. Newer
29+
// models keep mid-conversation system in `messages`; this stub is that wire.
30+
function preservingSystemAdapter(): ProviderAdapter {
31+
return {
32+
buildRequest(turns) {
33+
const messages = turns.map((turn, index) => {
34+
const content: WireBlock[] = turn.content.flatMap((block) => {
35+
if (block.type !== "text") return [];
36+
return [{ text: block.text }];
37+
});
38+
const isLastNonAssistant =
39+
turn.role !== "assistant" &&
40+
turns.slice(index + 1).every((rest) => rest.role === "assistant");
41+
const last = content[content.length - 1];
42+
if (isLastNonAssistant && last !== undefined) {
43+
last.cache_control = { type: "ephemeral" };
44+
}
45+
return { role: turn.role, content };
46+
});
47+
return {
48+
url: "https://example.test/messages",
49+
headers: {},
50+
body: JSON.stringify({ messages }),
51+
};
52+
},
53+
parseResponse: () => [],
54+
parseJSONResponse: () => [],
55+
};
56+
}
57+
58+
const systemWireAdapters = withAnthropicCacheBreakpoint({
59+
has: (provider) => provider === "anthropic" || provider === "zen-messages",
60+
resolve: () => preservingSystemAdapter(),
61+
});
62+
2863
function userTurn(text: string): ConversationTurn {
2964
return {
3065
role: "user",
@@ -98,6 +133,56 @@ describe("anthropic cache breakpoint with ephemeral turns", () => {
98133
});
99134
}
100135

136+
for (const provider of ["anthropic", "zen-messages"]) {
137+
test(`${provider}: system-role ephemeral stays in messages and off the cached prefix`, () => {
138+
const persisted = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")];
139+
const inject = systemTurn("inject");
140+
const request = systemWireAdapters
141+
.resolve(sourceFor(provider))
142+
.buildRequest([...persisted, inject], "test-model", {
143+
ephemeralTurns: [inject],
144+
} as InferenceOptions);
145+
const body = wireBody(request.body);
146+
147+
expect(body.messages).toHaveLength(4);
148+
expect(body.messages[3]?.role).toBe("system");
149+
expect(body.messages[3]?.content[0]?.text).toBe("inject");
150+
expect(
151+
body.messages[2]?.content[body.messages[2].content.length - 1]
152+
?.cache_control,
153+
).toEqual({ type: "ephemeral" });
154+
expect(
155+
body.messages[3]?.content.filter(
156+
(block) => block.cache_control !== undefined,
157+
),
158+
).toEqual([]);
159+
});
160+
}
161+
162+
test("builtin adapter + systemPrompt drops system-role ephemeral from messages", () => {
163+
const persisted = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")];
164+
const inject = systemTurn("inject");
165+
const request = adapters
166+
.resolve(sourceFor("anthropic"))
167+
.buildRequest([...persisted, inject], "test-model", {
168+
systemPrompt: "stable prompt",
169+
ephemeralTurns: [inject],
170+
} as InferenceOptions);
171+
const body = wireBody(request.body);
172+
173+
expect(body.messages.map((message) => message.role)).toEqual([
174+
"user",
175+
"assistant",
176+
"user",
177+
]);
178+
expect(
179+
body.messages.flatMap((message) =>
180+
message.content.map((block) => block.text),
181+
),
182+
).not.toContain("inject");
183+
expect(body.system?.[0]?.text).toBe("stable prompt");
184+
});
185+
101186
test("system and tools breakpoints stay untouched while the nudge is attached", () => {
102187
const persisted = [
103188
{ ...userTurn("sys"), role: "system" as const },
@@ -129,30 +214,6 @@ describe("anthropic cache breakpoint with ephemeral turns", () => {
129214
).toEqual([]);
130215
});
131216

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-
156217
test("without ephemeral turns the request is byte-identical to the base adapter", () => {
157218
const turns = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")];
158219
const base = inner

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

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

2525
function ephemeralSuffixLength(options: ExtendedInferenceOptions): number {
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.
32-
const turns = options.ephemeralTurns;
33-
if (turns === undefined) return 0;
34-
return turns.filter((turn) => turn.role !== "system").length;
26+
// Every ephemeral turn is suffix, including mid-conversation `role: "system"`
27+
// injects. Role choice is ephemeralInjectRole; this wrapper only moves the
28+
// cache breakpoint before that suffix.
29+
return options.ephemeralTurns?.length ?? 0;
3530
}
3631

3732
function isWireBlock(block: unknown): block is WireBlock {
Lines changed: 22 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,138 +1,35 @@
11
import { describe, expect, test } from "bun:test";
2-
import { createBuiltinRegistry } from "@intx/inference/providers";
3-
import type {
4-
ConversationTurn,
5-
InferenceOptions,
6-
LastCycleSource,
7-
} from "@intx/types/runtime";
82
import {
9-
adapterPreservesMidSystem,
103
ephemeralInjectRole,
114
midConversationSystemSupported,
125
} from "./ephemeral-inject-role.js";
136

14-
function sourceFor(provider: string): LastCycleSource {
15-
return { sourceId: `test-${provider}`, provider, model: "test-model" };
16-
}
17-
18-
function userTurn(text: string): ConversationTurn {
19-
return {
20-
role: "user",
21-
timestamp: 0,
22-
content: [{ type: "text", text }],
23-
};
24-
}
25-
26-
function assistantTurn(text: string): ConversationTurn {
27-
return {
28-
role: "assistant",
29-
timestamp: 0,
30-
content: [{ type: "text", text }],
31-
};
32-
}
33-
34-
function systemTurn(text: string): ConversationTurn {
35-
return {
36-
role: "system",
37-
timestamp: 0,
38-
content: [{ type: "text", text }],
39-
};
40-
}
41-
42-
type WireBody = {
43-
messages: { role: string; content: { text?: string }[] }[];
44-
system?: { text?: string }[];
45-
};
46-
47-
function wireBody(body: string): WireBody {
48-
return JSON.parse(body) as WireBody;
49-
}
50-
51-
function systemText(body: WireBody): string {
52-
return (body.system ?? []).map((block) => block.text ?? "").join("\n\n");
53-
}
7+
const SUPPORT_TABLE = [
8+
["anthropic", "claude-sonnet-4-6", false],
9+
["zen-messages", "claude-sonnet-4-6", false],
10+
["anthropic", "claude-fable-5-1", true],
11+
["zen-messages", "claude-fable-5", true],
12+
["opencode-go-messages", "mythos", true],
13+
["anthropic", "claude-opus-4-8", true],
14+
["anthropic", "claude-opus-4.8", true],
15+
["anthropic", "claude-opus-5", true],
16+
["anthropic", "claude-opus-4-6", false],
17+
["openai", "claude-fable-5-1", false],
18+
["grok-responses", "grok-4", false],
19+
["openai", "gpt-5", false],
20+
] as const;
5421

5522
describe("midConversationSystemSupported", () => {
56-
const providers = ["anthropic", "zen-messages", "opencode-go-messages"];
57-
const models = [
58-
"fable",
59-
"mythos",
60-
"claude-opus-4-8",
61-
"claude-opus-4.8",
62-
"claude-opus-5",
63-
];
64-
for (const provider of providers) {
65-
for (const model of models) {
66-
test(`supports ${provider} / ${model}`, () => {
67-
expect(midConversationSystemSupported(provider, model)).toBe(true);
68-
});
69-
}
70-
}
71-
72-
test("rejects non-anthropic providers even on new models", () => {
73-
expect(midConversationSystemSupported("openai", "fable")).toBe(false);
74-
expect(midConversationSystemSupported("openai", "claude-opus-5")).toBe(
75-
false,
76-
);
77-
});
78-
79-
test("rejects older models on anthropic providers", () => {
80-
expect(
81-
midConversationSystemSupported("anthropic", "claude-sonnet-4-5"),
82-
).toBe(false);
83-
expect(
84-
midConversationSystemSupported("zen-messages", "claude-opus-4-6"),
85-
).toBe(false);
23+
test.each(SUPPORT_TABLE)("%s / %s → %s", (provider, model, supported) => {
24+
expect(midConversationSystemSupported(provider, model)).toBe(supported);
8625
});
8726
});
8827

8928
describe("ephemeralInjectRole", () => {
90-
test("adapter does not preserve mid-conversation system turns", () => {
91-
expect(adapterPreservesMidSystem()).toBe(false);
92-
});
93-
94-
test("never returns system, even where mid-conversation system is supported", () => {
95-
for (const provider of [
96-
"anthropic",
97-
"zen-messages",
98-
"opencode-go-messages",
99-
"openai",
100-
]) {
101-
for (const model of ["fable", "mythos", "claude-opus-4-8", "other"]) {
102-
expect(ephemeralInjectRole(provider, model)).toBe("user");
103-
}
104-
}
105-
});
106-
});
107-
108-
describe("builtin anthropic adapter vs system-role inject (real registry, no stubs)", () => {
109-
const persisted = [userTurn("q1"), assistantTurn("a1")];
110-
const injectText = "wrap up soon";
111-
112-
test("mid-conversation system turn is hoisted to the head system block", () => {
113-
const request = createBuiltinRegistry()
114-
.resolve(sourceFor("anthropic"))
115-
.buildRequest(
116-
[...persisted, systemTurn(injectText)],
117-
"claude-opus-4-8",
118-
{},
119-
);
120-
const body = wireBody(request.body);
121-
expect(body.messages.some((m) => m.role === "system")).toBe(false);
122-
expect(systemText(body)).toContain(injectText);
123-
});
124-
125-
test("with systemPrompt set, a system-role inject is dropped", () => {
126-
const options = { systemPrompt: "STABLE PROMPT" } as InferenceOptions;
127-
const request = createBuiltinRegistry()
128-
.resolve(sourceFor("anthropic"))
129-
.buildRequest(
130-
[...persisted, systemTurn(injectText)],
131-
"claude-opus-4-8",
132-
options,
133-
);
134-
const body = wireBody(request.body);
135-
expect(systemText(body)).toBe("STABLE PROMPT");
136-
expect(JSON.stringify(body.messages)).not.toContain(injectText);
137-
});
29+
test.each(SUPPORT_TABLE.map(([provider, model]) => [provider, model]))(
30+
"%s / %s → user until the adapter preserves mid-conversation system",
31+
(provider, model) => {
32+
expect(ephemeralInjectRole(provider, model)).toBe("user");
33+
},
34+
);
13835
});

0 commit comments

Comments
 (0)