diff --git a/changelog.d/fixes/9436-claude-system-role-cache-boundary.md b/changelog.d/fixes/9436-claude-system-role-cache-boundary.md new file mode 100644 index 0000000000..c2433f470a --- /dev/null +++ b/changelog.d/fixes/9436-claude-system-role-cache-boundary.md @@ -0,0 +1 @@ +- **fix(sse):** hoisting a mid-conversation `system`/`developer` message into the top-level `system` field no longer carries its `cache_control` marker along, which left the conversation history without a cache breakpoint and forced a full re-read plus a rebuild on the next turn. The boundary is moved to the nearest preceding block that can carry one, and now survives the rewrites that turn `tool_result` and inlined file/document blocks into plain text; if the target block is already marked, both markers are kept unless Anthropic's TTL ordering forbids it. Both hoisting paths are fixed — `extractSystemRoleMessages` and `extractSystemMessagesToBody`. Regression guard: `tests/unit/claude-system-role-cache-boundary.test.ts`. ([#9436](https://github.com/diegosouzapw/OmniRoute/issues/9436)) diff --git a/open-sse/handlers/chatCore/claudeSystemRole.ts b/open-sse/handlers/chatCore/claudeSystemRole.ts index 0c22180166..a661847f29 100644 --- a/open-sse/handlers/chatCore/claudeSystemRole.ts +++ b/open-sse/handlers/chatCore/claudeSystemRole.ts @@ -7,8 +7,96 @@ * chat role, so they must be hoisted. `developer` is OpenAI's Responses-API rename of `system` and * is treated identically. Mutates the payload in place; behaviour is byte-identical to the previous * top-level definition (still re-exported from chatCore.ts for existing importers/tests). + * + * `relocateHoistedCacheBoundary` keeps that hoist from destroying the client's prompt-cache + * layout (#9436); both hoisting implementations share it. */ +export type HoistedCacheBoundary = "moved" | "kept" | "dropped"; + +/** Effective cache TTL of a `cache_control` value; Anthropic defaults to 5m when `ttl` is absent. */ +function effectiveTtl(marker: unknown): string { + const ttl = (marker as Record | null | undefined)?.ttl; + return typeof ttl === "string" ? ttl : "5m"; +} + +/** + * Whether a content block can carry a cache breakpoint. Excludes blocks Anthropic does not accept + * as one (thinking) and blocks the upstream normalisation discards or empties out anyway. + */ +function isCacheBreakpointTarget(block: unknown): block is Record { + if (block === null || typeof block !== "object") return false; + const candidate = block as Record; + switch (candidate.type) { + case "text": + // Empty text blocks are stripped before the payload goes upstream. + return typeof candidate.text === "string" && candidate.text.length > 0; + case "tool_use": + case "image": + case "image_url": + case "file": + case "file_url": + case "document": + return true; + case "tool_result": { + // A tool_result that yields no text collapses to nothing during normalisation. + const payload = candidate.content ?? candidate.text ?? candidate.output; + if (typeof payload === "string") return payload.length > 0; + if (Array.isArray(payload)) { + // Only the non-empty text parts of the array survive; images and unknown parts do not. + return payload.some((part) => { + const text = (part as Record | null)?.text; + return ( + (part as Record | null)?.type === "text" && + typeof text === "string" && + text.length > 0 + ); + }); + } + return payload != null; + } + default: + // thinking, redacted_thinking, and anything unrecognised. + return false; + } +} + +/** + * Preserves a message-level cache boundary when a marked system/developer block is hoisted into + * top-level `system[]`. + * + * The marker is moved to the nearest preceding block that can carry a breakpoint. If that block is + * already marked, both are kept — except where the hoisted marker, which ends up ahead of the + * target in `system[]`, would put a 5m breakpoint before a 1h one; Anthropic requires the longer + * TTL first, so the hoisted marker is dropped instead. + * + * @returns `"moved"` or `"dropped"` — the caller must remove the marker from the hoisted block; + * `"kept"` — the marker stays on it + */ +export function relocateHoistedCacheBoundary( + marker: unknown, + preceding: ReadonlyArray<{ content?: unknown }> +): HoistedCacheBoundary { + for (let i = preceding.length - 1; i >= 0; i--) { + const content = preceding[i]?.content; + if (!Array.isArray(content)) continue; + for (let j = content.length - 1; j >= 0; j--) { + const block = content[j]; + if (!isCacheBreakpointTarget(block)) continue; + if (block.cache_control == null) { + block.cache_control = marker; + return "moved"; + } + // Occupied: overwriting would discard the client's own marker, and stepping further back + // would only shorten the prefix — so both stay, unless the TTL order forbids it. + return effectiveTtl(marker) === "5m" && effectiveTtl(block.cache_control) === "1h" + ? "dropped" + : "kept"; + } + } + return "kept"; +} + export function extractSystemRoleMessages(payload: Record): void { if (!Array.isArray(payload.messages)) return; const messages = payload.messages as Array<{ role?: unknown; content?: unknown }>; @@ -23,13 +111,27 @@ export function extractSystemRoleMessages(payload: Record): voi if (systemMessages.length === 0) return; const extraBlocks: Array> = []; - for (const sm of systemMessages) { + // Walk in order rather than over the filtered list: re-anchoring a hoisted `cache_control` + // needs the messages that precede it and stay behind (#9436). + const preceding: Array<{ content?: unknown }> = []; + for (const sm of messages) { + if (!isSystemRole(sm.role)) { + preceding.push(sm); + continue; + } if (typeof sm.content === "string" && sm.content.length > 0) { extraBlocks.push({ type: "text", text: sm.content }); } else if (Array.isArray(sm.content)) { for (const block of sm.content as Array>) { if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) { - extraBlocks.push({ ...block }); + const hoisted = { ...block }; + if ( + hoisted.cache_control != null && + relocateHoistedCacheBoundary(hoisted.cache_control, preceding) !== "kept" + ) { + delete hoisted.cache_control; + } + extraBlocks.push(hoisted); } } } diff --git a/open-sse/handlers/chatCore/claudeUpstreamMessages.ts b/open-sse/handlers/chatCore/claudeUpstreamMessages.ts index b34ff7b36d..3202ed6502 100644 --- a/open-sse/handlers/chatCore/claudeUpstreamMessages.ts +++ b/open-sse/handlers/chatCore/claudeUpstreamMessages.ts @@ -12,27 +12,58 @@ */ import type { ClaudeContentBlock, ClaudeMessage } from "./claudeMessageTypes.ts"; -import { extractSystemRoleMessages } from "./claudeSystemRole.ts"; +import { extractSystemRoleMessages, relocateHoistedCacheBoundary } from "./claudeSystemRole.ts"; import { splitMisplacedToolResults } from "../../translator/helpers/claudeHelper.ts"; type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; +/** + * Carries a replaced block's `cache_control` onto its substitute. Rewriting a marked block into a + * plain text block would otherwise drop the breakpoint the client (or the #9436 hoist) put there. + */ +function withCacheControl( + replacement: ClaudeContentBlock, + original: ClaudeContentBlock +): ClaudeContentBlock { + if (original.cache_control != null) replacement.cache_control = original.cache_control; + return replacement; +} + export function extractSystemMessagesToBody(payload: Record) { if (!Array.isArray(payload.messages)) return; const messages = payload.messages as ClaudeMessage[]; - const systemMessages = messages.filter((m) => { - const role = String(m.role || "").toLowerCase(); - return role === "system" || role === "developer"; - }); + const isSystemRole = (role: unknown): boolean => { + const normalized = String(role || "").toLowerCase(); + return normalized === "system" || normalized === "developer"; + }; + const systemMessages = messages.filter((m) => isSystemRole(m.role)); if (systemMessages.length === 0) return; const extraBlocks: ClaudeContentBlock[] = []; - for (const sm of systemMessages) { + // Same in-order walk as extractSystemRoleMessages: re-anchoring a hoisted `cache_control` + // needs the messages that precede it and stay behind (#9436). + const preceding: ClaudeMessage[] = []; + for (const sm of messages) { + if (!isSystemRole(sm.role)) { + preceding.push(sm); + continue; + } if (typeof sm.content === "string" && sm.content.length > 0) { extraBlocks.push({ type: "text", text: sm.content }); } else if (Array.isArray(sm.content)) { for (const block of sm.content as ClaudeContentBlock[]) { if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) { - extraBlocks.push(block); + // Blocks are pushed by reference here (the sibling implementation spreads them), so + // only a block whose marker actually moves is copied. + if ( + block.cache_control != null && + relocateHoistedCacheBoundary(block.cache_control, preceding) !== "kept" + ) { + const withoutMarker: ClaudeContentBlock = { ...block }; + delete withoutMarker.cache_control; + extraBlocks.push(withoutMarker); + } else { + extraBlocks.push(block); + } } } } @@ -47,10 +78,7 @@ export function extractSystemMessagesToBody(payload: Record) { payload.system = extraBlocks; } } - payload.messages = messages.filter((m) => { - const role = String(m.role || "").toLowerCase(); - return role !== "system" && role !== "developer"; - }); + payload.messages = messages.filter((m) => !isSystemRole(m.role)); } export function normalizeClaudeUpstreamMessages( @@ -104,7 +132,7 @@ export function normalizeClaudeUpstreamMessages( const fileName = (block.file as Record)?.name ?? block.name ?? "attachment"; if (typeof fileContent === "string" && fileContent.length > 0) { - return [{ type: "text", text: `[${fileName}]\n${fileContent}` }]; + return [withCacheControl({ type: "text", text: `[${fileName}]\n${fileContent}` }, block)]; } } return [block]; @@ -126,7 +154,9 @@ export function normalizeClaudeUpstreamMessages( .join("\n") : JSON.stringify(resultContent); if (resultText.length > 0) { - return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }]; + return [ + withCacheControl({ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }, block), + ]; } return []; } diff --git a/tests/unit/claude-system-role-cache-boundary.test.ts b/tests/unit/claude-system-role-cache-boundary.test.ts new file mode 100644 index 0000000000..6d5a12d609 --- /dev/null +++ b/tests/unit/claude-system-role-cache-boundary.test.ts @@ -0,0 +1,409 @@ +// tests/unit/claude-system-role-cache-boundary.test.ts +// Regression coverage for #9436. +// Hoisting a marked system/developer block must not remove the effective cache boundary from +// messages[]. Covers both hoisting implementations plus the native Claude path. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { extractSystemRoleMessages } from "../../open-sse/handlers/chatCore/claudeSystemRole.ts"; +import { + extractSystemMessagesToBody, + normalizeClaudeUpstreamMessages, +} from "../../open-sse/handlers/chatCore/claudeUpstreamMessages.ts"; + +/** Breakpoint layout in the same terms the incident telemetry reports it: sys=N, msg=N. */ +function layout(payload: Record): { sys: number; msg: number } { + const marked = (blocks: unknown): number => + Array.isArray(blocks) + ? blocks.filter( + (b) => b !== null && typeof b === "object" && (b as Record).cache_control != null + ).length + : 0; + const messages = (Array.isArray(payload.messages) ? payload.messages : []) as Array<{ + content?: unknown; + }>; + return { + sys: marked(payload.system), + msg: messages.reduce((n, m) => n + marked(m.content), 0), + }; +} + +/** + * A Claude Code shaped turn: a cached system prompt, an accumulated mid-conversation system note, + * and the second breakpoint the client advances each round. `markerOn` selects where that second + * breakpoint sits — on the system note (the reported defect) or on the newest tool_result (what the + * healthy calls in the incident window show). + */ +function claudeCodeTurn(markerOn: "systemNote" | "toolResult"): Record { + const note: Record = { type: "text", text: "mid-conversation system note" }; + const toolResult: Record = { + type: "tool_result", + tool_use_id: "toolu_1", + content: "ok", + }; + (markerOn === "systemNote" ? note : toolResult).cache_control = { type: "ephemeral" }; + + return { + system: [{ type: "text", text: "base system prompt", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "turn 1" }] }, + { role: "assistant", content: [{ type: "text", text: "reply 1" }] }, + { role: "system", content: [note] }, + { role: "user", content: [toolResult] }, + ], + }; +} + +test("#9436 hoisting a marked system note keeps a cache boundary inside messages", () => { + const payload = claudeCodeTurn("systemNote"); + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); + + extractSystemRoleMessages(payload); + + // Before the fix this was { sys: 2, msg: 0 } — the production signature behind 99.4 % of all + // uncached input tokens in the incident window. + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); +}); + +test("#9436 the boundary lands before the hoisted note, never after it", () => { + const payload = claudeCodeTurn("systemNote"); + extractSystemRoleMessages(payload); + + const messages = payload.messages as Array<{ content: Array> }>; + assert.equal(messages.length, 3); + // The assistant reply directly preceded the system note — same cut point in the reordered prefix. + assert.deepEqual(messages[1].content[0].cache_control, { type: "ephemeral" }); + // Moving it onto the newer tool_result would cache content the client never marked. + assert.equal(messages[2].content[0].cache_control, undefined); +}); + +test("#9436 the marker does not ride along into the system array", () => { + const payload = claudeCodeTurn("systemNote"); + extractSystemRoleMessages(payload); + + const system = payload.system as Array>; + assert.equal(system.length, 2); // merged, not dropped — #7293 + assert.equal(system[1].text, "mid-conversation system note"); + assert.equal(system[1].cache_control, undefined); +}); + +test("hoisting still lifts system/developer content out of messages (#7293)", () => { + const payload = claudeCodeTurn("toolResult"); + extractSystemRoleMessages(payload); + + const messages = payload.messages as Array<{ role: string }>; + assert.ok(!messages.some((m) => m.role === "system")); + assert.equal((payload.system as unknown[]).length, 2); +}); + +test("a healthy cache-boundary layout remains unchanged", () => { + const payload = claudeCodeTurn("toolResult"); + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); + + extractSystemRoleMessages(payload); + + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); + const messages = payload.messages as Array<{ content: Array> }>; + assert.deepEqual(messages[2].content[0].cache_control, { type: "ephemeral" }); // still the tool_result + assert.equal(messages[1].content[0].cache_control, undefined); // no marker added +}); + +/** A turn whose re-anchor target is already marked, so the two TTLs decide the outcome. */ +function occupiedTarget(targetTtl: string, hoistedTtl: string): Record { + return { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "turn 1", cache_control: { type: "ephemeral", ttl: targetTtl } }, + ], + }, + { + role: "system", + content: [ + { type: "text", text: "note", cache_control: { type: "ephemeral", ttl: hoistedTtl } }, + ], + }, + { role: "user", content: [{ type: "text", text: "turn 2" }] }, + ], + }; +} + +/** Reads back both markers of an `occupiedTarget` payload after hoisting. */ +function markers(payload: Record): { target: unknown; hoisted: unknown } { + const messages = payload.messages as Array<{ content: Array> }>; + const system = payload.system as Array>; + return { target: messages[0].content[0].cache_control, hoisted: system[0].cache_control }; +} + +test("occupied 1h target, hoisted 5m marker: only the target marker survives", () => { + // Keeping both would leave a 5m breakpoint in system[] ahead of the 1h one in messages[]; + // Anthropic requires the longer TTL first. + const payload = occupiedTarget("1h", "5m"); + extractSystemRoleMessages(payload); + + assert.deepEqual(markers(payload).target, { type: "ephemeral", ttl: "1h" }); + assert.equal(markers(payload).hoisted, undefined); + assert.deepEqual(layout(payload), { sys: 0, msg: 1 }); +}); + +test("occupied 5m target, hoisted 1h marker: both markers survive", () => { + const payload = occupiedTarget("5m", "1h"); + extractSystemRoleMessages(payload); + + assert.deepEqual(markers(payload).target, { type: "ephemeral", ttl: "5m" }); + assert.deepEqual(markers(payload).hoisted, { type: "ephemeral", ttl: "1h" }); + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); +}); + +test("occupied target, both markers 5m: both survive", () => { + const payload = occupiedTarget("5m", "5m"); + extractSystemRoleMessages(payload); + + assert.deepEqual(markers(payload).target, { type: "ephemeral", ttl: "5m" }); + assert.deepEqual(markers(payload).hoisted, { type: "ephemeral", ttl: "5m" }); + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); +}); + +test("occupied target, both markers 1h: both survive", () => { + const payload = occupiedTarget("1h", "1h"); + extractSystemRoleMessages(payload); + + assert.deepEqual(markers(payload).target, { type: "ephemeral", ttl: "1h" }); + assert.deepEqual(markers(payload).hoisted, { type: "ephemeral", ttl: "1h" }); + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); +}); + +test("two consecutive marked system messages compete for the same target", () => { + // No message between them, so both would re-anchor onto the same block. The first takes it; + // the second keeps its marker rather than overwriting the first. + const payload: Record = { + messages: [ + { role: "user", content: [{ type: "text", text: "turn 1" }] }, + { + role: "system", + content: [{ type: "text", text: "note a", cache_control: { type: "ephemeral", ttl: "5m" } }], + }, + { + role: "system", + content: [{ type: "text", text: "note b", cache_control: { type: "ephemeral", ttl: "1h" } }], + }, + { role: "user", content: [{ type: "text", text: "turn 2" }] }, + ], + }; + + extractSystemRoleMessages(payload); + + const messages = payload.messages as Array<{ content: Array> }>; + const system = payload.system as Array>; + assert.deepEqual(messages[0].content[0].cache_control, { type: "ephemeral", ttl: "5m" }); + assert.equal(system[0].cache_control, undefined); // note a — moved + assert.deepEqual(system[1].cache_control, { type: "ephemeral", ttl: "1h" }); // note b — kept + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); // two in, two out +}); + +test("several marked system messages keep one boundary each, without adding any", () => { + // The `sys=3,msg=0` shape from the incident window: more than one hoisted block carries a marker. + const payload: Record = { + system: [{ type: "text", text: "base", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "turn 1" }] }, + { + role: "system", + content: [{ type: "text", text: "note a", cache_control: { type: "ephemeral" } }], + }, + { role: "assistant", content: [{ type: "text", text: "reply 1" }] }, + { + role: "system", + content: [{ type: "text", text: "note b", cache_control: { type: "ephemeral" } }], + }, + { role: "user", content: [{ type: "text", text: "turn 2" }] }, + ], + }; + + extractSystemRoleMessages(payload); + + const after = layout(payload); + assert.equal(after.sys, 1); // only the client's own system prompt keeps its marker + assert.equal(after.msg, 2); // one boundary per hoisted note, each at its own position + // Anthropic caps a request at four breakpoints, so relocation must never inflate the count. + assert.equal(after.sys + after.msg, 3); +}); + +test("with no usable preceding message the marker stays on the hoisted block", () => { + // A shorter cached prefix is acceptable; a longer one would cache content the client did not + // mark. Nothing precedes the note here, so system[] is the same cut point. + const payload: Record = { + messages: [ + { + role: "system", + content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }], + }, + { role: "user", content: [{ type: "text", text: "turn 1" }] }, + ], + }; + + extractSystemRoleMessages(payload); + + assert.deepEqual(layout(payload), { sys: 1, msg: 0 }); + const messages = payload.messages as Array<{ content: Array> }>; + assert.equal(messages[0].content[0].cache_control, undefined); +}); + +test("#9436 also holds for the second hoisting implementation", () => { + // extractSystemMessagesToBody is a near-duplicate of extractSystemRoleMessages; a fix that + // touches only one of them leaves the other path broken. + const payload = claudeCodeTurn("systemNote"); + extractSystemMessagesToBody(payload); + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); +}); + +test("the second hoisting implementation resolves an occupied target identically", () => { + const dropped = occupiedTarget("1h", "5m"); + extractSystemMessagesToBody(dropped); + assert.equal(markers(dropped).hoisted, undefined); + assert.deepEqual(layout(dropped), { sys: 0, msg: 1 }); + + const kept = occupiedTarget("5m", "1h"); + extractSystemMessagesToBody(kept); + assert.deepEqual(markers(kept).hoisted, { type: "ephemeral", ttl: "1h" }); + assert.deepEqual(layout(kept), { sys: 1, msg: 1 }); +}); + +test("a thinking block is never chosen as the boundary", () => { + const payload: Record = { + messages: [ + { + role: "assistant", + content: [ + { type: "text", text: "reply 1" }, + { type: "thinking", thinking: "…", signature: "sig" }, + ], + }, + { + role: "system", + content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }], + }, + { role: "user", content: [{ type: "text", text: "turn 2" }] }, + ], + }; + + extractSystemRoleMessages(payload); + + const messages = payload.messages as Array<{ content: Array> }>; + assert.deepEqual(messages[0].content[0].cache_control, { type: "ephemeral" }); // the text block + assert.equal(messages[0].content[1].cache_control, undefined); // the thinking block + assert.deepEqual(layout(payload), { sys: 0, msg: 1 }); +}); + +test("with only thinking and empty text before it the marker stays on the hoisted block", () => { + const payload: Record = { + messages: [ + { role: "user", content: [{ type: "text", text: "" }] }, + { role: "assistant", content: [{ type: "thinking", thinking: "…", signature: "sig" }] }, + { + role: "system", + content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }], + }, + { role: "user", content: [{ type: "text", text: "turn 2" }] }, + ], + }; + + extractSystemRoleMessages(payload); + + assert.deepEqual(layout(payload), { sys: 1, msg: 0 }); + assert.deepEqual((payload.system as Array>)[0].cache_control, { + type: "ephemeral", + }); +}); + +test("a tool_result whose array yields no text is skipped as a target", () => { + // Normalisation keeps only the non-empty text parts of a tool_result array, so this block is + // dropped entirely — anchoring the boundary on it would lose the marker again. + const payload: Record = { + messages: [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_1", + content: [ + { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBOR" } }, + { type: "text", text: "" }, + ], + }, + ], + }, + { + role: "system", + content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }], + }, + { role: "user", content: [{ type: "text", text: "turn 2" }] }, + ], + }; + + extractSystemRoleMessages(payload); + + assert.deepEqual(layout(payload), { sys: 1, msg: 0 }); + assert.deepEqual((payload.system as Array>)[0].cache_control, { + type: "ephemeral", + }); +}); + +test("a marker relocated onto a tool_result survives its collapse into text", () => { + const payload: Record = { + messages: [ + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }] }, + { + role: "system", + content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }], + }, + { role: "user", content: [{ type: "text", text: "turn 2" }] }, + ], + }; + + // Without preserveToolResultBlocks the tool_result is rewritten into a plain text block. + normalizeClaudeUpstreamMessages(payload); + + const messages = payload.messages as Array<{ content: Array> }>; + assert.equal(messages[0].content[0].type, "text"); + assert.match(messages[0].content[0].text as string, /^\[Tool Result: toolu_1\]/); + assert.deepEqual(messages[0].content[0].cache_control, { type: "ephemeral" }); + assert.deepEqual(layout(payload), { sys: 0, msg: 1 }); +}); + +test("a marker relocated onto an inlined document survives its conversion to text", () => { + const payload: Record = { + messages: [ + { + role: "user", + content: [ + { type: "document", name: "notes.txt", document: {}, content: "document body" }, + ], + }, + { + role: "system", + content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }], + }, + ], + }; + + normalizeClaudeUpstreamMessages(payload); + + const messages = payload.messages as Array<{ content: Array> }>; + assert.equal(messages[0].content[0].type, "text"); + assert.match(messages[0].content[0].text as string, /^\[notes\.txt\]\ndocument body$/); + assert.deepEqual(messages[0].content[0].cache_control, { type: "ephemeral" }); + assert.deepEqual(layout(payload), { sys: 0, msg: 1 }); +}); + +test("#9436 holds on the native Claude path through normalizeClaudeUpstreamMessages", () => { + // Path proof: the hoist is not confined to the semantic-passthrough branch — the function the + // native Claude passthrough calls routes through extractSystemRoleMessages. + const payload = claudeCodeTurn("systemNote"); + normalizeClaudeUpstreamMessages(payload, { preserveToolResultBlocks: true }); + + const messages = payload.messages as Array<{ role: string }>; + assert.ok(!messages.some((m) => m.role === "system")); + assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); +});