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
This commit is contained in:
Peter Steinberger
2026-08-23 00:51:36 -07:00
committed by GitHub
parent 87d49a3dca
commit 8d2024542e
2 changed files with 98 additions and 5 deletions
@@ -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<void> | 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);
+19 -4
View File
@@ -338,8 +338,7 @@ export class SessionSnapshotStore implements ChatCacheObserver {
}
async delete(sessionKey: string): Promise<void> {
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<void> {
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);