Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions open-sse/services/compression/engines/rtk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).cache_control !== undefined &&
(part as Record<string, unknown>).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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
96 changes: 96 additions & 0 deletions tests/unit/compression/rtk-cache-control-preserve.test.ts
Original file line number Diff line number Diff line change
@@ -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 <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/app.ts
modified: src/lib/util.ts

Untracked files:
(use "git add <file>..." 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<string, unknown>) {
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<string, unknown>;
}

// Pull the single tool_result block out of message[2].content[0] with a typed shape (no `any`).
function toolResultBlock(body: Record<string, unknown>): Record<string, unknown> {
const messages = body.messages as Array<{ content: Array<Record<string, unknown>> }>;
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<string, unknown>));

// 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<string, unknown>).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<string, unknown>));

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<string, unknown>).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");
});
Loading