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");