From ea9a662eb743fdca15cc4408ae7168e04777ed93 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 25 Sep 2026 18:58:36 +0100 Subject: [PATCH] fix(anthropic): keep cache_control when an image block is rewritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic image pipeline rebuilds a block as a fresh object literal, so every sibling property of `type` is silently discarded — including the caller's `cache_control` breakpoint: - anthropic-image-normalize.ts `textify()` (undecodable/bomb/overflow notes) - anthropic-image-normalize.ts `replaceImage()` (tier resize/re-encode) - anthropic-image-guard.ts `textify()` (Rules 1, 1b, 2, 3, 4) Both run over the caller's raw body on the native Anthropic lane (messages-native.ts `prepareNativeBody`, claude-messages.ts), so when Claude Code's rolling cache breakpoint sits on an image block — a pasted screenshot, a `Read` of a PNG, a screenshot `tool_result` — and that image is textified or re-encoded, the breakpoint is destroyed. The prefix is then re-written instead of read; in a screenshot-driven loop the whole conversation tail gets re-written every turn. That lane already promises passthrough in its own test: tests/claude-integration/claude-native-passthrough.test.ts asserts `expect(hit.body).toEqual(claudeBody())` under "Body untouched: thinking signature, cache_control, max_tokens all intact." The image pipeline quietly broke that promise; the test passes today only because its fixture has no image that gets normalized. Carry `cache_control` across the rebuild at all three sites via one shared `cacheControlOf()` helper. Only `cache_control` is carried: blanket-spreading the old block would leave an image block's `source` on a text block. Co-Authored-By: Claude Opus 5 --- src/adapters/anthropic-image-guard.ts | 14 +++++++++++++- src/adapters/anthropic-image-normalize.ts | 5 +++-- .../anthropic/anthropic-image-guard.test.ts | 11 +++++++++++ .../anthropic/anthropic-image-normalize.test.ts | 17 +++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/adapters/anthropic-image-guard.ts b/src/adapters/anthropic-image-guard.ts index 1def336b491..30179a01f3a 100644 --- a/src/adapters/anthropic-image-guard.ts +++ b/src/adapters/anthropic-image-guard.ts @@ -170,8 +170,20 @@ export function collectImageRefs(messages: unknown[]): ImageBlockRef[] { return refs; } +/** + * A rebuilt block must carry the caller's prompt-cache breakpoint. `cache_control` is a + * SIBLING of `type` on the original block, so replacing that block with a fresh object + * literal silently deletes it — the prefix is then re-written instead of read, which in a + * screenshot-driven session re-writes the whole conversation tail every turn. Only + * `cache_control` is carried over: an image block's `source` must never land on a text block. + */ +export function cacheControlOf(ref: ImageBlockRef): { cache_control?: unknown } { + const cacheControl = (ref.container[ref.index] as { cache_control?: unknown })?.cache_control; + return cacheControl === undefined ? {} : { cache_control: cacheControl }; +} + function textify(ref: ImageBlockRef, text: string): void { - ref.container[ref.index] = { type: "text", text }; + ref.container[ref.index] = { type: "text", text, ...cacheControlOf(ref) }; } /** diff --git a/src/adapters/anthropic-image-normalize.ts b/src/adapters/anthropic-image-normalize.ts index 747994718aa..d9506f6e90c 100644 --- a/src/adapters/anthropic-image-normalize.ts +++ b/src/adapters/anthropic-image-normalize.ts @@ -15,6 +15,7 @@ */ import { + cacheControlOf, collectImageRefs, sniffImageDimensions, TOTAL_IMAGE_BASE64_BUDGET, @@ -75,11 +76,11 @@ function mediaTypeOf(ref: ImageBlockRef): string { } function textify(ref: ImageBlockRef, text: string): void { - ref.container[ref.index] = { type: "text", text }; + ref.container[ref.index] = { type: "text", text, ...cacheControlOf(ref) }; } function replaceImage(ref: ImageBlockRef, data: string, mediaType: string): void { - ref.container[ref.index] = { type: "image", source: { type: "base64", media_type: mediaType, data } }; + ref.container[ref.index] = { type: "image", source: { type: "base64", media_type: mediaType, data }, ...cacheControlOf(ref) }; } function initialPosition(newestFirstIndex: number, bias: number): number { diff --git a/tests/adapters/anthropic/anthropic-image-guard.test.ts b/tests/adapters/anthropic/anthropic-image-guard.test.ts index 8858d5521ec..07f6e4d6422 100644 --- a/tests/adapters/anthropic/anthropic-image-guard.test.ts +++ b/tests/adapters/anthropic/anthropic-image-guard.test.ts @@ -149,6 +149,17 @@ describe("enforceAnthropicImageLimits", () => { expect(content[1].type).toBe("image"); }); + test("a cache_control breakpoint survives textification", () => { + const huge = { ...imageBlock(HUGE), cache_control: { type: "ephemeral", ttl: "1h" } }; + const messages = [userMsg([huge, imageBlock(SMALL)])]; + enforceAnthropicImageLimits(messages); + const content = (messages[0] as { content: Array> }).content; + expect(content[0].type).toBe("text"); + expect(content[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" }); + // The image block's own properties must NOT ride along onto a text block. + expect(content[0].source).toBeUndefined(); + }); + test("C4: >100 small images trimmed to 100", () => { const messages = [userMsg(Array.from({ length: 110 }, () => imageBlock(SMALL)))]; enforceAnthropicImageLimits(messages); diff --git a/tests/adapters/anthropic/anthropic-image-normalize.test.ts b/tests/adapters/anthropic/anthropic-image-normalize.test.ts index a57123f8d0c..7b22fb26276 100644 --- a/tests/adapters/anthropic/anthropic-image-normalize.test.ts +++ b/tests/adapters/anthropic/anthropic-image-normalize.test.ts @@ -280,6 +280,23 @@ describe("normalizeAnthropicImages — real Bun.Image path", () => { expect(block.text).toContain("undecodable"); }); + test("a cache_control breakpoint survives both re-encoding and textification", async () => { + // Claude Code's rolling cache breakpoint can sit on any block, including an image. + // Losing it re-writes the prefix instead of reading it. + const resized = { ...imageBlock(await realPngBase64(4000, 3000)), cache_control: { type: "ephemeral" } }; + const garbage = { ...imageBlock(Buffer.from("this is not an image at all").toString("base64")), cache_control: { type: "ephemeral", ttl: "1h" } }; + const messages = [userMsg([resized, garbage])]; + await normalizeAnthropicImages(messages); + const content = contentOf(messages) as unknown as Array>; + expect(content[0].type).toBe("image"); + expect((content[0].source as { data: string }).data).not.toBe((resized.source as { data: string }).data); + expect(content[0].cache_control).toEqual({ type: "ephemeral" }); + expect(content[1].type).toBe("text"); + expect(content[1].cache_control).toEqual({ type: "ephemeral", ttl: "1h" }); + // The image block's own properties must NOT ride along onto a text block. + expect(content[1].source).toBeUndefined(); + }); + test("N6b: sniffable-but-truncated PNG is caught by pass-through validation and textified", async () => { // Real PNG cut short: header (dimensions) survives sniffing, pixel data is gone. const whole = Buffer.from(await realPngBase64(400, 300), "base64");