hermes-agent/apps/desktop/src/store/session-watchdog.test.ts
Brooklyn Nicholson 93192059c9 fix(desktop): let the session watchdog heal a stuck "looping" turn
The 8-minute stream-silence watchdog only removed a stuck session from
$workingSessionIds (the sidebar dot). The composer's busy state lives in
the session-state cache and was never cleared, so a hung or looping turn
that never delivered its terminal event — including an old session
re-opened while the backend still reports it "running" — stayed wedged on
"Thinking" / Stop indefinitely.

Have the watchdog notify subscribers when it force-clears a session, and
subscribe from the session-state cache to also drop that session's
busy/awaiting/needsInput flags. updateSessionState re-syncs $busy when the
healed session is the one on screen, so the composer recovers instead of
spinning forever.

Frontend-only safety net; doesn't touch the turn lifecycle. The backend
root (a stale in-memory session["running"] surviving a dead turn thread
and re-arming busy on every resume) is a separate follow-up.
2026-06-24 18:36:17 -05:00

59 lines
1.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $workingSessionIds, onSessionWatchdogClear, setSessionWorking, setWorkingSessionIds } from './session'
const WATCHDOG_MS = 8 * 60 * 1000
describe('session watchdog', () => {
beforeEach(() => {
vi.useFakeTimers()
setWorkingSessionIds(() => [])
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('drops a stuck session and notifies listeners once the silence window elapses', () => {
const cleared: string[] = []
const off = onSessionWatchdogClear(id => cleared.push(id))
setSessionWorking('s1', true)
expect($workingSessionIds.get()).toContain('s1')
vi.advanceTimersByTime(WATCHDOG_MS)
// Both the sidebar dot AND the busy-clearing signal fire — the contract
// that lets the composer recover from a hung/looping turn, not just the dot.
expect($workingSessionIds.get()).not.toContain('s1')
expect(cleared).toEqual(['s1'])
off()
})
it('never fires for a session that settles before the window', () => {
const cleared: string[] = []
const off = onSessionWatchdogClear(id => cleared.push(id))
setSessionWorking('s2', true)
setSessionWorking('s2', false)
vi.advanceTimersByTime(WATCHDOG_MS)
expect(cleared).toEqual([])
off()
})
it('stops notifying after unsubscribe', () => {
const cleared: string[] = []
const off = onSessionWatchdogClear(id => cleared.push(id))
off()
setSessionWorking('s3', true)
vi.advanceTimersByTime(WATCHDOG_MS)
expect(cleared).toEqual([])
})
})