Skip to content

Commit 7c91630

Browse files
committed
refactor(extension): replace signature-based saves with divergence index and non-destructive migration
- Remove signature field from StoredMessageRef/StoredMessageRecord; detect divergence by comparing storageKey/messageId/order instead - On save, only write message records from the first divergent index onward (or just the last record for streaming appends) rather than any record whose JSON signature changed - Replace the drop-all-stores upgrade path with migrateLegacySessionRecords, which reads existing session rows that still carry an inline messages array and rewrites them into the split sessions/session-messages/session-metadata layout without data loss - Update tests: add createTwoTurnSession helper, add divergence-suffix test, rename streaming-save test, rewrite migration test to assert data is preserved across the upgrade
1 parent 373a1a1 commit 7c91630

2 files changed

Lines changed: 238 additions & 76 deletions

File tree

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

Lines changed: 127 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,38 @@ function createRunningSession(text: string) {
249249
};
250250
}
251251

252+
function createTwoTurnSession(secondQuestion = "Follow-up") {
253+
return {
254+
...createSession("Question", "First answer"),
255+
messages: [
256+
{
257+
id: "user-1",
258+
role: "user" as const,
259+
parts: [{ type: "text" as const, text: "Question" }],
260+
status: "complete" as const,
261+
},
262+
{
263+
id: "assistant-1",
264+
role: "assistant" as const,
265+
parts: [{ type: "text" as const, text: "First answer" }],
266+
status: "complete" as const,
267+
},
268+
{
269+
id: "user-2",
270+
role: "user" as const,
271+
parts: [{ type: "text" as const, text: secondQuestion }],
272+
status: "complete" as const,
273+
},
274+
{
275+
id: "assistant-2",
276+
role: "assistant" as const,
277+
parts: [{ type: "text" as const, text: "Second answer" }],
278+
status: "complete" as const,
279+
},
280+
],
281+
};
282+
}
283+
252284
describe("sessionStorage persistence layout", () => {
253285
let fakeIndexedDB: FakeIndexedDB;
254286

@@ -275,7 +307,15 @@ describe("sessionStorage persistence layout", () => {
275307
expect(sessionRecord.schemaVersion).toBe(4);
276308
expect(sessionRecord.messages).toBeUndefined();
277309
expect(sessionRecord.messageRefs).toHaveLength(2);
310+
expect(sessionRecord.messageRefs).toEqual([
311+
expect.not.objectContaining({ signature: expect.anything() }),
312+
expect.not.objectContaining({ signature: expect.anything() }),
313+
]);
278314
expect(messageRecords.size).toBe(2);
315+
expect(Array.from(messageRecords.values())).toEqual([
316+
expect.not.objectContaining({ signature: expect.anything() }),
317+
expect.not.objectContaining({ signature: expect.anything() }),
318+
]);
279319

280320
const restored = await getSession("session-1");
281321
expect(restored?.messages.map((message) => message.id)).toEqual([
@@ -284,7 +324,7 @@ describe("sessionStorage persistence layout", () => {
284324
]);
285325
});
286326

287-
it("only rewrites changed message rows on later saves", async () => {
327+
it("only rewrites the latest message row on streaming saves", async () => {
288328
const { saveSession } = await import("../sidepanel/sessionStorage");
289329

290330
await saveSession(createSession("Question", "First chunk"));
@@ -305,6 +345,39 @@ describe("sessionStorage persistence layout", () => {
305345
).toBe("Second chunk");
306346
});
307347

348+
it("rewrites only the divergent suffix after a history edit", async () => {
349+
const { saveSession } = await import("../sidepanel/sessionStorage");
350+
351+
await saveSession(createTwoTurnSession());
352+
353+
const db = fakeIndexedDB.databases.get("huntly-agent")!;
354+
const messageStore = db.stores.get("session-messages")!;
355+
messageStore.putCount = 0;
356+
357+
const edited = createTwoTurnSession("Edited follow-up");
358+
edited.messages[2] = {
359+
...edited.messages[2],
360+
id: "user-2-edited",
361+
};
362+
edited.messages[3] = {
363+
...edited.messages[3],
364+
id: "assistant-2-edited",
365+
};
366+
367+
await saveSession(edited);
368+
369+
expect(messageStore.putCount).toBe(2);
370+
const storedIds = Array.from(messageStore.records.values()).map(
371+
(record) => (record as { message: { id: string } }).message.id
372+
);
373+
expect(storedIds).toEqual([
374+
"user-1",
375+
"assistant-1",
376+
"user-2-edited",
377+
"assistant-2-edited",
378+
]);
379+
});
380+
308381
it("keeps history metadata and stored messages after a run completes", async () => {
309382
const { getSession, listSessionMetadata, saveSession } = await import(
310383
"../sidepanel/sessionStorage"
@@ -328,43 +401,81 @@ describe("sessionStorage persistence layout", () => {
328401
expect(restored?.messages[1].parts[0].text).toBe("Final answer");
329402
});
330403

331-
it("resets older chat stores before writing the current schema", async () => {
404+
it("migrates older chat stores without dropping persisted data", async () => {
332405
const legacyDb = new FakeDatabase();
333-
legacyDb.version = 3;
406+
legacyDb.version = 2;
334407
legacyDb.createObjectStore("sessions", { keyPath: "id" });
335408
legacyDb.createObjectStore("session-metadata", { keyPath: "id" });
409+
legacyDb.createObjectStore("session-attachments", { keyPath: "id" });
336410
legacyDb.stores.get("sessions")!.records.set("legacy-session", {
411+
...createSession("Legacy question", "Legacy answer"),
337412
id: "legacy-session",
338-
messages: [],
413+
title: "Legacy chat",
414+
createdAt: "2026-04-24T08:00:00.000Z",
415+
updatedAt: "2026-04-24T08:00:01.000Z",
416+
lastMessageAt: "2026-04-24T08:00:01.000Z",
417+
lastOpenedAt: "2026-04-24T08:00:01.000Z",
339418
});
340419
legacyDb.stores.get("session-metadata")!.records.set("legacy-session", {
341420
id: "legacy-session",
342421
title: "Legacy chat",
343422
createdAt: "2026-04-24T08:00:00.000Z",
344-
updatedAt: "2026-04-24T08:00:00.000Z",
345-
messageCount: 0,
423+
updatedAt: "2026-04-24T08:00:01.000Z",
424+
messageCount: 2,
346425
preview: "",
347426
currentModelId: null,
348427
});
428+
legacyDb.stores
429+
.get("session-attachments")!
430+
.records.set("legacy-attachment", {
431+
id: "legacy-attachment",
432+
sessionId: "legacy-session",
433+
blob: { size: 17, type: "text/plain" } as unknown as Blob,
434+
createdAt: "2026-04-24T08:00:01.000Z",
435+
mediaType: "text/plain",
436+
size: 17,
437+
});
349438
fakeIndexedDB.databases.set("huntly-agent", legacyDb);
350439

351-
const { listSessionMetadata, saveSession } = await import(
440+
const { getSession, listSessionMetadata, saveSession } = await import(
352441
"../sidepanel/sessionStorage"
353442
);
354443

355-
await saveSession(createSession("Question", "Answer"));
356-
357444
const db = fakeIndexedDB.databases.get("huntly-agent")!;
445+
const restoredLegacy = await getSession("legacy-session");
446+
358447
expect(db.version).toBe(4);
359-
expect(db.stores.get("sessions")!.records.has("legacy-session")).toBe(
360-
false
361-
);
448+
const migratedSession = db.stores
449+
.get("sessions")!
450+
.records.get("legacy-session") as Record<string, unknown>;
451+
expect(migratedSession.messages).toBeUndefined();
452+
expect(migratedSession.messageRefs).toHaveLength(2);
453+
expect(db.stores.get("session-messages")!.records.size).toBe(2);
362454
expect(
363-
db.stores.get("session-metadata")!.records.has("legacy-session")
364-
).toBe(false);
455+
db.stores.get("session-attachments")!.records.has("legacy-attachment")
456+
).toBe(true);
457+
458+
expect(restoredLegacy?.title).toBe("Legacy chat");
459+
expect(restoredLegacy?.messages.map((message) => message.id)).toEqual([
460+
"user-1",
461+
"assistant-1",
462+
]);
463+
expect(restoredLegacy?.messages[1].parts[0].text).toBe("Legacy answer");
365464

366465
const metadata = await listSessionMetadata();
367-
expect(metadata.map((session) => session.id)).toEqual(["session-1"]);
368-
expect(metadata[0].preview).toBe("Question\nAnswer");
466+
expect(metadata).toHaveLength(1);
467+
expect(metadata[0].id).toBe("legacy-session");
468+
expect(metadata[0].preview).toBe("Legacy question\nLegacy answer");
469+
470+
await saveSession(createSession("Question", "Answer"));
471+
472+
const mergedMetadata = await listSessionMetadata();
473+
expect(mergedMetadata).toHaveLength(2);
474+
expect(mergedMetadata.map((session) => session.id)).toEqual(
475+
expect.arrayContaining(["legacy-session", "session-1"])
476+
);
477+
expect(
478+
(await getSession("legacy-session"))?.messages[1].parts[0].text
479+
).toBe("Legacy answer");
369480
});
370481
});

0 commit comments

Comments
 (0)