import { describe, expect, it } from "vitest"; import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SessionNotificationStore, truncateSessionNotificationMessage, } from "./sessionNotificationStore.js"; const identity = { sessionId: "session-1", cwd: "/workspace" }; function testStore() { let tick = 0; return new SessionNotificationStore({ daemonInstanceId: "daemon-test", now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++)), }); } function register(store: SessionNotificationStore) { return store.registerSession(identity.sessionId, identity.cwd).generation; } describe("SessionNotificationStore", () => { it("keeps duplicate calls distinct, newest-first, and recomputes severity", () => { const store = testStore(); const generation = register(store); const first = store.addNotification(generation, "same", undefined).notification; const second = store.addNotification(generation, "same", "warning").notification; const third = store.addNotification(generation, "same", "error").notification; const unknown = store.addNotification(generation, "unknown severity", "fatal").notification; expect([first?.id, second?.id, third?.id, unknown?.id]).toEqual([ "daemon-test:1", "daemon-test:2", "daemon-test:3", "daemon-test:4", ]); expect(store.inboxSnapshot(identity.sessionId, identity.cwd)).toMatchObject({ summary: { retainedCount: 4, discardedCount: 0, highestSeverity: "error", inboxRevision: 4 }, notifications: [ { id: "daemon-test:4", severity: "info" }, { id: "daemon-test:3", severity: "error" }, { id: "daemon-test:2", severity: "warning" }, { id: "daemon-test:1", severity: "info" }, ], dismissThrough: { order: 4, overflowWatermark: 0 }, }); store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, third?.id ?? ""); expect(store.inboxSnapshot(identity.sessionId, identity.cwd).summary.highestSeverity).toBe("warning"); }); it("truncates UTF-8 only between code points and marks exact overflow", () => { const exact = "a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES); expect(truncateSessionNotificationMessage(exact)).toEqual({ message: exact, truncated: false }); const astral = `${"a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES - 1)}😀tail`; const astralResult = truncateSessionNotificationMessage(astral); expect(astralResult).toEqual({ message: "a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES - 1), truncated: true }); expect(new TextEncoder().encode(astralResult.message).byteLength).toBe(SESSION_NOTIFICATION_MESSAGE_BYTES - 1); const bidi = `${"a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES - 3)}\u202etail`; const bidiResult = truncateSessionNotificationMessage(bidi); expect(bidiResult.message.endsWith("\u202e")).toBe(true); expect(new TextEncoder().encode(bidiResult.message).byteLength).toBe(SESSION_NOTIFICATION_MESSAGE_BYTES); expect(bidiResult.truncated).toBe(true); }); it("retains exactly the newest 100 and reports exact overflow", () => { const store = testStore(); const generation = register(store); for (let index = 1; index <= 105; index += 1) store.addNotification(generation, `message ${String(index)}`, "info"); const snapshot = store.inboxSnapshot(identity.sessionId, identity.cwd); expect(snapshot.notifications).toHaveLength(SESSION_NOTIFICATION_LIMIT); expect(snapshot.notifications[0]).toMatchObject({ id: "daemon-test:105", message: "message 105" }); expect(snapshot.notifications.at(-1)).toMatchObject({ id: "daemon-test:6", message: "message 6" }); expect(snapshot.summary).toMatchObject({ retainedCount: 100, discardedCount: 5 }); expect(snapshot.dismissThrough).toEqual({ order: 105, overflowWatermark: 5 }); }); it("makes individual dismissal idempotent and clears overflow with the final retained entry", () => { const store = testStore(); const generation = register(store); for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(generation, String(index), "info"); const firstSnapshot = store.inboxSnapshot(identity.sessionId, identity.cwd); const firstId = firstSnapshot.notifications[0]?.id ?? ""; const firstDismiss = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, firstId); const afterFirstRevision = firstDismiss.snapshot.summary.inboxRevision; const duplicateDismiss = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, firstId); expect(duplicateDismiss.mutations).toEqual([]); expect(duplicateDismiss.snapshot.summary.inboxRevision).toBe(afterFirstRevision); expect(duplicateDismiss.snapshot.summary.discardedCount).toBe(1); for (const notification of duplicateDismiss.snapshot.notifications) { store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, notification.id); } expect(store.inboxSnapshot(identity.sessionId, identity.cwd).summary).toMatchObject({ retainedCount: 0, discardedCount: 0 }); expect(store.catalogSnapshot().sessions).toEqual([]); }); it("dismisses only through captured order and overflow cutoffs", () => { const store = testStore(); const generation = register(store); for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(generation, String(index), "info"); const clicked = store.inboxSnapshot(identity.sessionId, identity.cwd); const later = store.addNotification(generation, "later", "error").notification; const result = store.dismissAll( identity.sessionId, identity.cwd, store.daemonInstanceId, clicked.dismissThrough.order, clicked.dismissThrough.overflowWatermark, ); expect(result.snapshot.notifications).toEqual([later]); expect(result.snapshot.summary).toMatchObject({ retainedCount: 1, discardedCount: 1, highestSeverity: "error" }); const revision = result.snapshot.summary.inboxRevision; const replay = store.dismissAll( identity.sessionId, identity.cwd, store.daemonInstanceId, clicked.dismissThrough.order, clicked.dismissThrough.overflowWatermark, ); expect(replay.mutations).toEqual([]); expect(replay.snapshot.summary.inboxRevision).toBe(revision); }); it("requests resync when dismissal reveals an entry hidden by the replacement projection cap", () => { const store = testStore(); const oldGeneration = register(store); for (let index = 1; index <= 100; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "info"); const candidate = store.beginReplacement(oldGeneration, identity); const added = store.addNotification(candidate, "candidate", "warning"); const candidateId = added.notification?.id ?? ""; expect(added.mutations[0]?.inboxEvent.delta).toMatchObject({ kind: "added", evictedNotificationId: "daemon-test:1" }); const result = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, candidateId); expect(result.mutations[0]?.inboxEvent.delta).toEqual({ kind: "resync" }); expect(result.snapshot.notifications).toHaveLength(100); expect(result.snapshot.notifications.at(-1)).toMatchObject({ id: "daemon-test:1", message: "old 1" }); expect(result.snapshot.summary.discardedCount).toBe(0); }); it("keeps dismiss-all deltas bounded to the public 100-entry projection", () => { const store = testStore(); const oldGeneration = register(store); for (let index = 0; index < 100; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "info"); const candidate = store.beginReplacement(oldGeneration, identity); for (let index = 0; index < 100; index += 1) store.addNotification(candidate, `candidate ${String(index)}`, "warning"); const clicked = store.inboxSnapshot(identity.sessionId, identity.cwd); const result = store.dismissAll( identity.sessionId, identity.cwd, store.daemonInstanceId, clicked.dismissThrough.order, clicked.dismissThrough.overflowWatermark, ); const delta = result.mutations[0]?.inboxEvent.delta; expect(delta?.kind).toBe("dismissed"); expect(delta?.kind === "dismissed" ? delta.notificationIds : []).toHaveLength(100); expect(result.snapshot.summary.retainedCount).toBe(0); store.abortReplacement(candidate); }); it("treats stale daemon and unknown notification identifiers as no-ops", () => { const store = testStore(); const generation = register(store); store.addNotification(generation, "keep", "warning"); const before = store.inboxSnapshot(identity.sessionId, identity.cwd); const stale = store.dismissAll(identity.sessionId, identity.cwd, "old-daemon", Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); const unknown = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, "missing"); expect(stale.mutations).toEqual([]); expect(unknown.mutations).toEqual([]); expect(unknown.snapshot).toEqual(before); }); it("advances catalog and inbox revisions only for visible mutations and emits zero cleanup", () => { const store = testStore(); const generation = register(store); expect(store.catalogSnapshot()).toMatchObject({ daemonInstanceId: "daemon-test", catalogRevision: 0, sessions: [] }); const added = store.addNotification(generation, "notice", "info").mutations[0]; expect(added).toMatchObject({ sessionId: "session-1", inboxEvent: { type: "notifications.inbox", catalogRevision: 1, summary: { inboxRevision: 1, retainedCount: 1 }, delta: { kind: "added" } }, summaryEvent: { type: "notifications.summary", catalogRevision: 1, summary: { inboxRevision: 1, retainedCount: 1 } }, }); const cleared = store.clearSession(identity.sessionId, "archive"); expect(cleared).toHaveLength(1); expect(cleared[0]).toMatchObject({ inboxEvent: { catalogRevision: 2, summary: { inboxRevision: 2, retainedCount: 0, discardedCount: 0 }, delta: { kind: "cleared", reason: "archive" } }, summaryEvent: { catalogRevision: 2, summary: { retainedCount: 0, discardedCount: 0 } }, }); expect(store.catalogSnapshot()).toMatchObject({ catalogRevision: 2, sessions: [] }); expect(store.addNotification(generation, "stale", "error")).toEqual({ mutations: [] }); }); it("keeps inbox revisions and overflow watermarks monotonic across same-daemon reopen", () => { const store = testStore(); const firstGeneration = register(store); for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(firstGeneration, `first ${String(index)}`, "info"); const oldSnapshot = store.inboxSnapshot(identity.sessionId, identity.cwd); store.clearSession(identity.sessionId, "runtime-close"); const reopenedGeneration = register(store); const reopenedEmpty = store.inboxSnapshot(identity.sessionId, identity.cwd); expect(reopenedEmpty.summary.inboxRevision).toBeGreaterThan(oldSnapshot.summary.inboxRevision); expect(reopenedEmpty.dismissThrough.overflowWatermark).toBe(oldSnapshot.dismissThrough.overflowWatermark); for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(reopenedGeneration, `second ${String(index)}`, "warning"); const beforeReplay = store.inboxSnapshot(identity.sessionId, identity.cwd); const replay = store.dismissAll( identity.sessionId, identity.cwd, store.daemonInstanceId, oldSnapshot.dismissThrough.order, oldSnapshot.dismissThrough.overflowWatermark, ); expect(replay.mutations).toEqual([]); expect(replay.snapshot).toEqual(beforeReplay); expect(replay.snapshot.summary.discardedCount).toBe(1); expect(replay.snapshot.dismissThrough.overflowWatermark).toBeGreaterThan(oldSnapshot.dismissThrough.overflowWatermark); }); it("commits replacement notifications while dropping the old generation and overflow", () => { const store = testStore(); const oldGeneration = register(store); for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "warning"); const candidate = store.beginReplacement(oldGeneration, identity); const replacementOneResult = store.addNotification(candidate, "replacement one", "info"); const replacementOne = replacementOneResult.notification; const replacementTwo = store.addNotification(candidate, "replacement two", "error").notification; expect(replacementOneResult.mutations[0]?.inboxEvent.delta).toMatchObject({ kind: "added", evictedNotificationId: "daemon-test:2", }); expect(store.addNotification(oldGeneration, "stale shutdown callback", "error")).toEqual({ mutations: [] }); const mutations = store.commitReplacement(candidate); const snapshot = store.inboxSnapshot(identity.sessionId, identity.cwd); expect(mutations.at(-1)?.inboxEvent.delta).toEqual({ kind: "resync" }); expect(snapshot.notifications.map((notification) => notification.id)).toEqual([replacementTwo?.id, replacementOne?.id]); expect(snapshot.summary).toMatchObject({ retainedCount: 2, discardedCount: 0, highestSeverity: "error" }); expect(store.addNotification(oldGeneration, "stale", "info")).toEqual({ mutations: [] }); }); it("aborts replacement without cleanup and keeps the 100-entry bound", () => { const store = testStore(); const oldGeneration = register(store); for (let index = 0; index < 100; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "info"); const candidate = store.beginReplacement(oldGeneration, identity); const replacementIds: string[] = []; for (let index = 0; index < 100; index += 1) { const notification = store.addNotification(candidate, `candidate ${String(index)}`, "warning").notification; if (notification !== undefined) replacementIds.push(notification.id); } const mutations = store.abortReplacement(candidate); const snapshot = store.inboxSnapshot(identity.sessionId, identity.cwd); expect(mutations.at(-1)?.inboxEvent.delta).toEqual({ kind: "resync" }); expect(snapshot.notifications).toHaveLength(100); expect(snapshot.notifications.every((notification) => replacementIds.includes(notification.id))).toBe(true); expect(snapshot.summary.discardedCount).toBe(100); expect(store.addNotification(candidate, "stale candidate", "error")).toEqual({ mutations: [] }); const afterAbort = store.addNotification(oldGeneration, "old runtime recovered", "error"); expect(afterAbort.notification).toBeDefined(); expect(store.inboxSnapshot(identity.sessionId, identity.cwd)).toMatchObject({ summary: { retainedCount: 100, discardedCount: 101, highestSeverity: "error" }, }); }); it("can keep a rotated candidate binding active after aborting cleanup", () => { const store = testStore(); const oldGeneration = register(store); store.addNotification(oldGeneration, "old", "info"); const candidate = store.beginReplacement(oldGeneration, identity); store.addNotification(candidate, "candidate", "warning"); store.abortReplacement(candidate, "candidate"); expect(store.addNotification(oldGeneration, "stale old runner", "error")).toEqual({ mutations: [] }); expect(store.addNotification(candidate, "current runner", "error").notification).toMatchObject({ message: "current runner", severity: "error" }); expect(store.inboxSnapshot(identity.sessionId, identity.cwd).notifications.map((notification) => notification.message)).toEqual([ "current runner", "candidate", "old", ]); }); it("moves preserved calls to the active changed-id candidate after aborting cleanup", () => { const store = testStore(); const oldGeneration = register(store); store.addNotification(oldGeneration, "old", "info"); const candidate = store.beginReplacement(oldGeneration, { sessionId: "session-2", cwd: identity.cwd }); store.addNotification(candidate, "candidate", "warning"); const mutations = store.abortReplacement(candidate, "candidate"); expect(mutations.map((mutation) => [mutation.sessionId, mutation.inboxEvent.delta.kind])).toEqual([ ["session-1", "cleared"], ["session-2", "resync"], ]); expect(() => store.inboxSnapshot("session-1", identity.cwd)).toThrow("Session not found"); expect(store.inboxSnapshot("session-2", identity.cwd).notifications.map((notification) => notification.message)).toEqual(["candidate", "old"]); expect(store.addNotification(candidate, "after failure", "error").notification).toMatchObject({ message: "after failure" }); }); it("retags a failed changed-id replacement back to the prior inbox", () => { const store = testStore(); const oldGeneration = register(store); store.addNotification(oldGeneration, "old", "info"); const candidate = store.beginReplacement(oldGeneration, { sessionId: "session-2", cwd: identity.cwd }); const replacement = store.addNotification(candidate, "replacement", "error").notification; const mutations = store.abortReplacement(candidate); expect(mutations.map((mutation) => [mutation.sessionId, mutation.inboxEvent.delta.kind])).toEqual([ ["session-2", "cleared"], ["session-1", "resync"], ]); expect(() => store.inboxSnapshot("session-2", identity.cwd)).toThrow("Session not found"); expect(store.inboxSnapshot(identity.sessionId, identity.cwd).notifications.map((notification) => notification.id)).toContain(replacement?.id); }); });