Archived
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user