From 3cfb26e58157b632ea11f1150e93c509874e8170 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:56:29 -0700 Subject: [PATCH 1/2] fix: bound chat images for long conversations --- .../chat/lib/__tests__/resizeImage.test.ts | 134 +++++++++++++++++- .../chat/lib/attachmentPayloadBudget.ts | 2 +- src/features/chat/lib/resizeImage.ts | 22 ++- 3 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/features/chat/lib/__tests__/resizeImage.test.ts b/src/features/chat/lib/__tests__/resizeImage.test.ts index 29836980c..19859595f 100644 --- a/src/features/chat/lib/__tests__/resizeImage.test.ts +++ b/src/features/chat/lib/__tests__/resizeImage.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { sniffAcceptedImageMimeType } from "../resizeImage"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + dimensionsForProviderHistory, + resizeImage, + sniffAcceptedImageMimeType, +} from "../resizeImage"; const JPEG_HEADER = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]; const PNG_HEADER = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; @@ -19,6 +23,132 @@ function bytes(values: number[]): Uint8Array { return new Uint8Array(values); } +describe("dimensionsForProviderHistory", () => { + it("keeps images at the many-image request limit unchanged", () => { + expect(dimensionsForProviderHistory(2_000, 1_500)).toEqual({ + width: 2_000, + height: 1_500, + }); + }); + + it("scales a 2048px screenshot below the many-image request limit", () => { + expect(dimensionsForProviderHistory(2_048, 1_338)).toEqual({ + width: 2_000, + height: 1_307, + }); + expect(dimensionsForProviderHistory(1_731, 2_048)).toEqual({ + width: 1_690, + height: 2_000, + }); + }); +}); + +describe("resizeImage", () => { + const originalImage = globalThis.Image; + const originalCreateElement = document.createElement.bind(document); + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + + let sourceWidth = 0; + let sourceHeight = 0; + let canvas: HTMLCanvasElement; + let drawImage: ReturnType; + let toDataURL: ReturnType; + + function imageBlob(): Blob { + const source = bytes(PNG_HEADER); + const blob = new Blob([source], { type: "image/png" }); + Object.defineProperty(blob, "arrayBuffer", { + value: async () => source.buffer, + }); + const originalSlice = blob.slice.bind(blob); + Object.defineProperty(blob, "slice", { + value: (...args: Parameters) => { + const slice = originalSlice(...args); + Object.defineProperty(slice, "arrayBuffer", { + value: async () => source.slice(args[0] ?? 0, args[1]).buffer, + }); + return slice; + }, + }); + return blob; + } + + beforeEach(() => { + class MockImage { + width = sourceWidth; + height = sourceHeight; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + set src(_value: string) { + this.width = sourceWidth; + this.height = sourceHeight; + queueMicrotask(() => this.onload?.()); + } + } + + drawImage = vi.fn(); + toDataURL = vi.fn(() => "data:image/png;base64,cmVzaXplZA=="); + canvas = { + width: 0, + height: 0, + getContext: vi.fn(() => ({ drawImage })), + toDataURL, + } as unknown as HTMLCanvasElement; + + globalThis.Image = MockImage as unknown as typeof Image; + URL.createObjectURL = vi.fn(() => "blob:test-image"); + URL.revokeObjectURL = vi.fn(); + document.createElement = vi.fn(((tagName: string) => + tagName === "canvas" + ? canvas + : originalCreateElement(tagName)) as typeof document.createElement); + }); + + afterEach(() => { + globalThis.Image = originalImage; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + document.createElement = originalCreateElement; + }); + + it("re-encodes a 2048px screenshot at the many-image request limit", async () => { + sourceWidth = 2_048; + sourceHeight = 1_338; + + const normalized = await resizeImage(imageBlob()); + + expect(canvas.width).toBe(2_000); + expect(canvas.height).toBe(1_307); + expect(drawImage).toHaveBeenCalledWith( + expect.objectContaining({ width: 2_048, height: 1_338 }), + 0, + 0, + 2_000, + 1_307, + ); + expect(toDataURL).toHaveBeenCalledWith("image/png", undefined); + expect(normalized).toEqual({ + base64: "cmVzaXplZA==", + mimeType: "image/png", + }); + }); + + it("passes through a 2000px image without canvas encoding", async () => { + sourceWidth = 2_000; + sourceHeight = 1_500; + const source = bytes(PNG_HEADER); + + const normalized = await resizeImage(imageBlob()); + + expect(document.createElement).not.toHaveBeenCalledWith("canvas"); + expect(toDataURL).not.toHaveBeenCalled(); + expect(normalized.mimeType).toBe("image/png"); + expect(atob(normalized.base64)).toBe(String.fromCharCode(...source)); + }); +}); + describe("sniffAcceptedImageMimeType", () => { it("identifies the four accepted formats from magic bytes", () => { expect(sniffAcceptedImageMimeType(bytes(JPEG_HEADER))).toBe("image/jpeg"); diff --git a/src/features/chat/lib/attachmentPayloadBudget.ts b/src/features/chat/lib/attachmentPayloadBudget.ts index 71ff521c5..312298221 100644 --- a/src/features/chat/lib/attachmentPayloadBudget.ts +++ b/src/features/chat/lib/attachmentPayloadBudget.ts @@ -7,7 +7,7 @@ import type { ChatAttachmentDraft } from "@/shared/types/messages"; * every open chat — when a message overflows its frame limit (16MiB * tungstenite default, BOT-1463). The budget stays comfortably under that * so prompt text and JSON envelope overhead can never push a send over the - * edge. Normalized images (2048px cap) are a few hundred KB each, so normal + * edge. Normalized images (2000px cap) are a few hundred KB each, so normal * use never approaches this. */ export const MAX_PROMPT_ATTACHMENT_BYTES = 12 * 1024 * 1024; diff --git a/src/features/chat/lib/resizeImage.ts b/src/features/chat/lib/resizeImage.ts index a1667dbd4..bc7e5d0af 100644 --- a/src/features/chat/lib/resizeImage.ts +++ b/src/features/chat/lib/resizeImage.ts @@ -1,4 +1,7 @@ -const MAX_IMAGE_DIMENSION = 2048; +// Anthropic applies this stricter per-image limit to every historical image +// once a request contains more than 20 image or document blocks. Normalizing +// at ingress keeps an image accepted early in a chat valid as history grows. +const MAX_IMAGE_DIMENSION = 2000; const JPEG_QUALITY = 0.85; export interface NormalizedImage { @@ -96,14 +99,23 @@ function loadImageElement(blob: Blob): Promise { }); } +export function dimensionsForProviderHistory( + sourceWidth: number, + sourceHeight: number, +): { width: number; height: number } { + const maxDimension = Math.max(sourceWidth, sourceHeight); + const scale = Math.min(1, MAX_IMAGE_DIMENSION / maxDimension); + return { + width: Math.max(1, Math.round(sourceWidth * scale)), + height: Math.max(1, Math.round(sourceHeight * scale)), + }; +} + function encodeWithCanvas( img: HTMLImageElement, sourceMimeType: string, ): NormalizedImage { - const maxDim = Math.max(img.width, img.height); - const scale = Math.min(1, MAX_IMAGE_DIMENSION / maxDim); - const width = Math.max(1, Math.round(img.width * scale)); - const height = Math.max(1, Math.round(img.height * scale)); + const { width, height } = dimensionsForProviderHistory(img.width, img.height); const canvas = document.createElement("canvas"); canvas.width = width; From 3a13faf1062a377025ec4c143e711a4ab6d89e98 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:01:10 -0700 Subject: [PATCH 2/2] test: use a type-safe image blob fixture --- src/features/chat/lib/__tests__/resizeImage.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/features/chat/lib/__tests__/resizeImage.test.ts b/src/features/chat/lib/__tests__/resizeImage.test.ts index 19859595f..1e682343b 100644 --- a/src/features/chat/lib/__tests__/resizeImage.test.ts +++ b/src/features/chat/lib/__tests__/resizeImage.test.ts @@ -56,8 +56,8 @@ describe("resizeImage", () => { let toDataURL: ReturnType; function imageBlob(): Blob { - const source = bytes(PNG_HEADER); - const blob = new Blob([source], { type: "image/png" }); + const source = Uint8Array.from(PNG_HEADER); + const blob = new Blob([source.buffer], { type: "image/png" }); Object.defineProperty(blob, "arrayBuffer", { value: async () => source.buffer, }); @@ -65,8 +65,9 @@ describe("resizeImage", () => { Object.defineProperty(blob, "slice", { value: (...args: Parameters) => { const slice = originalSlice(...args); + const slicedSource = source.slice(args[0] ?? 0, args[1]); Object.defineProperty(slice, "arrayBuffer", { - value: async () => source.slice(args[0] ?? 0, args[1]).buffer, + value: async () => slicedSource.buffer, }); return slice; },