diff --git a/open-sse/services/compression/engines/rtk/index.ts b/open-sse/services/compression/engines/rtk/index.ts index 438d8aa405..53e0522c33 100644 --- a/open-sse/services/compression/engines/rtk/index.ts +++ b/open-sse/services/compression/engines/rtk/index.ts @@ -27,6 +27,23 @@ type ToolMeta = { toolName: string; command: string | null }; // the build-typescript filter). const SHELL_TOOL_NAME_RE = /\b(bash|shell|terminal|run_command|execute_command|exec|command)\b/; +/** + * A content block (or text sub-block) carrying `cache_control` is an explicit upstream + * prompt-cache breakpoint — the provider caches the prefix up to and INCLUDING it. Rewriting + * such a block's text invalidates that cached prefix every turn (guaranteed cache miss), the + * "provider cache broken" regression once RTK started compressing Anthropic tool_result blocks. + * So we preserve any cache_control-marked block byte-for-byte. (#3936: under caching, only ever + * preserve more of the prefix — never rewrite a client-declared breakpoint.) + */ +function hasCacheControlMarker(part: unknown): boolean { + return ( + !!part && + typeof part === "object" && + (part as Record).cache_control !== undefined && + (part as Record).cache_control !== null + ); +} + /** * Resolve the shell command + whether to skip RTK filters for a tool result, given the * tool id (OpenAI `tool_call_id` / Anthropic `tool_use_id`) and the lookup built from the @@ -456,6 +473,8 @@ function processToolResultBlocks( let compressed = false; const nextContent = content.map((part) => { if (!isAnthropicToolResultBlock(part)) return part; + // The block itself is a cache breakpoint — preserve it byte-for-byte. + if (hasCacheControlMarker(part)) return part; const toolUseId = typeof part.tool_use_id === "string" ? part.tool_use_id : null; const { command, skipFilters } = resolveToolMeta(toolUseId, toolCallLookup); const inner = part.content; @@ -473,6 +492,8 @@ function processToolResultBlocks( let blockChanged = false; const nextInner = inner.map((sub) => { if (!isTextBlock(sub) || !sub.text) return sub; + // A text sub-block can carry its own cache breakpoint — preserve it byte-for-byte. + if (hasCacheControlMarker(sub)) return sub; const processed = processRtkText(sub.text, { config, command, skipFilters }); collect(processed); if (!processed.compressed) return sub; diff --git a/tests/unit/compression/rtk-cache-control-preserve.test.ts b/tests/unit/compression/rtk-cache-control-preserve.test.ts new file mode 100644 index 0000000000..354fae365f --- /dev/null +++ b/tests/unit/compression/rtk-cache-control-preserve.test.ts @@ -0,0 +1,96 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { applyRtkCompression } from "../../../open-sse/services/compression/engines/rtk/index.ts"; + +// Regression: a tool_result block the client marked with `cache_control` is an explicit +// prompt-cache breakpoint — the upstream caches the prefix up to and INCLUDING that block. +// RTK (since the Anthropic tool_result support added in v3.8.32) rewrote the block's inner +// content, so the cached prefix no longer matched byte-for-byte → guaranteed cache miss at +// that breakpoint (reported as "provider cache again broken" after upgrading). Compression +// must never alter a block carrying `cache_control`. Mirrors #3936's invariant: under +// caching, only ever preserve more of the prefix — never rewrite a declared breakpoint. + +const GIT_STATUS = `On branch main +Your branch is up to date with 'origin/main'. + +Changes not staged for commit: + (use "git add ..." to update what will be committed) + (use "git restore ..." to discard changes in working directory) + modified: src/app.ts + modified: src/lib/util.ts + +Untracked files: + (use "git add ..." to include in what will be committed) + src/new.ts + +no changes added to commit (use "git add" and/or "git commit -a")`; + +function anthropicBody(toolResultBlock: Record) { + return { + model: "anthropic/claude-sonnet-4", + messages: [ + { role: "user", content: "run git status" }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_1", name: "bash", input: { command: "git status" } }, + ], + }, + { role: "user", content: [toolResultBlock] }, + ], + } as Record; +} + +// Pull the single tool_result block out of message[2].content[0] with a typed shape (no `any`). +function toolResultBlock(body: Record): Record { + const messages = body.messages as Array<{ content: Array> }>; + return messages[2].content[0]; +} + +test("RTK preserves a tool_result block carrying cache_control byte-for-byte (string content)", () => { + const block = { + type: "tool_result", + tool_use_id: "toolu_1", + content: GIT_STATUS, + cache_control: { type: "ephemeral" }, + }; + const body = anthropicBody(block); + const before = JSON.stringify(toolResultBlock(body)); + + const res = applyRtkCompression(body, { config: { enabled: true, applyToToolResults: true } }); + const after = JSON.stringify(toolResultBlock(res.body as Record)); + + // The marked breakpoint block must be untouched — content identical, marker intact. + assert.equal(after, before, "cache_control-marked tool_result must not be rewritten"); + assert.deepEqual(toolResultBlock(res.body as Record).cache_control, { type: "ephemeral" }); +}); + +test("RTK preserves an inner text sub-block carrying cache_control (array content)", () => { + const block = { + type: "tool_result", + tool_use_id: "toolu_1", + content: [{ type: "text", text: GIT_STATUS, cache_control: { type: "ephemeral" } }], + }; + const body = anthropicBody(block); + const before = JSON.stringify(toolResultBlock(body)); + + const res = applyRtkCompression(body, { config: { enabled: true, applyToToolResults: true } }); + const after = JSON.stringify(toolResultBlock(res.body as Record)); + + assert.equal(after, before, "cache_control-marked text sub-block must not be rewritten"); +}); + +test("RTK still compresses tool_result blocks WITHOUT cache_control (no over-protection)", () => { + const block = { + type: "tool_result", + tool_use_id: "toolu_1", + content: GIT_STATUS, + }; + const body = anthropicBody(block); + + const res = applyRtkCompression(body, { config: { enabled: true, applyToToolResults: true } }); + const after = toolResultBlock(res.body as Record).content as string; + + assert.equal(res.compressed, true, "unmarked tool_result should still compress"); + assert.ok(!after.includes('(use "git add'), "hint lines should be dropped when uncached"); +});