Skip to content

Commit 5a72273

Browse files
committed
Dedupe path-mention images by content hash
1 parent df0dbe4 commit 5a72273

5 files changed

Lines changed: 86 additions & 14 deletions

File tree

src/tui/image-attachments.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import {
77
extractPastedImagePaths,
8+
findDuplicateAttachment,
89
findImagePathMentions,
910
imageMimeTypeForPath,
1011
capImageForIngestion,
@@ -160,10 +161,10 @@ describe("image attachment helpers", () => {
160161
expect(result.contentType).toBe("image/png");
161162
});
162163

163-
// The dedupe fix (see shell.ts attachClipboardImage) rests entirely on this:
164-
// two ingests of identical source bytes must hash identically even though
165-
// capImageForIngestion re-encodes oversized images through `sips`, whose
166-
// JPEG output is not byte-stable across runs. Sizing the fixture above
164+
// Identity is SHA-256 of the source bytes, not the (lossy, non-deterministic)
165+
// capped output -- two ingests of identical source bytes must hash identically
166+
// even though capImageForIngestion re-encodes oversized images through `sips`,
167+
// whose JPEG output is not byte-stable across runs. Sizing the fixture above
167168
// DOWNSCALE_THRESHOLD_BYTES (300 KB) exercises that re-encode path -- a
168169
// small fixture would pass even if the hash were taken after capping.
169170
test("hashes identical source bytes the same regardless of filename, even through the sips recompression path", async () => {
@@ -187,3 +188,29 @@ describe("image attachment helpers", () => {
187188
}
188189
});
189190
});
191+
192+
describe("findDuplicateAttachment", () => {
193+
const first = {
194+
id: "a",
195+
name: "kept.png",
196+
contentType: "image/png",
197+
data: new Uint8Array([1]),
198+
contentHash: "hash-a",
199+
};
200+
const other = {
201+
id: "b",
202+
name: "other.png",
203+
contentType: "image/png",
204+
data: new Uint8Array([2]),
205+
contentHash: "hash-b",
206+
};
207+
208+
test("returns the first existing attachment with the same content hash", () => {
209+
const candidate = { ...first, id: "later", name: "copy.png", path: "/tmp/copy.png" };
210+
expect(findDuplicateAttachment([first, other], candidate)).toBe(first);
211+
});
212+
213+
test("returns undefined when no existing attachment shares the hash", () => {
214+
expect(findDuplicateAttachment([first], other)).toBeUndefined();
215+
});
216+
});

src/tui/image-attachments.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,18 @@ const IMAGE_MIME_BY_EXT: Readonly<Record<string, string>> = {
2727
export type PendingImageAttachment = MessageAttachment & {
2828
id: string;
2929
path?: string;
30-
/** SHA-256 of the source image file's bytes, used to dedupe repeat pastes. */
30+
/** SHA-256 of the source image file's bytes, used to identify identical images. */
3131
contentHash: string;
3232
};
3333

34+
/** First existing attachment whose `contentHash` matches the candidate, if any. */
35+
export function findDuplicateAttachment(
36+
existing: readonly PendingImageAttachment[],
37+
candidate: PendingImageAttachment,
38+
): PendingImageAttachment | undefined {
39+
return existing.find((attachment) => attachment.contentHash === candidate.contentHash);
40+
}
41+
3442
export type AttachImageResult =
3543
{ ok: true; attachment: PendingImageAttachment } | { ok: false; reason: string };
3644

@@ -118,7 +126,7 @@ export async function imageAttachmentFromPath(path: string): Promise<AttachImage
118126
};
119127
}
120128

121-
/** SHA-256 of the source image file's bytes, used to identify identical pastes regardless of filename or timing. */
129+
/** SHA-256 of the source image file's bytes, used to identify identical images regardless of filename or timing. */
122130
async function hashImageBytes(bytes: Buffer): Promise<string> {
123131
// Buffer's type parameter is the looser ArrayBufferLike (it may back onto a
124132
// pooled allocation), but readFile never actually hands back a

src/tui/prompt-attachments.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ describe("ingestPathMentions", () => {
4242
expect(result.text).toBe("./shot.png");
4343
expect(result.attachments).toEqual([]);
4444
});
45+
46+
test("two mentions of the same bytes keep one attachment and rewrite both tokens to its name", async () => {
47+
const load = async (path: string): Promise<AttachImageResult> => ({
48+
ok: true,
49+
attachment: {
50+
id: path,
51+
name: path.endsWith("alias.png") ? "alias.png" : "shot.png",
52+
contentType: "image/png",
53+
data: new Uint8Array([1, 2, 3]),
54+
path,
55+
contentHash: "same-bytes",
56+
},
57+
});
58+
const result = await ingestPathMentions("see ./shot.png and ./alias.png", "/repo", load);
59+
expect(result.attachments).toHaveLength(1);
60+
expect(result.attachments[0]?.name).toBe("shot.png");
61+
expect(result.text).toBe("see [Attached image: shot.png] and [Attached image: shot.png]");
62+
});
4563
});
4664

4765
describe("ingestOperatorPrompt", () => {
@@ -66,6 +84,19 @@ describe("ingestOperatorPrompt", () => {
6684
expect(result.text).toBe("just words");
6785
expect(result.attachments).toEqual([]);
6886
});
87+
88+
test("a path mention matching a pending clipboard hash keeps the pending attachment", async () => {
89+
const pending = attachment("clipboard.png");
90+
const pendingHash = pending.contentHash;
91+
const load = async (path: string): Promise<AttachImageResult> => ({
92+
ok: true,
93+
attachment: { ...attachment("shot.png"), path, contentHash: pendingHash },
94+
});
95+
const result = await ingestOperatorPrompt("see ./shot.png", "/repo", load, [pending]);
96+
expect(result.attachments).toHaveLength(1);
97+
expect(result.attachments[0]).toEqual(pending);
98+
expect(result.text).toBe("see [Attached image: clipboard.png]");
99+
});
69100
});
70101

71102
describe("spliceMentionCompletion", () => {

src/tui/prompt-attachments.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import {
8+
findDuplicateAttachment,
89
findImagePathMentions,
910
type AttachImageResult,
1011
type PendingImageAttachment,
@@ -20,13 +21,15 @@ export interface PathMentionIngestion {
2021

2122
/**
2223
* Replace image paths written inline in the prompt with attachment markers and
23-
* return the loaded attachments. `load` is injected so this stays testable
24-
* without touching the filesystem.
24+
* return only attachments whose content hash is not already in `pending` or
25+
* earlier in this batch. Duplicate tokens still rewrite to the kept name.
26+
* `load` is injected so this stays testable without touching the filesystem.
2527
*/
2628
export async function ingestPathMentions(
2729
text: string,
2830
cwd: string,
2931
load: (path: string) => Promise<AttachImageResult>,
32+
pending: readonly PendingImageAttachment[] = [],
3033
): Promise<PathMentionIngestion> {
3134
const mentions = findImagePathMentions(text, cwd);
3235
if (mentions.length === 0) return { text, attachments: [] };
@@ -37,8 +40,12 @@ export async function ingestPathMentions(
3740
for (const [index, mention] of mentions.entries()) {
3841
const result = loaded[index];
3942
if (result === undefined || !result.ok) continue;
40-
attachments.push(result.attachment);
41-
out = out.replace(mention.raw, `[Attached image: ${result.attachment.name}]`);
43+
const kept =
44+
findDuplicateAttachment(pending, result.attachment) ??
45+
findDuplicateAttachment(attachments, result.attachment) ??
46+
result.attachment;
47+
if (kept === result.attachment) attachments.push(result.attachment);
48+
out = out.replace(mention.raw, `[Attached image: ${kept.name}]`);
4249
}
4350
return { text: out, attachments };
4451
}
@@ -53,7 +60,7 @@ export async function ingestOperatorPrompt(
5360
load: (path: string) => Promise<AttachImageResult>,
5461
pending: readonly PendingImageAttachment[] = [],
5562
): Promise<PathMentionIngestion> {
56-
const ingested = await ingestPathMentions(text, cwd, load);
63+
const ingested = await ingestPathMentions(text, cwd, load, pending);
5764
const resolved = await resolveAtMentions(ingested.text, cwd);
5865
return { text: resolved, attachments: [...pending, ...ingested.attachments] };
5966
}

src/tui/shell.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { sliceTailToWidth, sliceToWidth, stringWidth } from "./view/height.js";
3636
import { listPathSuggestions } from "./components/at-mention/list.js";
3737
import { parseAtState, type AtState } from "./components/at-mention/parse.js";
3838
import {
39+
findDuplicateAttachment,
3940
formatAttachmentSummary,
4041
readClipboardImage,
4142
type ClipboardImageResult,
@@ -990,9 +991,7 @@ export async function attachClipboardImage(shell: AppShell): Promise<boolean> {
990991
});
991992
return false;
992993
}
993-
const duplicate = shell.pendingAttachments.find(
994-
(attachment) => attachment.contentHash === result.attachment.contentHash,
995-
);
994+
const duplicate = findDuplicateAttachment(shell.pendingAttachments, result.attachment);
996995
if (duplicate !== undefined) {
997996
setStatusFlash(shell, `${duplicate.name} is already attached`, {
998997
ttlMs: RUNTIME_FLASH_MS,

0 commit comments

Comments
 (0)