diff --git a/src/tui-opentui/keybindings.test.ts b/src/tui-opentui/keybindings.test.ts index c44967776..59ef251d0 100644 --- a/src/tui-opentui/keybindings.test.ts +++ b/src/tui-opentui/keybindings.test.ts @@ -557,6 +557,7 @@ function attachOnNextPrompt(shell: AppShell, id: string): Promise { name: `${id}.png`, contentType: "image/png", data: new Uint8Array([137, 80, 78, 71]), + contentHash: `hash-${id}`, }, } }) diff --git a/src/tui-opentui/live-session-port.test.ts b/src/tui-opentui/live-session-port.test.ts index a0aaf6074..1de856db5 100644 --- a/src/tui-opentui/live-session-port.test.ts +++ b/src/tui-opentui/live-session-port.test.ts @@ -104,6 +104,7 @@ describe("attachment passthrough", () => { name: "clipboard.png", contentType: "image/png", data: new Uint8Array([1]), + contentHash: "hash-1", } test("sendImmediate forwards attachments to the host send", () => { diff --git a/src/tui-opentui/prompt-attachments.test.ts b/src/tui-opentui/prompt-attachments.test.ts index 0c266d511..62f7360b5 100644 --- a/src/tui-opentui/prompt-attachments.test.ts +++ b/src/tui-opentui/prompt-attachments.test.ts @@ -12,6 +12,7 @@ function attachment(name: string): PendingImageAttachment { name, contentType: "image/png", data: new Uint8Array([1, 2, 3]), + contentHash: `hash-${name}`, } } diff --git a/src/tui-opentui/prompt-features.test.ts b/src/tui-opentui/prompt-features.test.ts index 350059262..c8dfe3e37 100644 --- a/src/tui-opentui/prompt-features.test.ts +++ b/src/tui-opentui/prompt-features.test.ts @@ -10,6 +10,7 @@ import { withTestRenderer, type Harness } from "./harness" import { acceptOverlaySelection, attachClipboardImage, + clearPendingAttachments, createAppShell, moveOverlaySelection, noticeText, @@ -27,6 +28,25 @@ const CLIP: PendingImageAttachment = { name: "clipboard.png", contentType: "image/png", data: new Uint8Array([137, 80, 78, 71]), + contentHash: "hash-a", +} + +// A second read of the same clipboard content: distinct id/name/timestamp +// (as a real re-paste would produce) but identical decoded bytes and hash. +const CLIP_SAME_CONTENT: PendingImageAttachment = { + id: "clip-2", + name: "clipboard-later.png", + contentType: "image/png", + data: new Uint8Array([137, 80, 78, 71]), + contentHash: "hash-a", +} + +const CLIP_OTHER: PendingImageAttachment = { + id: "clip-3", + name: "clipboard-other.png", + contentType: "image/png", + data: new Uint8Array([1, 2, 3, 4]), + contentHash: "hash-b", } function withShell( @@ -140,6 +160,48 @@ describe("image attachments", () => { }) } + test("re-pasting the same clipboard content does not attach a second copy", async () => { + await withShell(async (shell) => { + let calls = 0 + setPromptImageSource(shell, async () => { + calls += 1 + return { ok: true, attachment: calls === 1 ? CLIP : CLIP_SAME_CONTENT } + }) + expect(await attachClipboardImage(shell)).toBe(true) + expect(await attachClipboardImage(shell)).toBe(false) + expect(shell.pendingAttachments).toHaveLength(1) + expect(shell.pendingAttachments[0]?.id).toBe("clip-1") + // Names the attachment already sitting in the pending set (CLIP), not + // the rejected paste (CLIP_SAME_CONTENT) -- the operator never saw the + // rejected paste's filename, so naming it would read as a bug. + expect(shell.statusFlash).toContain(`${CLIP.name} is already attached`) + expect(shell.statusFlash).not.toContain(CLIP_SAME_CONTENT.name) + }) + }) + + test("a genuinely different image still attaches alongside the first", async () => { + await withShell(async (shell) => { + let calls = 0 + setPromptImageSource(shell, async () => { + calls += 1 + return { ok: true, attachment: calls === 1 ? CLIP : CLIP_OTHER } + }) + expect(await attachClipboardImage(shell)).toBe(true) + expect(await attachClipboardImage(shell)).toBe(true) + expect(shell.pendingAttachments).toHaveLength(2) + }) + }) + + test("removing all attachments and re-pasting the same content re-attaches it", async () => { + await withShell(async (shell) => { + setPromptImageSource(shell, async () => ({ ok: true, attachment: CLIP })) + expect(await attachClipboardImage(shell)).toBe(true) + clearPendingAttachments(shell) + expect(await attachClipboardImage(shell)).toBe(true) + expect(shell.pendingAttachments).toHaveLength(1) + }) + }) + test("submit hands pending attachments to the bridge and clears them", async () => { await withShell(async (shell) => { const seen: Array = [] diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 47efdee0d..30077c418 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -932,6 +932,13 @@ export async function attachClipboardImage(shell: AppShell): Promise { setStatusFlash(shell, `image attach failed: ${result.reason}`) return false } + const duplicate = shell.pendingAttachments.find( + (attachment) => attachment.contentHash === result.attachment.contentHash, + ) + if (duplicate !== undefined) { + setStatusFlash(shell, `${duplicate.name} is already attached`) + return false + } addPendingAttachment(shell, result.attachment) setStatusFlash(shell, `attached ${result.attachment.name}`) return true diff --git a/src/tui/image-attachments.test.ts b/src/tui/image-attachments.test.ts index e0c1500a8..2b7281b7f 100644 --- a/src/tui/image-attachments.test.ts +++ b/src/tui/image-attachments.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from "bun:test"; import { deflateSync } from "node:zlib"; import { unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { extractPastedImagePaths, findImagePathMentions, imageMimeTypeForPath, capImageForIngestion, + imageAttachmentFromPath, MAX_IMAGE_DIMENSION, } from "./image-attachments.js"; @@ -150,4 +153,31 @@ describe("image attachment helpers", () => { expect(result.data).toBe(large); expect(result.contentType).toBe("image/png"); }); + + // The dedupe fix (see shell.ts attachClipboardImage) rests entirely on this: + // two ingests of identical source bytes must hash identically even though + // capImageForIngestion re-encodes oversized images through `sips`, whose + // JPEG output is not byte-stable across runs. Sizing the fixture above + // DOWNSCALE_THRESHOLD_BYTES (300 KB) exercises that re-encode path -- a + // small fixture would pass even if the hash were taken after capping. + test("hashes identical source bytes the same regardless of filename, even through the sips recompression path", async () => { + const bytes = buildTestPng(1200, 1200); + expect(bytes.byteLength).toBeGreaterThan(300 * 1024); + + const pathA = join(tmpdir(), `corbits-hash-test-a-${process.pid}-${Date.now()}.png`); + const pathB = join(tmpdir(), `corbits-hash-test-b-${process.pid}-${Date.now()}.png`); + await Bun.write(pathA, bytes); + await Bun.write(pathB, bytes); + try { + const resultA = await imageAttachmentFromPath(pathA); + const resultB = await imageAttachmentFromPath(pathB); + if (!resultA.ok || !resultB.ok) throw new Error("expected both ingests to succeed"); + + expect(resultA.attachment.contentHash).toBe(resultB.attachment.contentHash); + expect(resultA.attachment.id).not.toBe(resultB.attachment.id); + } finally { + await unlink(pathA).catch(() => undefined); + await unlink(pathB).catch(() => undefined); + } + }); }); diff --git a/src/tui/image-attachments.ts b/src/tui/image-attachments.ts index ea3b56036..bec5f28f4 100644 --- a/src/tui/image-attachments.ts +++ b/src/tui/image-attachments.ts @@ -27,6 +27,8 @@ const IMAGE_MIME_BY_EXT: Readonly> = { export type PendingImageAttachment = MessageAttachment & { id: string; path?: string; + /** SHA-256 of the source image file's bytes, used to dedupe repeat pastes. */ + contentHash: string; }; export type AttachImageResult = @@ -90,6 +92,10 @@ export async function imageAttachmentFromPath(path: string): Promise { + // Buffer's type parameter is the looser ArrayBufferLike (it may back onto a + // pooled allocation), but readFile never actually hands back a + // SharedArrayBuffer-backed view, so this is a type-only cast, not a copy. + const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + /** * Downscale/recompress an image before it enters a turn. Only shells out to * `sips` (macOS) when the source exceeds `DOWNSCALE_THRESHOLD_BYTES` -- diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 38ed0951e..7ce181be9 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -101,6 +101,7 @@ describe("image attachment submits", () => { name: "clipboard.png", contentType: "image/png", data: new Uint8Array([1, 2, 3]), + contentHash: "hash-1", }; function attachmentHarness() {