Compare commits

...

3 Commits

Author SHA1 Message Date
diegosouzapw
3a5eb7cdb3 Merge remote-tracking branch 'origin/release/v3.8.50' into HEAD 2026-08-07 21:28:13 -03:00
Diego Rodrigues de Sa e Souza
5cbff99b87 Merge branch 'release/v3.8.50' into fix/9436-preserve-cache-boundary 2026-08-05 23:03:53 -03:00
LeonG606
78545a010d fix(sse): preserve client cache boundaries when hoisting system roles
Hoisting a mid-conversation `system`/`developer` message into the top-level
`system` field carried its `cache_control` marker along. Anthropic assembles the
cache prefix as tools -> system -> messages, so the marker ended the cached
prefix at the system block and left the accumulated conversation without a
breakpoint: that turn was billed as fresh input and the next one rebuilt the
cache.

`relocateHoistedCacheBoundary` moves the marker to the nearest preceding block
that can carry a breakpoint, skipping thinking blocks, empty text and anything
the upstream normalisation discards or empties out. If that block already
carries the client's own marker, both are kept - unless the hoisted one, now
ahead of the target in `system[]`, would put a 5m breakpoint before a 1h one,
which Anthropic rejects; it is dropped in that case. Either way the breakpoint
count never grows.

normalizeClaudeUpstreamMessages rewrites tool_result and inlined file/document
blocks into plain text after the hoist, which silently discarded any marker on
them - including a relocated one. The replacement block now inherits it.

Both hoisting implementations share the helper; a fix touching only
claudeSystemRole.ts would leave extractSystemMessagesToBody broken, and the
native Claude path reaches the former through normalizeClaudeUpstreamMessages.
Capability-gated hoisting for strict providers (#7293) is unaffected.

Fixes #9436
2026-08-04 22:09:32 +02:00
4 changed files with 557 additions and 15 deletions

View File

@@ -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))

View File

@@ -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<string, unknown> | 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<string, unknown> {
if (block === null || typeof block !== "object") return false;
const candidate = block as Record<string, unknown>;
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<string, unknown> | null)?.text;
return (
(part as Record<string, unknown> | 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<string, unknown>): 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<string, unknown>): voi
if (systemMessages.length === 0) return;
const extraBlocks: Array<Record<string, unknown>> = [];
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<Record<string, unknown>>) {
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);
}
}
}

View File

@@ -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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>)?.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 [];
}

View File

@@ -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<string, unknown>): { sys: number; msg: number } {
const marked = (blocks: unknown): number =>
Array.isArray(blocks)
? blocks.filter(
(b) => b !== null && typeof b === "object" && (b as Record<string, unknown>).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<string, unknown> {
const note: Record<string, unknown> = { type: "text", text: "mid-conversation system note" };
const toolResult: Record<string, unknown> = {
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<Record<string, unknown>> }>;
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<Record<string, unknown>>;
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<Record<string, unknown>> }>;
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<string, unknown> {
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<string, unknown>): { target: unknown; hoisted: unknown } {
const messages = payload.messages as Array<{ content: Array<Record<string, unknown>> }>;
const system = payload.system as Array<Record<string, unknown>>;
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<string, unknown> = {
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<Record<string, unknown>> }>;
const system = payload.system as Array<Record<string, unknown>>;
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<string, unknown> = {
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<string, unknown> = {
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<Record<string, unknown>> }>;
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<string, unknown> = {
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<Record<string, unknown>> }>;
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<string, unknown> = {
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<Record<string, unknown>>)[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<string, unknown> = {
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<Record<string, unknown>>)[0].cache_control, {
type: "ephemeral",
});
});
test("a marker relocated onto a tool_result survives its collapse into text", () => {
const payload: Record<string, unknown> = {
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<Record<string, unknown>> }>;
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<string, unknown> = {
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<Record<string, unknown>> }>;
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 });
});