import { beforeEach, describe, expect, it } from 'vitest' import type { SessionInfo } from '@/types/hermes' import { makeSessionInfo } from '../test/session-info' import { $activeGatewayProfile } from './profile' import { $cronSessions, $messagingSessions, $selectedStoredSessionId, $sessions, $unreadFinishedSessionIds, setSelectedStoredSessionId, setSessions } from './session' import { clearAllSessionStates } from './session-states' import { $sessionSeenCounts, $unreadFinishedMarkers, forgetSessionUnread, markSessionUnreadFinished } from './session-unread' const session = (over: Partial): SessionInfo => makeSessionInfo({ id: 'live', ...over }) function resetAll() { clearAllSessionStates() // Lists first: their listeners recompute the unread atom, so wiping the // atom afterwards leaves a genuinely clean slate. $sessions.set([]) $cronSessions.set([]) $messagingSessions.set([]) $selectedStoredSessionId.set(null) $activeGatewayProfile.set('default') $sessionSeenCounts.set({}) $unreadFinishedMarkers.set({}) $unreadFinishedSessionIds.set([]) } describe('persisted unread (session-unread)', () => { beforeEach(resetAll) it('reconstructs the green dot from a seen watermark after a cold start', () => { // "Restart": persisted watermark says 3 messages were seen; the freshly // loaded list says the session now has 5 — it finished while we were away. $sessionSeenCounts.set({ default: { s1: 3 } }) setSessions([session({ id: 's1', message_count: 5 })]) expect($unreadFinishedSessionIds.get()).toEqual(['s1']) }) it('seeds the watermark on first sight instead of painting every row green', () => { setSessions([session({ id: 's1', message_count: 5 })]) expect($unreadFinishedSessionIds.get()).toEqual([]) expect($sessionSeenCounts.get()).toEqual({ default: { s1: 5 } }) }) it('keeps a live-edge marker across a transient wipe (gateway/profile switch)', () => { setSessions([session({ id: 's1', message_count: 4 })]) markSessionUnreadFinished('s1') expect($unreadFinishedSessionIds.get()).toEqual(['s1']) // Gateway switch: transient paint layer wiped, lists drained… setSessions([]) $unreadFinishedSessionIds.set([]) // …then the profile's lists load again: the persisted marker repaints. setSessions([session({ id: 's1', message_count: 4 })]) expect($unreadFinishedSessionIds.get()).toEqual(['s1']) }) it('acks watermark + marker when the user opens the session', () => { $sessionSeenCounts.set({ default: { s1: 3 } }) setSessions([session({ id: 's1', message_count: 5 })]) markSessionUnreadFinished('s1') setSelectedStoredSessionId('s1') expect($unreadFinishedSessionIds.get()).toEqual([]) expect($unreadFinishedMarkers.get()).toEqual({}) expect($sessionSeenCounts.get()).toEqual({ default: { s1: 5 } }) // A later refresh with the same count stays read. setSessions([session({ id: 's1', message_count: 5 })]) expect($unreadFinishedSessionIds.get()).toEqual([]) }) it('tracks the selected session’s count so on-screen activity never reads as unread', () => { $selectedStoredSessionId.set('s1') setSessions([session({ id: 's1', message_count: 2 })]) // The turn completes while the user is looking at it. setSessions([session({ id: 's1', message_count: 7 })]) expect($unreadFinishedSessionIds.get()).toEqual([]) expect($sessionSeenCounts.get()).toEqual({ default: { s1: 7 } }) // Navigating away must not retroactively flag it. setSelectedStoredSessionId(null) setSessions([session({ id: 's1', message_count: 7 })]) expect($unreadFinishedSessionIds.get()).toEqual([]) }) it('follows auto-compression id rotation via the durable lineage id', () => { $sessionSeenCounts.set({ default: { root: 3 } }) setSessions([session({ _lineage_root_id: 'root', id: 'tip-2', message_count: 6 })]) expect($unreadFinishedSessionIds.get()).toEqual(['tip-2']) // Opening the rotated tip acks the lineage watermark. setSelectedStoredSessionId('tip-2') expect($sessionSeenCounts.get()).toEqual({ default: { root: 6 } }) }) it('reconstructs unread for cron rows too', () => { $sessionSeenCounts.set({ default: { cron1: 1 } }) $cronSessions.set([session({ id: 'cron1', message_count: 3 })]) expect($unreadFinishedSessionIds.get()).toEqual(['cron1']) }) it('never paints messaging rows from count growth, but honors live-edge markers', () => { // First sight seeds… $messagingSessions.set([session({ id: 'tg1', message_count: 10 })]) // …then inbound messages bump the count: NOT "finished — unread". $messagingSessions.set([session({ id: 'tg1', message_count: 14 })]) expect($unreadFinishedSessionIds.get()).toEqual([]) // A real background turn edge still marks it, durably. markSessionUnreadFinished('tg1') $unreadFinishedSessionIds.set([]) $messagingSessions.set([session({ id: 'tg1', message_count: 14 })]) expect($unreadFinishedSessionIds.get()).toEqual(['tg1']) }) it('preserves a live-edge id whose row is not in any loaded list yet', () => { // A brand-new session's first turn isn't flushed to the sidebar list until // persisted — the flag must survive list refreshes in the meantime. markSessionUnreadFinished('fresh') setSessions([session({ id: 'other', message_count: 2 })]) expect($unreadFinishedSessionIds.get()).toContain('fresh') }) it('keeps same-id sessions in different profiles independent', () => { // Session ids are caller-supplied and every profile backend is its own // namespace, so "s1" in alpha and "s1" in beta are different sessions — // and cron/messaging lists always mix profiles into one array. $sessionSeenCounts.set({ alpha: { s1: 3 }, beta: { s1: 5 } }) $unreadFinishedMarkers.set({ alpha: ['s1'] }) setSessions([ session({ id: 's1', message_count: 5, profile: 'beta' }), session({ id: 's1', message_count: 5, profile: 'alpha' }) ]) // Only alpha's row is past its watermark; beta's sits exactly on it. expect($unreadFinishedSessionIds.get()).toEqual(['s1']) expect($sessionSeenCounts.get()).toEqual({ alpha: { s1: 3 }, beta: { s1: 5 } }) // Opening beta's row (the gateway is on beta) acks beta only. $activeGatewayProfile.set('beta') setSelectedStoredSessionId('s1') expect($sessionSeenCounts.get()).toEqual({ alpha: { s1: 3 }, beta: { s1: 5 } }) // alpha's marker survives an ack of beta's identically-named session. expect($unreadFinishedMarkers.get()).toEqual({ alpha: ['s1'] }) // A later refresh must not launder alpha's gap through beta's selection. setSessions([ session({ id: 's1', message_count: 6, profile: 'beta' }), session({ id: 's1', message_count: 5, profile: 'alpha' }) ]) expect($sessionSeenCounts.get()).toEqual({ alpha: { s1: 3 }, beta: { s1: 6 } }) expect($unreadFinishedMarkers.get()).toEqual({ alpha: ['s1'] }) }) it('files a live-edge marker with no loaded row under the active gateway profile', () => { $activeGatewayProfile.set('work') markSessionUnreadFinished('fresh') expect($unreadFinishedMarkers.get()).toEqual({ work: ['fresh'] }) expect($unreadFinishedSessionIds.get()).toEqual(['fresh']) }) it('forgets a deleted session’s persisted unread, per profile', () => { $sessionSeenCounts.set({ alpha: { root: 3, other: 1 }, beta: { root: 9 } }) $unreadFinishedMarkers.set({ alpha: ['root', 'keep'], beta: ['root'] }) $unreadFinishedSessionIds.set(['tip-2']) forgetSessionUnread(['tip-2', 'root', null, undefined], 'alpha') expect($sessionSeenCounts.get()).toEqual({ alpha: { other: 1 }, beta: { root: 9 } }) expect($unreadFinishedMarkers.get()).toEqual({ alpha: ['keep'], beta: ['root'] }) expect($unreadFinishedSessionIds.get()).toEqual([]) }) it('drops an emptied profile bucket instead of persisting an empty shell', () => { $sessionSeenCounts.set({ alpha: { s1: 3 } }) $unreadFinishedMarkers.set({ alpha: ['s1'] }) forgetSessionUnread(['s1'], 'alpha') expect($sessionSeenCounts.get()).toEqual({}) expect($unreadFinishedMarkers.get()).toEqual({}) }) it('caps markers per profile, evicting the oldest', () => { for (let i = 0; i < 201; i += 1) { markSessionUnreadFinished(`s${i}`) } const markers = $unreadFinishedMarkers.get().default expect(markers).toHaveLength(200) expect(markers[0]).toBe('s1') expect(markers.at(-1)).toBe('s200') }) })