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
1 change: 1 addition & 0 deletions src/tui-opentui/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ function attachOnNextPrompt(shell: AppShell, id: string): Promise<void> {
name: `${id}.png`,
contentType: "image/png",
data: new Uint8Array([137, 80, 78, 71]),
contentHash: `hash-${id}`,
},
}
})
Expand Down
1 change: 1 addition & 0 deletions src/tui-opentui/live-session-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions src/tui-opentui/prompt-attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function attachment(name: string): PendingImageAttachment {
name,
contentType: "image/png",
data: new Uint8Array([1, 2, 3]),
contentHash: `hash-${name}`,
}
}

Expand Down
62 changes: 62 additions & 0 deletions src/tui-opentui/prompt-features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { withTestRenderer, type Harness } from "./harness"
import {
acceptOverlaySelection,
attachClipboardImage,
clearPendingAttachments,
createAppShell,
moveOverlaySelection,
noticeText,
Expand All @@ -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(
Expand Down Expand Up @@ -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<readonly PendingImageAttachment[] | undefined> = []
Expand Down
7 changes: 7 additions & 0 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,13 @@ export async function attachClipboardImage(shell: AppShell): Promise<boolean> {
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
Expand Down
30 changes: 30 additions & 0 deletions src/tui/image-attachments.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
}
});
});
16 changes: 16 additions & 0 deletions src/tui/image-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const IMAGE_MIME_BY_EXT: Readonly<Record<string, string>> = {
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 =
Expand Down Expand Up @@ -90,6 +92,10 @@ export async function imageAttachmentFromPath(path: string): Promise<AttachImage
return { ok: false, reason: `image is too large; max ${formatBytes(MAX_IMAGE_ATTACHMENT_BYTES)}` };
}
const raw = await readFile(path);
// Hash the source bytes, not the (lossy, non-deterministic) capped output --
// two ingests of the same clipboard content must hash identically even if
// downscaling recompresses them differently.
const contentHash = await hashImageBytes(raw);
const capped = await capImageForIngestion(raw, mimeType);
return {
ok: true,
Expand All @@ -99,10 +105,20 @@ export async function imageAttachmentFromPath(path: string): Promise<AttachImage
contentType: capped.contentType,
data: capped.data,
path,
contentHash,
},
};
}

/** SHA-256 of the source image file's bytes, used to identify identical pastes regardless of filename or timing. */
async function hashImageBytes(bytes: Buffer): Promise<string> {
// 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` --
Expand Down
1 change: 1 addition & 0 deletions src/tui/submit-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading