Skip to content

Commit 8b5f33c

Browse files
committed
feat(provider): keep ephemeral injects on the user role
1 parent 081c161 commit 8b5f33c

3 files changed

Lines changed: 208 additions & 1 deletion

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ 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.
2629
const turns = options.ephemeralTurns;
2730
if (turns === undefined) return 0;
28-
return turns.filter((turn) => turn.role !== "system").length;
31+
return turns.length;
2932
}
3033

3134
function isWireBlock(block: unknown): block is WireBlock {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
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";
8+
import {
9+
adapterPreservesMidSystem,
10+
ephemeralInjectRole,
11+
midConversationSystemSupported,
12+
} from "./ephemeral-inject-role.js";
13+
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+
}
54+
55+
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);
86+
});
87+
});
88+
89+
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+
});
138+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
OPENCODE_GO_MESSAGES_PROVIDER,
3+
ZEN_MESSAGES_PROVIDER,
4+
} from "./anthropic-session-adapter.js";
5+
6+
const ANTHROPIC_MESSAGES_PROVIDERS: ReadonlySet<string> = new Set([
7+
"anthropic",
8+
ZEN_MESSAGES_PROVIDER,
9+
OPENCODE_GO_MESSAGES_PROVIDER,
10+
]);
11+
12+
const MID_CONVERSATION_SYSTEM_MODELS: readonly string[] = [
13+
"fable",
14+
"mythos",
15+
"opus-4-8",
16+
"opus-5",
17+
];
18+
19+
function normalizeModel(model: string): string {
20+
return model.toLowerCase().replaceAll(".", "-");
21+
}
22+
23+
/**
24+
* True when the provider/model pair is known to accept a system prompt
25+
* mid-conversation (past the head of the request). Provider and model must
26+
* both match: the Anthropic-messages wire shape on one side, a model new
27+
* enough to honor a mid-list system block on the other.
28+
*/
29+
export function midConversationSystemSupported(
30+
provider: string,
31+
model: string,
32+
): boolean {
33+
if (!ANTHROPIC_MESSAGES_PROVIDERS.has(provider)) return false;
34+
const normalized = normalizeModel(model);
35+
return MID_CONVERSATION_SYSTEM_MODELS.some((fragment) =>
36+
normalized.includes(fragment),
37+
);
38+
}
39+
40+
/**
41+
* False until a vendor adapter carries a mid-conversation system turn
42+
* through to the wire in position. The builtin Anthropic adapter hoists
43+
* every system-role turn into the head `system` block — and drops the
44+
* turn content entirely when `systemPrompt` is set — so a system-role
45+
* inject would either move to the head or vanish.
46+
*/
47+
export function adapterPreservesMidSystem(): false {
48+
return false;
49+
}
50+
51+
// mailbox = work, ephemeral = inject: the durable transcript carries the
52+
// working conversation, while ephemeral turns ride exactly one inference.
53+
// An inject must never take the system role — the wire would hoist or drop
54+
// it — so this stays "user" until adapterPreservesMidSystem() flips.
55+
export function ephemeralInjectRole(
56+
provider: string,
57+
model: string,
58+
): "user" | "system" {
59+
if (
60+
adapterPreservesMidSystem() &&
61+
midConversationSystemSupported(provider, model)
62+
) {
63+
return "system";
64+
}
65+
return "user";
66+
}

0 commit comments

Comments
 (0)