import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createClientSessionState } from '@/lib/chat-runtime' import { sessionMessagesSignature } from '@/lib/session-signatures' import { $changeEventsAvailable, notifySessionsChanged, resetLiveSync } from '@/store/live-sync' import { $activeSessionId, $selectedStoredSessionId, setBusy, setCronSessions, setMessagingSessions, setSessionOwnerHint, setSessions } from '@/store/session' import { $attentionSessionIds, $sessionTiles, $stalledSessionIds, $workingSessionIds, clearAllSessionStates, publishSessionState, SESSION_WATCHDOG_TIMEOUT_MS } from '@/store/session-states' import { type ActiveTranscriptRefreshDeps, isTypingBurstActive, noteRendererKeyboardActivity, reconcileActiveTranscript, reconcileTileTranscripts as reconcileTileTranscriptsForTest, rehydrateLiveSessionStatuses, resetTypingActivityTracking, resolveActiveTranscriptSession, useBackgroundSync, windowIsActivelyViewed } from './use-background-sync' vi.mock('@/hermes', async importOriginal => ({ ...(await importOriginal()), getLatestSessionMessages: vi.fn() })) vi.mock('@/store/projects', async importOriginal => ({ ...(await importOriginal()), refreshProjectTree: vi.fn(async () => undefined) })) const { getLatestSessionMessages } = await import('@/hermes') const { refreshProjectTree } = await import('@/store/projects') const ACTIVE_RUNTIME_ID = 'runtime-active' const ACTIVE_STORED_ID = 'stored-active' function transcript(answer: string, sessionId = ACTIVE_STORED_ID) { return { messages: [ { content: 'question', role: 'user', timestamp: 1 }, { content: answer, role: 'assistant', timestamp: 2 } ], session_id: sessionId } } function makeRefresh(resolveSession: ActiveTranscriptRefreshDeps['resolveSession'] = () => ({ profile: 'default' })) { const activeSessionIdRef = { current: ACTIVE_RUNTIME_ID as string | null } const selectedStoredSessionIdRef = { current: ACTIVE_STORED_ID as string | null } const busyRef = { current: false } const requestSequenceRef = { current: 0 } const signatureRef = { current: new Map() } const state = createClientSessionState(ACTIVE_STORED_ID) const states = new Map([[ACTIVE_RUNTIME_ID, state]]) const updateSessionStateRef = { updateSessionState: vi.fn((sessionId: string, updater: (value: typeof state) => typeof state) => { const next = updater(states.get(sessionId) ?? createClientSessionState(ACTIVE_STORED_ID)) states.set(sessionId, next) return next }) } const { updateSessionState } = updateSessionStateRef const refresh = () => reconcileActiveTranscript({ activeSessionIdRef, busyRef, requestSequenceRef, resolveSession, selectedStoredSessionIdRef, signatureRef, updateSessionState }) return { activeSessionIdRef, busyRef, refresh, selectedStoredSessionIdRef, state, states, updateSessionState } } function useSyncHarness({ activeIsMessaging = false, activeSessionId, activeStoredSessionId, gatewayState = 'open', refreshActiveTranscript }: { activeIsMessaging?: boolean activeSessionId: string | null activeStoredSessionId: string | null gatewayState?: string refreshActiveTranscript: () => Promise }) { const updateSessionState: Parameters[0]['updateSessionState'] = vi.fn( (sessionId, updater) => { const current = {} as Parameters[0] return updater(current) } ) useBackgroundSync({ activeConnectionId: 'local', activeGatewayProfile: 'default', activeIsMessaging, activeSessionId, activeStoredSessionId, freshDraftReady: false, gatewayState, refreshActiveTranscript, refreshCronJobs: vi.fn(), refreshCurrentModel: vi.fn(), refreshHermesConfig: vi.fn(), refreshMessagingSessions: vi.fn(), refreshSessions: vi.fn(), updateSessionState, requestGateway: vi.fn(async () => ({ sessions: [] })) as never }) } type SyncOptions = { activeIsMessaging?: boolean activeSessionId?: null | string activeStoredSessionId?: null | string gatewayState?: string } function renderSync(refreshActiveTranscript: () => Promise, options: SyncOptions = {}) { return renderHook( (props: SyncOptions) => useSyncHarness({ activeSessionId: ACTIVE_RUNTIME_ID, activeStoredSessionId: ACTIVE_STORED_ID, refreshActiveTranscript, ...props }), { initialProps: options } ) } beforeEach(() => { // visiblePoll only ticks while the window is actively viewed; jsdom's // document.hasFocus() is not reliably true, so pin it for these tests. vi.spyOn(document, 'hasFocus').mockReturnValue(true) }) afterEach(() => { cleanup() vi.clearAllTimers() vi.useRealTimers() resetLiveSync() $activeSessionId.set(null) $selectedStoredSessionId.set(null) setSessions([]) setCronSessions([]) setMessagingSessions([]) setBusy(false) vi.clearAllMocks() vi.restoreAllMocks() clearAllSessionStates() $sessionTiles.set([]) resetTypingActivityTracking() }) describe('active transcript refresh', () => { beforeEach(() => { vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('answer') as never) }) it('refreshes a hidden session through its unique complete owner route', async () => { const hiddenStoredSessionId = 'hidden-bot-chat' const ownerRoute = { connectionId: 'ssh-bot-owner', mode: 'remote' as const, profile: 'bot-route', targetProfile: 'bot-profile' } $changeEventsAvailable.set(true) $activeSessionId.set(ACTIVE_RUNTIME_ID) $selectedStoredSessionId.set(hiddenStoredSessionId) setSessionOwnerHint(hiddenStoredSessionId, ownerRoute) const fixture = makeRefresh(resolveActiveTranscriptSession) fixture.selectedStoredSessionIdRef.current = hiddenStoredSessionId vi.mocked(getLatestSessionMessages).mockResolvedValue( transcript('hidden external answer', hiddenStoredSessionId) as never ) renderSync(fixture.refresh, { activeStoredSessionId: hiddenStoredSessionId }) act(() => notifySessionsChanged()) await waitFor(() => expect(getLatestSessionMessages).toHaveBeenCalledWith(hiddenStoredSessionId, { connectionId: ownerRoute.connectionId, profile: ownerRoute.targetProfile }) ) expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({ text: 'hidden external answer' }) }) it('reconciles a workspace TILE transcript when sessions.changed ticks (#94255 review: behavior, not source-grep)', async () => { $changeEventsAvailable.set(true) // The tile's runtime differs from the active session — it is NOT the main // pane surface, so only the tile reconcile path may update it. const TILE_RUNTIME_ID = 'runtime-tile' const TILE_STORED_ID = 'stored-tile' $activeSessionId.set('runtime-something-else') $selectedStoredSessionId.set('stored-other') const states = new Map>() states.set(TILE_RUNTIME_ID, createClientSessionState(TILE_STORED_ID)) let updaterCallCount = 0 const updateSessionState: Parameters[0]['updateSessionState'] = vi.fn( (sessionId, updater) => { updaterCallCount += 1 const current = {} as Parameters[0] return updater(current) } ) void updateSessionState const signatureRef = { current: new Map() } const requestSequenceRef = { current: 0 } vi.mocked(getLatestSessionMessages).mockImplementation(async (storedId: string) => { if (storedId === TILE_STORED_ID) { return { messages: [ { content: 'tile question', role: 'user', timestamp: 1 }, { content: 'background delivery answer', role: 'assistant', timestamp: 2 } ], session_id: TILE_STORED_ID } as never } return transcript('main-pane answer') as never }) // Seed a tile so reconcileTileTranscripts has a target. setSessions([]) // bot chats are hidden from $sessions — the whole point await act(async () => { await reconcileTileTranscriptsForTest({ tiles: [{ storedSessionId: TILE_STORED_ID, runtimeId: TILE_RUNTIME_ID }], requestSequenceRef, signatureRef, updateSessionState }) }) // Behavior assertions: expect(updaterCallCount).toBeGreaterThan(0) expect(getLatestSessionMessages).toHaveBeenCalledWith(TILE_STORED_ID, undefined) }) it('reconciles an idle tile while the main pane is busy', async () => { const runtimeId = 'runtime-idle-tile' const storedId = 'stored-idle-tile' const idleState = createClientSessionState(storedId) setBusy(true) publishSessionState(runtimeId, idleState) vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('idle tile update', storedId) as never) const updateSessionState = vi.fn((sessionId: string, updater: (state: typeof idleState) => typeof idleState) => { expect(sessionId).toBe(runtimeId) return updater(idleState) }) await reconcileTileTranscriptsForTest({ tiles: [{ runtimeId, storedSessionId: storedId }], requestSequenceRef: { current: 0 }, signatureRef: { current: new Map() }, updateSessionState }) expect(getLatestSessionMessages).toHaveBeenCalledWith(storedId, undefined) expect(updateSessionState).toHaveBeenCalledTimes(1) }) it('does not reconcile a busy tile when the main pane is idle', async () => { const runtimeId = 'runtime-busy-tile' const storedId = 'stored-busy-tile' const liveState = createClientSessionState(storedId) liveState.busy = true liveState.messages = [ { id: 'live-assistant', parts: [{ text: 'streaming answer', type: 'text' }], pending: true, role: 'assistant' } ] publishSessionState(runtimeId, liveState) vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: [], session_id: storedId } as never) const updateSessionState = vi.fn() await reconcileTileTranscriptsForTest({ tiles: [{ runtimeId, storedSessionId: storedId }], requestSequenceRef: { current: 0 }, signatureRef: { current: new Map() }, updateSessionState }) expect(getLatestSessionMessages).not.toHaveBeenCalled() expect(updateSessionState).not.toHaveBeenCalled() }) it('discards a tile snapshot when the tile closes during the read', async () => { const runtimeId = 'runtime-closing-tile' const storedId = 'stored-closing-tile' let resolveRead: (value: unknown) => void = () => undefined $sessionTiles.set([{ runtimeId, storedSessionId: storedId }]) publishSessionState(runtimeId, createClientSessionState(storedId)) vi.mocked(getLatestSessionMessages).mockReturnValueOnce( new Promise(resolve => { resolveRead = resolve }) as never ) const updateSessionState = vi.fn() const reconcile = reconcileTileTranscriptsForTest({ requestSequenceRef: { current: 0 }, signatureRef: { current: new Map() }, updateSessionState }) $sessionTiles.set([]) resolveRead(transcript('stale tile answer', storedId)) await reconcile expect(updateSessionState).not.toHaveBeenCalled() }) it('isolates tile transcript reads by connection and profile while preserving the legacy local path', async () => { vi.mocked(getLatestSessionMessages).mockImplementation(async storedId => transcript(storedId, storedId) as never) const updateSessionState: Parameters[0]['updateSessionState'] = vi.fn( (_sessionId, updater) => updater({} as Parameters[0]) ) await reconcileTileTranscriptsForTest({ tiles: [ { ownerRoute: { connectionId: 'connection-a', mode: 'remote', profile: 'shared-profile', targetProfile: 'target-a' }, runtimeId: 'runtime-a', storedSessionId: 'stored-a' }, { ownerRoute: { connectionId: 'connection-b', mode: 'remote', profile: 'shared-profile' }, runtimeId: 'runtime-b', storedSessionId: 'stored-b' }, { runtimeId: 'runtime-local', storedSessionId: 'stored-local' } ], requestSequenceRef: { current: 0 }, signatureRef: { current: new Map() }, updateSessionState }) expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-a', { connectionId: 'connection-a', profile: 'target-a' }) expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-b', { connectionId: 'connection-b', profile: 'shared-profile' }) expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-local', undefined) expect(updateSessionState).toHaveBeenCalledWith('runtime-a', expect.any(Function), 'stored-a') expect(updateSessionState).toHaveBeenCalledWith('runtime-b', expect.any(Function), 'stored-b') expect(updateSessionState).toHaveBeenCalledWith('runtime-local', expect.any(Function), 'stored-local') }) it('skips the tile fetch entirely when nothing changed (signature-gated)', async () => { $changeEventsAvailable.set(true) const TILE_RUNTIME_ID = 'runtime-tile-2' const TILE_STORED_ID = 'stored-tile-2' const signatureRef = { current: new Map() } // Pre-seed the signature with what the mock returns → no-change tick. const pre = { messages: [ { content: 'q', role: 'user', timestamp: 1 }, { content: 'a', role: 'assistant', timestamp: 2 } ], session_id: TILE_STORED_ID } vi.mocked(getLatestSessionMessages).mockResolvedValue(pre as never) // Compute the same signature the reconcile will compute, and pre-seed it. const preSignature = sessionMessagesSignature(pre.messages as never) signatureRef.current.set(`tile:${TILE_STORED_ID}`, preSignature) const updateSessionState = vi.fn() const requestSequenceRef = { current: 0 } await act(async () => { await reconcileTileTranscriptsForTest({ tiles: [{ storedSessionId: TILE_STORED_ID, runtimeId: TILE_RUNTIME_ID }], requestSequenceRef, signatureRef, updateSessionState }) }) expect(updateSessionState).not.toHaveBeenCalled() }) it('refreshes a local/Desktop session when sessions.changed ticks', async () => { $changeEventsAvailable.set(true) $activeSessionId.set(ACTIVE_RUNTIME_ID) $selectedStoredSessionId.set(ACTIVE_STORED_ID) setSessionOwnerHint(ACTIVE_STORED_ID, { connectionId: 'stale-owner', mode: 'remote', profile: 'wrong-profile', targetProfile: 'wrong-target' }) setSessions([ { connectionId: 'future-visible-owner', id: ACTIVE_STORED_ID, profile: 'desktop-profile', source: 'desktop', targetProfile: 'must-not-rewrite-visible-row' } as never ]) const fixture = makeRefresh(resolveActiveTranscriptSession) vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('external answer') as never) renderSync(fixture.refresh) act(() => notifySessionsChanged()) await waitFor(() => expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({ text: 'external answer' }) ) expect(getLatestSessionMessages).toHaveBeenCalledWith(ACTIVE_STORED_ID, 'desktop-profile') }) it('does not add a periodic transcript poll to local/Desktop sessions', async () => { vi.useFakeTimers() $changeEventsAvailable.set(true) const refresh = vi.fn(async () => undefined) renderSync(refresh) // Exactly the one connect-time pull (#94779) — no timer after it. expect(refresh).toHaveBeenCalledTimes(1) await act(async () => { vi.advanceTimersByTime(60_000) await Promise.resolve() }) expect(refresh).toHaveBeenCalledTimes(1) }) it('retains the existing periodic backstop for messaging sessions', async () => { vi.useFakeTimers() $changeEventsAvailable.set(true) const refresh = vi.fn(async () => undefined) renderSync(refresh, { activeIsMessaging: true }) expect(refresh).toHaveBeenCalledTimes(1) await act(async () => Promise.resolve()) refresh.mockClear() await act(async () => { vi.advanceTimersByTime(30_000) await Promise.resolve() }) expect(refresh).toHaveBeenCalledTimes(1) }) it('only defers an external tick while busy, then refreshes once after idle', async () => { $changeEventsAvailable.set(true) const refresh = vi.fn(async () => undefined) renderSync(refresh) refresh.mockClear() // drop the connect-time pull; this test is about busy transitions act(() => setBusy(true)) act(() => setBusy(false)) expect(refresh).not.toHaveBeenCalled() act(() => setBusy(true)) act(() => { notifySessionsChanged() notifySessionsChanged() }) expect(refresh).not.toHaveBeenCalled() act(() => setBusy(false)) await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)) }) it('pulls the open transcript once per (re)connect, not on session switches (#94779)', () => { $changeEventsAvailable.set(true) const refresh = vi.fn(async () => undefined) const { rerender } = renderSync(refresh, { gatewayState: 'connecting' }) expect(refresh).not.toHaveBeenCalled() rerender({ gatewayState: 'open' }) expect(refresh).toHaveBeenCalledTimes(1) rerender({ activeSessionId: 'runtime-other', activeStoredSessionId: 'stored-other', gatewayState: 'open' }) expect(refresh).toHaveBeenCalledTimes(1) rerender({ activeSessionId: 'runtime-other', activeStoredSessionId: 'stored-other', gatewayState: 'closed' }) rerender({ activeSessionId: 'runtime-other', activeStoredSessionId: 'stored-other', gatewayState: 'open' }) expect(refresh).toHaveBeenCalledTimes(2) }) it('coalesces a burst of global session-change ticks', async () => { vi.useFakeTimers() $changeEventsAvailable.set(true) const refresh = vi.fn(async () => undefined) renderSync(refresh) refresh.mockClear() // drop the connect-time pull; this test is about tick coalescing act(() => { for (let index = 0; index < 20; index += 1) { notifySessionsChanged() } }) expect(refresh).toHaveBeenCalledTimes(1) await act(async () => { vi.advanceTimersByTime(9_999) await Promise.resolve() }) expect(refresh).toHaveBeenCalledTimes(1) }) it('refreshes the project tree on a sessions.changed tick, alongside the sessions list (#100354)', async () => { $changeEventsAvailable.set(true) renderSync(vi.fn(async () => undefined)) act(() => notifySessionsChanged()) await waitFor(() => expect(refreshProjectTree).toHaveBeenCalledTimes(1)) }) }) describe('reconcileActiveTranscript', () => { it('resolves and hydrates a messaging session from the messaging sessions store', async () => { setSessionOwnerHint(ACTIVE_STORED_ID, { connectionId: 'stale-messaging-owner', mode: 'remote', profile: 'wrong-profile', targetProfile: 'wrong-target' }) setMessagingSessions([{ id: ACTIVE_STORED_ID, profile: 'messaging-profile', source: 'telegram' } as never]) const fixture = makeRefresh(resolveActiveTranscriptSession) vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('telegram answer') as never) await fixture.refresh() expect(getLatestSessionMessages).toHaveBeenCalledWith(ACTIVE_STORED_ID, 'messaging-profile') expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({ text: 'telegram answer' }) }) it('resolves and hydrates a cron session from the cron sessions store', async () => { setCronSessions([{ id: ACTIVE_STORED_ID, profile: 'cron-profile', source: 'cron' } as never]) const fixture = makeRefresh(resolveActiveTranscriptSession) vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('cron progress') as never) await fixture.refresh() expect(getLatestSessionMessages).toHaveBeenCalledWith(ACTIVE_STORED_ID, 'cron-profile') expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({ text: 'cron progress' }) }) it('fails closed when a hidden session id has multiple owner hints', async () => { const ambiguousStoredSessionId = 'ambiguous-hidden-chat' setSessionOwnerHint(ambiguousStoredSessionId, { connectionId: 'owner-a', mode: 'remote', profile: 'bot' }) setSessionOwnerHint(ambiguousStoredSessionId, { connectionId: 'owner-b', mode: 'remote', profile: 'bot' }) const fixture = makeRefresh(resolveActiveTranscriptSession) fixture.selectedStoredSessionIdRef.current = ambiguousStoredSessionId await fixture.refresh() expect(getLatestSessionMessages).not.toHaveBeenCalled() expect(fixture.updateSessionState).not.toHaveBeenCalled() }) it('uses the presentation profile when a hidden owner has no target profile', async () => { const hiddenStoredSessionId = 'hidden-no-target' setSessionOwnerHint(hiddenStoredSessionId, { connectionId: 'owner-no-target', mode: 'remote', profile: 'presentation-profile' }) const fixture = makeRefresh(resolveActiveTranscriptSession) fixture.selectedStoredSessionIdRef.current = hiddenStoredSessionId await fixture.refresh() expect(getLatestSessionMessages).toHaveBeenCalledWith(hiddenStoredSessionId, { connectionId: 'owner-no-target', profile: 'presentation-profile' }) }) it('reads and publishes only the active hidden owner when another owner coexists', async () => { const ownerAStoredSessionId = 'owner-a-chat' const ownerBStoredSessionId = 'owner-b-hidden-chat' const ownerBRoute = { connectionId: 'owner-b', mode: 'remote' as const, profile: 'bot-route', targetProfile: 'bot-b' } setSessions([{ id: ownerAStoredSessionId, profile: 'bot-a', source: 'desktop' } as never]) setSessionOwnerHint(ownerAStoredSessionId, { connectionId: 'owner-a', mode: 'remote', profile: 'bot-route', targetProfile: 'bot-a' }) setSessionOwnerHint(ownerBStoredSessionId, ownerBRoute) const fixture = makeRefresh(resolveActiveTranscriptSession) fixture.selectedStoredSessionIdRef.current = ownerBStoredSessionId vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('owner B answer', ownerBStoredSessionId) as never) await fixture.refresh() expect(getLatestSessionMessages).toHaveBeenCalledTimes(1) expect(getLatestSessionMessages).toHaveBeenCalledWith(ownerBStoredSessionId, { connectionId: ownerBRoute.connectionId, profile: ownerBRoute.targetProfile }) expect(fixture.updateSessionState).toHaveBeenCalledWith( ACTIVE_RUNTIME_ID, expect.any(Function), ownerBStoredSessionId ) expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({ text: 'owner B answer' }) }) it('publishes changed authoritative messages once without duplicates', async () => { const fixture = makeRefresh() vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('new answer') as never) await fixture.refresh() expect(fixture.updateSessionState).toHaveBeenCalledTimes(1) const messages = fixture.states.get(ACTIVE_RUNTIME_ID)?.messages ?? [] expect(messages.map(message => message.role)).toEqual(['user', 'assistant']) expect(new Set(messages.map(message => message.id)).size).toBe(messages.length) await fixture.refresh() expect(fixture.updateSessionState).toHaveBeenCalledTimes(1) }) it('preserves a local assistant error while hydrating authoritative messages', async () => { const fixture = makeRefresh() fixture.state.messages = [ { id: '1-0-user', parts: [{ text: 'question', type: 'text' }], role: 'user' }, { error: 'local failure', id: 'assistant-error', parts: [], role: 'assistant' } ] vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: [{ content: 'question', role: 'user', timestamp: 1 }], session_id: ACTIVE_STORED_ID } as never) await fixture.refresh() const messages = fixture.states.get(ACTIVE_RUNTIME_ID)?.messages ?? [] expect(messages.map(message => message.id)).toEqual(['1-0-user', 'assistant-error']) expect(messages.at(-1)?.error).toBe('local failure') }) it('does not clobber a busy stream', async () => { const fixture = makeRefresh() fixture.busyRef.current = true await fixture.refresh() expect(getLatestSessionMessages).not.toHaveBeenCalled() expect(fixture.updateSessionState).not.toHaveBeenCalled() }) it('discards a response when the active session changes in flight', async () => { const fixture = makeRefresh() let resolve: ((value: unknown) => void) | undefined vi.mocked(getLatestSessionMessages).mockReturnValueOnce( new Promise(currentResolve => { resolve = currentResolve }) as never ) const request = fixture.refresh() fixture.selectedStoredSessionIdRef.current = 'stored-other' fixture.activeSessionIdRef.current = 'runtime-other' resolve?.(transcript('stale answer')) await request expect(fixture.updateSessionState).not.toHaveBeenCalled() }) }) describe('windowIsActivelyViewed', () => { it('requires both DOM visibility and keyboard focus', () => { expect(windowIsActivelyViewed({ focused: true, visibilityState: 'visible' })).toBe(true) expect(windowIsActivelyViewed({ focused: false, visibilityState: 'visible' })).toBe(false) expect(windowIsActivelyViewed({ focused: true, visibilityState: 'hidden' })).toBe(false) }) }) describe('rehydrateLiveSessionStatuses', () => { it('restores running sessions after reconnect without opening them', () => { const now = 1_800_000_000_000 rehydrateLiveSessionStatuses( { sessions: [ { id: 'runtime-overnight', last_active: (now - SESSION_WATCHDOG_TIMEOUT_MS - 1_000) / 1000, session_key: 'overnight-exam-learning', status: 'working' }, { id: 'runtime-cleanup', last_active: now / 1000, session_key: 'temporary-file-cleanup', status: 'working' } ] }, now ) expect($workingSessionIds.get()).toEqual(['overnight-exam-learning', 'temporary-file-cleanup']) expect($stalledSessionIds.get()).toEqual(['overnight-exam-learning']) expect($attentionSessionIds.get()).toEqual([]) }) it('restores a waiting turn as working and needing attention', () => { rehydrateLiveSessionStatuses({ sessions: [{ id: 'runtime-needs-user', session_key: 'needs-user', status: 'waiting' }] }) expect($workingSessionIds.get()).toEqual(['needs-user']) expect($attentionSessionIds.get()).toEqual(['needs-user']) expect($stalledSessionIds.get()).toEqual([]) }) it('ignores idle, starting, and malformed live-session rows', () => { rehydrateLiveSessionStatuses({ sessions: [ { id: 'runtime-idle', session_key: 'idle-session', status: 'idle' }, { id: 'runtime-starting', session_key: 'starting-session', status: 'starting' }, { id: 'runtime-malformed', status: 'working' } ] }) expect($workingSessionIds.get()).toEqual([]) expect($attentionSessionIds.get()).toEqual([]) expect($stalledSessionIds.get()).toEqual([]) }) }) describe('typing-aware sessions.changed deferral', () => { // Dedicated harness: the sessions-list spy must be the exact fn handed to // the hook (the shared harness above wires inner vi.fn()s and its outer spy // observes the transcript path instead), and EVERY param must keep a stable // identity across the tick-driven re-renders — an unstable prop would // re-run the connect-reseed effect and re-subscribe the throttle each // render, polluting the counts under observation. function renderTypingSync(refreshSessions: () => Promise) { const stable = { refreshActiveTranscript: async () => undefined, refreshCronJobs: vi.fn(), refreshCurrentModel: vi.fn(), refreshHermesConfig: vi.fn(), refreshMessagingSessions: vi.fn(), requestGateway: vi.fn(async () => ({ sessions: [] })) as never, // Required by the hook's params. This harness never drives the // transcript path, so the updater just runs against a throwaway state — // but it must live in `stable` like every other prop, since a fresh // identity per render would re-run the connect-reseed effect. updateSessionState: vi.fn( ( _sessionId: string, updater: (state: ReturnType) => ReturnType ) => updater(createClientSessionState(ACTIVE_STORED_ID)) ) } return renderHook(() => { useBackgroundSync({ activeConnectionId: 'local', activeGatewayProfile: 'default', activeIsMessaging: false, activeSessionId: null, activeStoredSessionId: null, freshDraftReady: false, gatewayState: 'open', ...stable, refreshSessions }) }) } const typeKey = (): void => { window.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })) } /** Mount, land one full throttle cycle so lastRunAt sits at a known clock * position, then clear the spy. */ async function primeThrottle(refreshSessions: ReturnType): Promise { act(() => notifySessionsChanged()) await act(async () => { // One SESSIONS_LIST_TICK_GAP_MS covers both the immediate first tick // and any trailing timer the burst armed. vi.advanceTimersByTime(10_000) await Promise.resolve() }) refreshSessions.mockClear() } it('holds the trailing sessions.changed refresh while a typing burst is live, then lands it once after the keyboard quiets', async () => { vi.useFakeTimers() $changeEventsAvailable.set(true) const refreshSessions = vi.fn(async () => undefined) renderTypingSync(refreshSessions) await primeThrottle(refreshSessions) // A ~6s continuous burst: keys every 200ms, broadcasts every ~1s. The // first broadcast finds the throttle gap already elapsed (primed), so the // deferral engages immediately and must hold for the whole burst. for (let index = 0; index < 30; index += 1) { typeKey() if (index % 5 === 0) { act(() => notifySessionsChanged()) } await act(async () => { vi.advanceTimersByTime(200) await Promise.resolve() }) } // The heavy list pass must not have landed under the keystrokes. expect(refreshSessions).not.toHaveBeenCalled() // Last key at ~5.8s; quiet threshold elapses ~7.3s → the held pass lands // exactly once shortly after. await act(async () => { vi.advanceTimersByTime(2_000) await Promise.resolve() }) expect(refreshSessions).toHaveBeenCalledTimes(1) // ...and nothing extra afterwards without further broadcasts — mid-burst // ticks must not have stacked trailing timers behind the promised pass. await act(async () => { vi.advanceTimersByTime(10_000) await Promise.resolve() }) expect(refreshSessions).toHaveBeenCalledTimes(1) }) it('holds through a burst longer than the throttle gap and lands once after the keyboard quiets', async () => { vi.useFakeTimers() $changeEventsAvailable.set(true) const refreshSessions = vi.fn(async () => undefined) renderTypingSync(refreshSessions) await primeThrottle(refreshSessions) // Keys every 200ms for ~22s — longer than SESSIONS_LIST_TICK_GAP_MS. // Broadcasts keep flowing; the heavy pass must not land under them. for (let index = 0; index < 110; index += 1) { typeKey() if (index % 10 === 0) { act(() => notifySessionsChanged()) } await act(async () => { vi.advanceTimersByTime(200) await Promise.resolve() }) } expect(refreshSessions).not.toHaveBeenCalled() await act(async () => { vi.advanceTimersByTime(2_000) await Promise.resolve() }) expect(refreshSessions).toHaveBeenCalledTimes(1) await act(async () => { vi.advanceTimersByTime(10_000) await Promise.resolve() }) expect(refreshSessions).toHaveBeenCalledTimes(1) }) it('does not defer anything when the keyboard has been idle', async () => { vi.useFakeTimers() $changeEventsAvailable.set(true) const refreshSessions = vi.fn(async () => undefined) renderTypingSync(refreshSessions) await primeThrottle(refreshSessions) act(() => notifySessionsChanged()) await act(async () => { vi.advanceTimersByTime(11_000) await Promise.resolve() }) expect(refreshSessions).toHaveBeenCalledTimes(1) }) }) describe('isTypingBurstActive', () => { it('marks a burst warm for the quiet threshold and cold at it', () => { resetTypingActivityTracking() // No keyboard history → nothing to defer for. expect(isTypingBurstActive(1_000_000)).toBe(false) noteRendererKeyboardActivity(1_000_000) expect(isTypingBurstActive(1_000_000)).toBe(true) expect(isTypingBurstActive(1_000_000 + 1_499)).toBe(true) // Exactly one quiet threshold after the last key the keyboard is cold. expect(isTypingBurstActive(1_000_000 + 1_500)).toBe(false) }) })