Skip to content

Commit 3241cb0

Browse files
lcompleteCopilot
andcommitted
fix(extension): use deterministic fallback attachment ID to keep refs stable across saves
Replace crypto.randomUUID() fallback with a deterministic ID derived from session ID, message ID, and part index. This ensures that file attachment references remain stable when re-saving a session where only a later message changed, preventing duplicate attachment writes. Add test 'keeps earlier attachment refs stable when only the latest message rewrites' to cover this behaviour. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7c91630 commit 3241cb0

2 files changed

Lines changed: 109 additions & 3 deletions

File tree

app/extension/src/__tests__/sessionStoragePersistence.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,47 @@ function createTwoTurnSession(secondQuestion = "Follow-up") {
281281
};
282282
}
283283

284+
function createTwoTurnSessionWithAttachment(secondAnswer = "Second answer") {
285+
return {
286+
...createSession("Question", "First answer"),
287+
messages: [
288+
{
289+
id: "user-1",
290+
role: "user" as const,
291+
parts: [
292+
{ type: "text" as const, text: "Question" },
293+
{
294+
type: "file" as const,
295+
filename: "note.txt",
296+
mediaType: "text/plain",
297+
dataUrl: "data:text/plain;base64,SGVsbG8=",
298+
size: 5,
299+
},
300+
],
301+
status: "complete" as const,
302+
},
303+
{
304+
id: "assistant-1",
305+
role: "assistant" as const,
306+
parts: [{ type: "text" as const, text: "First answer" }],
307+
status: "complete" as const,
308+
},
309+
{
310+
id: "user-2",
311+
role: "user" as const,
312+
parts: [{ type: "text" as const, text: "Follow-up" }],
313+
status: "complete" as const,
314+
},
315+
{
316+
id: "assistant-2",
317+
role: "assistant" as const,
318+
parts: [{ type: "text" as const, text: secondAnswer }],
319+
status: "complete" as const,
320+
},
321+
],
322+
};
323+
}
324+
284325
describe("sessionStorage persistence layout", () => {
285326
let fakeIndexedDB: FakeIndexedDB;
286327

@@ -378,6 +419,57 @@ describe("sessionStorage persistence layout", () => {
378419
]);
379420
});
380421

422+
it("keeps earlier attachment refs stable when only the latest message rewrites", async () => {
423+
const { saveSession } = await import("../sidepanel/sessionStorage");
424+
const originalFetch = globalThis.fetch;
425+
426+
globalThis.fetch = jest.fn(async () => ({
427+
ok: true,
428+
blob: async () =>
429+
({ size: 5, type: "text/plain" }) as unknown as Blob,
430+
})) as unknown as typeof fetch;
431+
432+
try {
433+
await saveSession(createTwoTurnSessionWithAttachment());
434+
435+
const db = fakeIndexedDB.databases.get("huntly-agent")!;
436+
const messageStore = db.stores.get("session-messages")!;
437+
const attachmentStore = db.stores.get("session-attachments")!;
438+
const storedUserMessage = messageStore.records.get(
439+
"session-1\u001fuser-1"
440+
) as {
441+
message: { parts: Array<{ type: string; attachmentId?: string }> };
442+
};
443+
const firstAttachmentId = storedUserMessage.message.parts[1].attachmentId;
444+
445+
expect(firstAttachmentId).toBeDefined();
446+
expect(attachmentStore.records.has(firstAttachmentId!)).toBe(true);
447+
448+
messageStore.putCount = 0;
449+
450+
await saveSession(
451+
createTwoTurnSessionWithAttachment("Updated second answer")
452+
);
453+
454+
expect(messageStore.putCount).toBe(1);
455+
456+
const updatedUserMessage = messageStore.records.get(
457+
"session-1\u001fuser-1"
458+
) as {
459+
message: { parts: Array<{ type: string; attachmentId?: string }> };
460+
};
461+
const updatedAttachmentId =
462+
updatedUserMessage.message.parts[1].attachmentId;
463+
464+
expect(updatedAttachmentId).toBe(firstAttachmentId);
465+
expect(Array.from(attachmentStore.records.keys())).toEqual([
466+
firstAttachmentId,
467+
]);
468+
} finally {
469+
globalThis.fetch = originalFetch;
470+
}
471+
});
472+
381473
it("keeps history metadata and stored messages after a run completes", async () => {
382474
const { getSession, listSessionMetadata, saveSession } = await import(
383475
"../sidepanel/sessionStorage"

app/extension/src/sidepanel/sessionStorage.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@ function getMessageStorageKey(sessionId: string, messageId: string): string {
195195
return `${sessionId}\u001f${messageId}`;
196196
}
197197

198+
function getFallbackAttachmentId(
199+
sessionId: string,
200+
messageId: string,
201+
partIndex: number
202+
): string {
203+
return `${sessionId}\u001f${messageId}\u001fattachment-${partIndex}`;
204+
}
205+
198206
function createMessageRef(
199207
sessionId: string,
200208
message: ChatMessage,
@@ -437,13 +445,19 @@ async function serializeSessionAttachments(
437445
const referencedAttachmentIds = new Set<string>();
438446

439447
const messages = await Promise.all(
440-
session.messages.map(async (message) => ({
448+
session.messages.map(async (message, messageOrder) => ({
441449
...message,
442450
parts: await Promise.all(
443-
message.parts.map(async (part) => {
451+
message.parts.map(async (part, partIndex) => {
444452
if (part.type !== "file") return part;
445453

446-
const attachmentId = part.attachmentId || crypto.randomUUID();
454+
const attachmentId =
455+
part.attachmentId ||
456+
getFallbackAttachmentId(
457+
session.id,
458+
getMessageId(message, messageOrder),
459+
partIndex
460+
);
447461
referencedAttachmentIds.add(attachmentId);
448462

449463
if (part.dataUrl && !attachmentRecords.has(attachmentId)) {

0 commit comments

Comments
 (0)