import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientSessionState } from '@/app/types' import { createClientSessionState } from '@/lib/chat-runtime' import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds } from './session' import { $attentionSessionIds, $stalledSessionIds, $workingSessionIds, clearAllSessionStates, getRecentlySettledSessionIds, publishSessionState, SESSION_WATCHDOG_TIMEOUT_MS } from './session-states' // Read from the store rather than restated here: these assert what happens on // either side of the threshold, not what the threshold is. const WATCHDOG_MS = SESSION_WATCHDOG_TIMEOUT_MS function state(over: Partial = {}): ClientSessionState { return { ...createClientSessionState(null), storedSessionId: 's1', ...over } } describe('session status transitions', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(0) clearAllSessionStates() $unreadFinishedSessionIds.set([]) $selectedStoredSessionId.set(null) $activeSessionId.set(null) }) afterEach(() => { vi.runOnlyPendingTimers() vi.useRealTimers() clearAllSessionStates() $unreadFinishedSessionIds.set([]) $selectedStoredSessionId.set(null) $activeSessionId.set(null) }) it('adds a session to $workingSessionIds when busy transitions to true', () => { const s = state({ busy: false, storedSessionId: 's1' }) publishSessionState('rt1', s) publishSessionState('rt1', { ...s, busy: true }) expect($workingSessionIds.get()).toContain('s1') }) it('removes a session from $workingSessionIds when busy transitions to false', () => { const working = state({ busy: true, storedSessionId: 's1' }) publishSessionState('rt1', working) expect($workingSessionIds.get()).toContain('s1') publishSessionState('rt1', { ...working, busy: false }) expect($workingSessionIds.get()).not.toContain('s1') }) it('adds a session to $attentionSessionIds when needsInput is true', () => { const s = state({ busy: true, needsInput: false, storedSessionId: 's1' }) publishSessionState('rt1', s) publishSessionState('rt1', { ...s, needsInput: true }) expect($attentionSessionIds.get()).toContain('s1') }) it('marks a background session unread when its turn finishes', () => { $selectedStoredSessionId.set('other-session') const working = state({ busy: true, storedSessionId: 's1' }) publishSessionState('rt1', working) publishSessionState('rt1', { ...working, busy: false }) expect($unreadFinishedSessionIds.get()).toEqual(['s1']) }) it('does NOT mark unread when the finishing session is the active one', () => { $selectedStoredSessionId.set('s1') const working = state({ busy: true, storedSessionId: 's1' }) publishSessionState('rt1', working) publishSessionState('rt1', { ...working, busy: false }) expect($unreadFinishedSessionIds.get()).toEqual([]) }) it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => { $selectedStoredSessionId.set('other-session') publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' })) expect($unreadFinishedSessionIds.get()).toEqual([]) }) it('grants settle grace when a working session goes idle', () => { $selectedStoredSessionId.set('other') const working = state({ busy: true, storedSessionId: 's1' }) publishSessionState('rt1', working) publishSessionState('rt1', { ...working, busy: false }) expect(getRecentlySettledSessionIds()).toEqual(['s1']) }) it('does not grant grace on idle→idle re-asserts', () => { publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' })) expect(getRecentlySettledSessionIds()).toEqual([]) }) it('clears settle grace when the session goes busy again', () => { $selectedStoredSessionId.set('other') const working = state({ busy: true, storedSessionId: 's2' }) publishSessionState('rt1', working) const idle = { ...working, busy: false } publishSessionState('rt1', idle) expect(getRecentlySettledSessionIds()).toEqual(['s2']) publishSessionState('rt1', { ...idle, busy: true }) expect(getRecentlySettledSessionIds()).toEqual([]) }) }) describe('session watchdog', () => { beforeEach(() => { vi.useFakeTimers() clearAllSessionStates() $unreadFinishedSessionIds.set([]) $selectedStoredSessionId.set(null) $activeSessionId.set(null) }) afterEach(() => { vi.runOnlyPendingTimers() vi.useRealTimers() clearAllSessionStates() $unreadFinishedSessionIds.set([]) $selectedStoredSessionId.set(null) $activeSessionId.set(null) }) it('marks a silent session stalled without pretending it finished', () => { publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' })) vi.advanceTimersByTime(WATCHDOG_MS) expect($workingSessionIds.get()).toContain('s1') expect($stalledSessionIds.get()).toContain('s1') }) it('clears stalled on new activity and rearms the watchdog', () => { const working = state({ busy: true, storedSessionId: 's2' }) publishSessionState('rt2', working) vi.advanceTimersByTime(WATCHDOG_MS) expect($stalledSessionIds.get()).toContain('s2') publishSessionState('rt2', { ...working, awaitingResponse: true }) expect($stalledSessionIds.get()).not.toContain('s2') vi.advanceTimersByTime(WATCHDOG_MS - 1) expect($stalledSessionIds.get()).not.toContain('s2') expect($workingSessionIds.get()).toContain('s2') }) it('clears both running and stalled on an authoritative terminal transition', () => { const working = state({ busy: true, storedSessionId: 's3' }) publishSessionState('rt3', working) vi.advanceTimersByTime(WATCHDOG_MS) expect($stalledSessionIds.get()).toContain('s3') publishSessionState('rt3', { ...working, busy: false }) expect($workingSessionIds.get()).not.toContain('s3') expect($stalledSessionIds.get()).not.toContain('s3') }) it('never marks a session stalled when it settles before the window', () => { const working = state({ busy: true, storedSessionId: 's4' }) publishSessionState('rt4', working) publishSessionState('rt4', { ...working, busy: false }) vi.advanceTimersByTime(WATCHDOG_MS) expect($workingSessionIds.get()).not.toContain('s4') expect($stalledSessionIds.get()).not.toContain('s4') }) it('clears stalled state and disarms timers on a gateway wipe', () => { publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' })) vi.advanceTimersByTime(WATCHDOG_MS) expect($stalledSessionIds.get()).toEqual(['s1']) clearAllSessionStates() vi.advanceTimersByTime(WATCHDOG_MS) expect($workingSessionIds.get()).toEqual([]) expect($stalledSessionIds.get()).toEqual([]) }) }) describe('computed $workingSessionIds', () => { beforeEach(() => { clearAllSessionStates() }) afterEach(() => { clearAllSessionStates() }) it('is empty when no sessions are busy', () => { expect($workingSessionIds.get()).toEqual([]) }) it('reflects busy sessions under the id their surfaces key on', () => { publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' })) publishSessionState('rt2', state({ busy: false, storedSessionId: 's2' })) // Not yet persisted, so the runtime id is the only id it has — and the one // the row is keyed by until the backend hands a stored id back. publishSessionState('rt3', state({ busy: true, storedSessionId: null })) expect($workingSessionIds.get()).toEqual(['s1', 'rt3']) }) it('updates when session state changes', () => { publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' })) expect($workingSessionIds.get()).toEqual(['s1']) publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' })) expect($workingSessionIds.get()).toEqual([]) }) }) describe('computed $attentionSessionIds', () => { beforeEach(() => { clearAllSessionStates() }) afterEach(() => { clearAllSessionStates() }) it('reflects sessions with needsInput=true and a storedSessionId', () => { publishSessionState('rt1', state({ needsInput: true, storedSessionId: 's1' })) publishSessionState('rt2', state({ needsInput: false, storedSessionId: 's2' })) expect($attentionSessionIds.get()).toEqual(['s1']) }) it('clears when $sessionStates is cleared', () => { publishSessionState('rt1', state({ needsInput: true, storedSessionId: 's1' })) expect($attentionSessionIds.get()).toEqual(['s1']) clearAllSessionStates() expect($attentionSessionIds.get()).toEqual([]) }) })