test(sessions): cover unread cleanup across all archive paths

Relay unread-ux leg 1: audit verified every archive path (single, bulk,
tree, cleanup, restore, delete, rebind) clears unread state in the right
order with no resurrection vector. Add regression tests for the uncovered
paths: bulk archive, archive with descendants, cleanup, bulk delete, and
archiving an active session with a pending activity latch.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 13:59:29 +02:00
parent 4191d526f4
commit 0a080f4390
@@ -588,6 +588,237 @@ describe("PiSessionService daemon-owned unread state", () => {
await service.dispose();
}
});
it("clears unread on bulk archive for listed, active, and already-archived sessions while busy failures keep unread", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
for (const sessionId of ["listed-idle", "active-idle", "busy", "already-archived"]) {
completeStoreWork(unreadStore, sessionId, WORKSPACE_CWD);
}
const archivedRecord = { sessionId: "already-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/already-archived.jsonl" };
const activeIdle = fakeRuntime("active-idle");
const busy = fakeRuntime("busy", { isStreaming: true });
const runtimes = new Map([["active-idle", activeIdle.runtime], ["busy", busy.runtime]]);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: (_createRuntime, options) => {
const runtime = runtimes.get(options.sessionManager.getSessionId());
if (runtime === undefined) throw new Error("Unexpected runtime creation");
return Promise.resolve(runtime);
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([sessionRecord("listed-idle"), sessionRecord("active-idle"), sessionRecord("busy")]),
open: (path) => fakeSessionManager("/workspace", { getSessionId: () => path.replace(/^\/sessions\/|\.jsonl$/g, "") }),
},
archiveStore: {
list: () => Promise.resolve([archivedRecord]),
get: (sessionId) => Promise.resolve(sessionId === "already-archived" ? archivedRecord : undefined),
archive: () => Promise.reject(new Error("bulk archive should use archiveMany")),
archiveMany: (inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))),
restore: () => Promise.resolve(),
isArchived: (sessionId) => Promise.resolve(sessionId === "already-archived"),
},
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("active-idle"));
await service.status(sessionRef("busy"));
const result = await service.archiveMany([
{ id: "already-archived", cwd: "/workspace" },
{ id: "listed-idle", cwd: "/workspace" },
{ id: "active-idle", cwd: "/workspace" },
{ id: "busy", cwd: "/workspace" },
]);
expect(result.archivedSessionIds).toEqual(["already-archived", "listed-idle", "active-idle"]);
expect(result.failures).toEqual([{ sessionId: "busy", error: "Stop current session activity before archiving" }]);
expect(activeIdle.calls.dispose).toBe(1);
expect(busy.calls.abort).toBe(0);
expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "busy", cwd: WORKSPACE_CWD }]);
} finally {
await service.dispose();
}
});
it("clears unread for the whole subtree when archiving with descendants", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
for (const sessionId of ["root", "direct-child", "archived-child", "grandchild"]) {
completeStoreWork(unreadStore, sessionId, WORKSPACE_CWD);
}
const root = sessionRecord("root");
const directChild = { ...sessionRecord("direct-child"), parentSessionPath: root.path };
const archivedChild = { ...sessionRecord("archived-child"), parentSessionPath: root.path };
const grandchild = { ...sessionRecord("grandchild"), parentSessionPath: archivedChild.path };
const archivedChildRecord = {
sessionId: "archived-child",
cwd: "/workspace",
archivedAt: "2026-01-02T00:00:00.000Z",
originalPath: archivedChild.path,
archivePath: "/archive/archived-child.jsonl",
parentSessionPath: root.path,
};
const fake = fakeRuntime("root", { sessionFile: root.path });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([root, directChild, archivedChild, grandchild]),
open: () => fakeSessionManager(),
},
archiveStore: {
list: () => Promise.resolve([archivedChildRecord]),
get: () => Promise.resolve(undefined),
archive: (input: { sessionId: string; cwd: string }) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
const result = await service.archiveTree(sessionRef("root"));
expect(result.sessionIds).toEqual(["root", "direct-child", "grandchild"]);
expect(result.skippedAlreadyArchivedCount).toBe(1);
expect((await service.unreadCatalog()).sessions).toEqual([]);
} finally {
await service.dispose();
}
});
it("clears unread for sessions archived and deleted by cleanup", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "cleanup-archive", "/old-project");
completeStoreWork(unreadStore, "cleanup-delete", "/old-project");
const archivedRecord = { sessionId: "cleanup-delete", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/cleanup-delete.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: () => Promise.reject(new Error("cleanup should not open runtimes")),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([sessionRecord("cleanup-archive", "/old-project")]),
open: () => fakeSessionManager(),
},
archiveStore: {
list: () => Promise.resolve([archivedRecord]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
archiveMany: (inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }))),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
deleteArchivedMany: (sessionIds: readonly string[]) => Promise.resolve([...sessionIds]),
},
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 } });
expect(result.archivedSessionIds).toEqual(["cleanup-archive"]);
expect(result.deletedSessionIds).toEqual(["cleanup-delete"]);
expect((await service.unreadCatalog()).sessions).toEqual([]);
} finally {
await service.dispose();
}
});
it("clears unread only for archived records actually removed by bulk delete", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "busy-archived", WORKSPACE_CWD);
completeStoreWork(unreadStore, "idle-archived", WORKSPACE_CWD);
const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" };
const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" };
const busy = fakeRuntime("busy-archived", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(busy.runtime),
sessionManager: sessionGateway([]),
archiveStore: {
list: () => Promise.resolve([busyRecord, idleRecord]),
get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined),
archive: () => Promise.reject(new Error("archive should not be called")),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.reject(new Error("bulk delete should use deleteArchivedMany")),
deleteArchivedMany: (sessionIds: readonly string[]) => Promise.resolve([...sessionIds]),
},
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("busy-archived"));
const result = await service.deleteArchivedMany([
{ id: "busy-archived", cwd: "/workspace" },
{ id: "idle-archived", cwd: "/workspace" },
]);
expect(result).toMatchObject({
deletedSessionIds: ["idle-archived"],
failures: [{ sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" }],
});
expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "busy-archived", cwd: WORKSPACE_CWD }]);
} finally {
await service.dispose();
}
});
it("does not resurrect unread when archiving an active session with a pending activity latch", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("archive-active");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("archive-active")]),
archiveStore: {
...emptyArchiveStore(),
archive: (input: { sessionId: string; cwd: string }) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
},
heartbeatIntervalMs: 60_000,
unreadStore,
});
try {
await service.status(sessionRef("archive-active"));
completeRuntimeWork(fake);
expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "archive-active", completionOrder: 1 }]);
// Leave a set activity latch behind: work started, then stopped without an
// observable end event. The archive path must not let that latch record a
// completion after the forget clears unread state.
fake.session.isStreaming = true;
fake.emit({ type: "agent_start" });
fake.session.isStreaming = false;
await service.archive(sessionRef("archive-active"));
expect((await service.unreadCatalog()).sessions).toEqual([]);
expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "archive-active", unread: null });
// The disposed runtime is unsubscribed before the forget, so late events
// cannot re-latch and manufacture a completion for the archived session.
fake.emit({ type: "agent_start" });
fake.emit({ type: "turn_end" });
await drainMicrotasks();
expect((await service.unreadCatalog()).sessions).toEqual([]);
expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "archive-active", unread: null });
} finally {
await service.dispose();
}
});
});
function completeRuntimeWork(runtime: ReturnType<typeof fakeRuntime>): void {