Files
aiturk-hermes-ide/apps/desktop/src/store/session-unread.test.ts
T

231 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>): 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')
})
})