From 8d2024542e360a7008e9d53be9a3415f3fa31ca0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 00:51:36 -0700 Subject: [PATCH] fix(ui): preserve other chat transcripts when deleting a session (#128109) * fix(ui): preserve pending transcripts across scoped session deletes * fix(ui): type IndexedDB snapshot regression fixture --- .../pages/chat/session-snapshot-store.test.ts | 80 ++++++++++++++++++- ui/src/pages/chat/session-snapshot-store.ts | 23 +++++- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/chat/session-snapshot-store.test.ts b/ui/src/pages/chat/session-snapshot-store.test.ts index abfabab87d1b..2cb925e17905 100644 --- a/ui/src/pages/chat/session-snapshot-store.test.ts +++ b/ui/src/pages/chat/session-snapshot-store.test.ts @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { IDBFactory } from "fake-indexeddb"; +import { IDBFactory, IDBObjectStore } from "fake-indexeddb"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStorageMock } from "../../test-helpers/storage.ts"; import { @@ -314,6 +314,84 @@ describe("persistent chat session snapshots", () => { expect(await reader.read("agent:main:retained")).not.toBeNull(); }); + it("preserves an unrelated in-flight transcript while deleting another session", async () => { + const writer = new SessionSnapshotStore(); + writer.connect(); + try { + const retainedSession = "agent:main:retained"; + writer.write(retainedSession, snapshot("important transcript")); + + await Promise.all([writer.flush(), writer.delete("agent:main:deleted")]); + + expect(await new SessionSnapshotStore().read(retainedSession)).toEqual( + snapshot("important transcript"), + ); + expect(writer.readSavedAt(retainedSession)).not.toBeNull(); + } finally { + writer.disconnect(); + await writer.whenIdle(); + } + }); + + it.each(["session", "all"])( + "does not restore an in-flight transcript after %s invalidation", + async (scope) => { + const sessionKey = "agent:main:deleted"; + const writer = new SessionSnapshotStore(); + writer.connect(); + try { + writer.write(sessionKey, snapshot("deleted transcript")); + + await Promise.all([ + writer.flush(), + scope === "session" ? writer.delete(sessionKey) : clearStoredChatSnapshots(), + ]); + + expect(await new SessionSnapshotStore().read(sessionKey)).toBeNull(); + expect(writer.readSavedAt(sessionKey)).toBeNull(); + } finally { + writer.disconnect(); + await writer.whenIdle(); + } + }, + ); + + it("does not restore deleted metadata while seeding the snapshot index", async () => { + const sessionKey = "agent:main:deleted-during-seed"; + const writer = new SessionSnapshotStore(); + writer.write(sessionKey, snapshot("deleted transcript")); + await writer.flush(); + + const reader = new SessionSnapshotStore(); + reader.connect(); + try { + let deletion: Promise | undefined; + const originalGetAll = Reflect.get( + IDBObjectStore.prototype, + "getAll", + ) as IDBObjectStore["getAll"]; + vi.spyOn(IDBObjectStore.prototype, "getAll").mockImplementationOnce(function ( + this: IDBObjectStore, + ...args + ) { + const request = originalGetAll.apply(this, args); + request.addEventListener("success", () => { + deletion = writer.delete(sessionKey); + }); + return request; + }); + + await reader.loadSavedAtIndex(); + expect(deletion).toBeDefined(); + await deletion; + + expect(reader.readSavedAt(sessionKey)).toBeNull(); + } finally { + reader.disconnect(); + await reader.whenIdle(); + } + }); + it("upgrades a version one database before deleting an invalidated snapshot", async () => { const sessionKey = "agent:main:legacy-delete"; await putVersionOneRecord(sessionKey); diff --git a/ui/src/pages/chat/session-snapshot-store.ts b/ui/src/pages/chat/session-snapshot-store.ts index 09da23666678..881b8cc9f736 100644 --- a/ui/src/pages/chat/session-snapshot-store.ts +++ b/ui/src/pages/chat/session-snapshot-store.ts @@ -338,8 +338,7 @@ export class SessionSnapshotStore implements ChatCacheObserver { } async delete(sessionKey: string): Promise { - this.pending.delete(sessionKey); - this.savedAtBySession.delete(sessionKey); + this.forget(sessionKey); await deleteStoredChatSnapshot(sessionKey); } @@ -356,6 +355,9 @@ export class SessionSnapshotStore implements ChatCacheObserver { this.writeTimer = null; } const pending = [...this.pending.entries()]; + const pendingRevisions = new Map( + pending.map(([sessionKey]) => [sessionKey, this.revisions.get(sessionKey) ?? 0]), + ); this.pending.clear(); const records: SessionSnapshotRecord[] = []; for (const [sessionKey, state] of pending) { @@ -368,7 +370,11 @@ export class SessionSnapshotStore implements ChatCacheObserver { } const generation = snapshotStoreGeneration; this.writeChain = this.writeChain.then(async () => { - const evicted = await writeSnapshotRecords(records, generation); + const currentRecords = records.filter( + ({ sessionKey }) => + pendingRevisions.get(sessionKey) === (this.revisions.get(sessionKey) ?? 0), + ); + const evicted = await writeSnapshotRecords(currentRecords, generation); if (evicted === null) { this.resetSavedAtIndex(); return; @@ -416,6 +422,7 @@ export class SessionSnapshotStore implements ChatCacheObserver { private async seedSavedAtIndex(): Promise { const generation = snapshotStoreGeneration; + const revisions = new Map(this.revisions); const records = await readSnapshotRecords(); if (generation !== snapshotStoreGeneration) { return; @@ -425,6 +432,11 @@ export class SessionSnapshotStore implements ChatCacheObserver { return; } for (const record of records) { + if ( + (revisions.get(record.sessionKey) ?? 0) !== (this.revisions.get(record.sessionKey) ?? 0) + ) { + continue; + } const current = this.savedAtBySession.get(record.sessionKey) ?? 0; this.savedAtBySession.set(record.sessionKey, Math.max(current, record.savedAt)); } @@ -439,7 +451,10 @@ export class SessionSnapshotStore implements ChatCacheObserver { } subscribeSnapshotInvalidation(async ({ sessionKey }) => { - snapshotStoreGeneration += 1; + // Scoped deletes fence only their session; whole-cache clears retire every pending operation. + if (!sessionKey) { + snapshotStoreGeneration += 1; + } for (const store of activeStores) { if (sessionKey) { store.forget(sessionKey);