fix(desktop): keep quiet sessions visibly running (#65870)

* fix(desktop): keep quiet sessions visibly running

* fix(desktop): restore running status after reconnect

* fix(desktop): keep live session status unmistakable
This commit is contained in:
UnathiCodex 2026-07-20 16:31:58 +02:00 committed by GitHub
parent 59fdd41f5a
commit c59ca46940
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 390 additions and 135 deletions

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { sessionDotState, sessionShowsRunningArc } from './session-row-state'
describe('session row running appearance', () => {
it('keeps the running arc when an authoritative turn becomes quiet', () => {
expect(sessionShowsRunningArc({ isWorking: true, needsInput: false })).toBe(true)
expect(
sessionDotState({
hasBackground: false,
isStalled: true,
isUnread: false,
isWorking: true,
needsInput: false
})
).toBe('stalled')
})
it('uses the needs-input treatment instead of the running arc', () => {
expect(sessionShowsRunningArc({ isWorking: true, needsInput: true })).toBe(false)
expect(
sessionDotState({
hasBackground: true,
isStalled: true,
isUnread: true,
isWorking: true,
needsInput: true
})
).toBe('needs-input')
})
it('keeps background and unread states below active-turn states', () => {
expect(
sessionDotState({
hasBackground: true,
isStalled: false,
isUnread: true,
isWorking: false,
needsInput: false
})
).toBe('background')
})
})

View file

@ -0,0 +1,42 @@
export type SessionDotState = 'background' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working'
interface SessionRowState {
hasBackground: boolean
isStalled: boolean
isUnread: boolean
isWorking: boolean
needsInput: boolean
}
/** Resolve the sidebar dot's mutually-exclusive display state by priority. */
export function sessionDotState({
hasBackground,
isStalled,
isUnread,
isWorking,
needsInput
}: SessionRowState): SessionDotState {
if (needsInput) {
return 'needs-input'
}
if (isWorking) {
return isStalled ? 'stalled' : 'working'
}
if (hasBackground) {
return 'background'
}
return isUnread ? 'unread' : 'idle'
}
/** A quiet turn is still authoritatively running. Keep the unmistakable row
* arc until the gateway reports completion; only a blocking prompt suppresses
* it in favour of the needs-input treatment. */
export function sessionShowsRunningArc({
isWorking,
needsInput
}: Pick<SessionRowState, 'isWorking' | 'needsInput'>): boolean {
return isWorking && !needsInput
}

View file

@ -17,11 +17,12 @@ import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $sessionColorById } from '@/store/session-color'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { $attentionSessionIds, $stalledSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { type SessionDotState, sessionDotState, sessionShowsRunningArc } from './session-row-state'
import { useProfilePrewarm } from './use-profile-prewarm'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
@ -90,6 +91,9 @@ export function SidebarSessionRow({
// True when the session's most recent turn finished in the background (while
// the user was viewing a different session) and hasn't been opened since.
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
// True when the turn is still running but the stream has been quiet long
// enough to soften the animation. This must never look like an idle row.
const isStalled = useStore($stalledSessionIds).includes(session.id)
// True when a terminal(background=true) process is alive in this session.
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
// The session's resolved color (idle dot tint), read from the ONE shared map
@ -99,15 +103,7 @@ export function SidebarSessionRow({
// Resolve the dot's display state once — the four signals are mutually
// exclusive by priority, so threading them as booleans through wrappers just
// to collapse them at the leaf is backwards.
const dotState: SessionDotState = needsInput
? 'needs-input'
: isWorking
? 'working'
: hasBackground
? 'background'
: isUnread
? 'unread'
: 'idle'
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
return (
<SessionContextMenu
@ -183,7 +179,7 @@ export function SidebarSessionRow({
style={style}
{...rest}
>
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
{sessionShowsRunningArc({ isWorking, needsInput }) && <span aria-hidden="true" className="arc-border" />}
<SidebarRowBody
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
// Middle-click = open in a new tab (browser muscle memory). Swallow
@ -271,11 +267,6 @@ export function SidebarSessionRow({
)
}
/** The session's display state for the sidebar lead dot. The call site
* resolves this from the four underlying signals (needs-input, working,
* background, unread) so the dot component itself is a pure lookup. */
type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'working'
function SessionRowLeadDot({
branchStem,
dotState = 'idle',
@ -333,6 +324,14 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
role: 'status'
},
// Quiet accent pulse — the turn is still authoritative-running, but no
// stream activity has arrived for the watchdog window.
stalled: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
role: 'status',
title: r => r.sessionRunning
},
// Pulsing gray — a terminal(background=true) process is alive while the LLM
// is idle. Gray (not accent) reads as "something chugging along". Brighter
// than muted-foreground so it's visible against the sidebar surface.

View file

@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$attentionSessionIds,
$stalledSessionIds,
$workingSessionIds,
clearAllSessionStates,
SESSION_WATCHDOG_TIMEOUT_MS
} from '@/store/session-states'
import { rehydrateLiveSessionStatuses } from './use-background-sync'
describe('rehydrateLiveSessionStatuses', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
clearAllSessionStates()
})
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([])
})
})

View file

@ -1,7 +1,14 @@
import { useEffect } from 'react'
import { createClientSessionState } from '@/lib/chat-runtime'
import { refreshActiveProfile } from '@/store/profile'
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
import {
$sessionStates,
publishSessionState,
SESSION_WATCHDOG_TIMEOUT_MS,
setSessionStalled
} from '@/store/session-states'
import type { GatewayRequester } from '../types'
@ -11,8 +18,79 @@ import type { GatewayRequester } from '../types'
const CRON_POLL_INTERVAL_MS = 30_000
const MESSAGING_POLL_INTERVAL_MS = 10_000
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
// Match the TUI's live-session refresh cadence. Auto-compression can rotate a
// stored session id while its turn keeps running; until the next snapshot the
// sidebar row points at the new id while the renderer still knows the old one.
// A 15s cadence made that healthy transition look finished long enough to be
// alarming (and clicking the row appeared to "fix" it by touching the live
// session). This snapshot is small and already polled at 1.5s by the TUI.
const LIVE_SESSION_STATUS_POLL_INTERVAL_MS = 1_500
interface LiveSessionStatusItem {
id?: string
last_active?: number
session_key?: string
status?: 'idle' | 'starting' | 'waiting' | 'working'
}
interface LiveSessionStatusResponse {
sessions?: LiveSessionStatusItem[]
}
/** Restore sidebar liveness after a renderer/backend reconnect. Stream events
* normally own these states, but events emitted while Desktop was disconnected
* cannot be replayed. `session.active_list` is the authoritative in-memory
* snapshot and does not resume, focus, or otherwise mutate a chat. */
export function rehydrateLiveSessionStatuses(response: LiveSessionStatusResponse, nowMs = Date.now()): void {
for (const session of response.sessions ?? []) {
const runtimeSessionId = session.id?.trim()
const storedSessionId = session.session_key?.trim()
const needsInput = session.status === 'waiting'
const working = session.status === 'working' || needsInput
if (!runtimeSessionId || !storedSessionId) {
continue
}
const existing = $sessionStates.get()[runtimeSessionId]
// Avoid re-arming the watchdog on every poll. Publish only when the
// authoritative live snapshot differs from the renderer mirror; normal
// gateway events continue to own subsequent transitions.
if (
!existing ||
existing.storedSessionId !== storedSessionId ||
existing.busy !== working ||
existing.needsInput !== needsInput
) {
publishSessionState(runtimeSessionId, {
...(existing ?? createClientSessionState(storedSessionId)),
busy: working,
needsInput,
storedSessionId
})
}
if (!working) {
setSessionStalled(storedSessionId, false)
continue
}
const lastActiveMs = Number(session.last_active) * 1000
const isQuiet =
session.status === 'working' &&
Number.isFinite(lastActiveMs) &&
lastActiveMs > 0 &&
nowMs - lastActiveMs >= SESSION_WATCHDOG_TIMEOUT_MS
setSessionStalled(storedSessionId, isQuiet)
}
}
interface BackgroundSyncParams {
activeGatewayProfile: string
activeIsMessaging: boolean
activeSessionId: null | string
freshDraftReady: boolean
@ -51,6 +129,7 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void {
* All the "the desktop websocket won't tell us, so poll" logic in one place.
*/
export function useBackgroundSync({
activeGatewayProfile,
activeIsMessaging,
activeSessionId,
freshDraftReady,
@ -89,6 +168,49 @@ export function useBackgroundSync({
}
}, [gatewayState, refreshCurrentModel, refreshSessions, requestGateway])
// A reconnect loses renderer-only working/attention atoms while the backend
// keeps the actual turns alive. Re-seed from the gateway's in-memory session
// registry immediately, then cheaply poll while visible so a profile switch
// or missed reconnect edge cannot leave running rows dark until clicked.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
let cancelled = false
let inFlight = false
const refreshLiveStatuses = async () => {
if (inFlight) {
return
}
inFlight = true
try {
const response = await requestGateway<LiveSessionStatusResponse>('session.active_list', {})
if (!cancelled) {
rehydrateLiveSessionStatuses(response)
}
} catch {
// Older gateways may not expose session.active_list. Live stream events
// still work as before; leave the current sidebar state untouched.
} finally {
inFlight = false
}
}
const dispose = visiblePoll(LIVE_SESSION_STATUS_POLL_INTERVAL_MS, () => void refreshLiveStatuses())
void refreshLiveStatuses()
return () => {
cancelled = true
dispose()
}
}, [activeGatewayProfile, gatewayState, requestGateway])
// Keep the cron-jobs section live without a user action (scheduler ticks in
// the background); re-check on tab re-focus too.
useEffect(() => {

View file

@ -652,6 +652,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// Keep app data live while the gateway is open (on-connect reseed + the
// cron / messaging / transcript visibility polls + fresh-draft reseed).
useBackgroundSync({
activeGatewayProfile,
activeIsMessaging,
activeSessionId,
freshDraftReady,

View file

@ -17,7 +17,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { publishSessionState, setWatchdogClearFn } from '@/store/session-states'
import { publishSessionState } from '@/store/session-states'
import type { ClientSessionState } from '../../types'
@ -289,33 +289,6 @@ export function useSessionStateCache({
return runtimeState?.storedSessionId === storedSessionId ? runtimeId : null
}, [])
// Wire the watchdog's force-clear callback to our cache. When the watchdog
// fires (8 min of stream silence — a hung or looping turn that never
// delivered its terminal event), it calls this to clear the session's busy
// state. Clearing the sidebar dot alone would leave the composer wedged on
// "Thinking"/Stop; updateSessionState propagates the clear to $sessionStates
// → $workingSessionIds (computed) follows automatically, and
// syncSessionStateToView re-syncs $busy when the healed session is the one
// on screen.
useEffect(() => {
setWatchdogClearFn(runtimeId => {
const state = sessionStateByRuntimeIdRef.current.get(runtimeId)
if (!state?.busy) {
return
}
updateSessionState(runtimeId, current => ({
...current,
awaitingResponse: false,
busy: false,
needsInput: false
}))
})
return () => setWatchdogClearFn(null)
}, [updateSessionState])
return {
activeSessionIdRef,
ensureSessionState,

View file

@ -15,6 +15,7 @@ import {
setSessionsLoading,
setSessionsTotal
} from '@/store/session'
import { $stalledSessionIds } from '@/store/session-states'
import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from './gateway-switch'
@ -29,6 +30,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
setSessionsTotal(1)
setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never])
setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never])
$stalledSessionIds.set(['s1'])
setSessionsLoading(false)
setFreshDraftReady(false)
$sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE * 3)
@ -39,6 +41,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
setSessions([])
setCronSessions([])
setMessagingSessions([])
$stalledSessionIds.set([])
setSessionsLoading(true)
$gatewaySwitching.set(false)
})
@ -50,6 +53,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
expect($sessionsTotal.get()).toBe(0)
expect($cronSessions.get()).toEqual([])
expect($messagingSessions.get()).toEqual([])
expect($stalledSessionIds.get()).toEqual([])
expect($sessionsLoading.get()).toBe(true)
expect($sessionsLimit.get()).toBe(SIDEBAR_SESSIONS_PAGE_SIZE)
expect($freshDraftReady.get()).toBe(true)

View file

@ -49,8 +49,8 @@ export function wipeSessionListsForGatewaySwitch(): void {
setMessagingPlatformTotals({})
setMessagingTruncated(false)
// Clearing $sessionStates automatically clears $workingSessionIds and
// $attentionSessionIds (they're computed from it). $unreadFinishedSessionIds
// is separate (transient, not computable) so wipe it explicitly.
// $attentionSessionIds (computed) and $stalledSessionIds (owned beside it).
// $unreadFinishedSessionIds is separate, so wipe it explicitly.
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
setSessionsLoading(true)

View file

@ -45,17 +45,31 @@ import { isSecondaryWindow } from './windows'
export const $sessionStates = atom<Record<string, ClientSessionState>>({})
// --- Watchdog: force-clears busy after 8 min of stream silence -------------
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
// Stored session ids whose authoritative state is still busy, but whose
// runtime has produced no state publish for the watchdog window. Silence is
// not completion: long tool calls can legitimately stay quiet, so this is a
// presentation hint and never mutates the backend-derived busy state.
export const $stalledSessionIds = atom<string[]>([])
type WatchdogClearFn = (runtimeId: string) => void
let watchdogClearFn: WatchdogClearFn | null = null
export function setSessionStalled(storedSessionId: string | null | undefined, stalled: boolean) {
if (!storedSessionId) {
return
}
export function setWatchdogClearFn(fn: WatchdogClearFn | null) {
watchdogClearFn = fn
const current = $stalledSessionIds.get()
const present = current.includes(storedSessionId)
if (stalled && !present) {
$stalledSessionIds.set([...current, storedSessionId])
} else if (!stalled && present) {
$stalledSessionIds.set(current.filter(id => id !== storedSessionId))
}
}
// --- Watchdog: marks busy sessions quiet after 8 min of stream silence -----
export const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
function armWatchdog(runtimeId: string) {
const existing = sessionWatchdogTimers.get(runtimeId)
@ -67,7 +81,11 @@ function armWatchdog(runtimeId: string) {
runtimeId,
setTimeout(() => {
sessionWatchdogTimers.delete(runtimeId)
watchdogClearFn?.(runtimeId)
const current = $sessionStates.get()[runtimeId]
if (current?.busy) {
setSessionStalled(current.storedSessionId, true)
}
}, SESSION_WATCHDOG_TIMEOUT_MS)
)
}
@ -125,13 +143,19 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi
}
clearSettled(previous.storedSessionId)
setSessionStalled(previous.storedSessionId, false)
}
// Watchdog: arm on any busy publish, disarm on idle.
// Every busy publish is stream activity: clear the quiet hint and restart
// the silence window. A real terminal transition clears both the timer and
// any hint, but only that authoritative transition clears working/busy.
if (next.busy) {
setSessionStalled(next.storedSessionId, false)
armWatchdog(runtimeId)
} else {
clearWatchdog(runtimeId)
setSessionStalled(next.storedSessionId, false)
setSessionStalled(previous?.storedSessionId, false)
}
const storedId = next.storedSessionId
@ -175,6 +199,7 @@ export function dropSessionState(runtimeId: string) {
clearWatchdog(runtimeId)
const current = $sessionStates.get()
setSessionStalled(current[runtimeId]?.storedSessionId, false)
if (!(runtimeId in current)) {
return
@ -196,6 +221,7 @@ export function clearAllSessionStates() {
sessionWatchdogTimers.clear()
settledExpiry.clear()
$stalledSessionIds.set([])
$sessionStates.set({})
}

View file

@ -6,12 +6,11 @@ import { createClientSessionState } from '@/lib/chat-runtime'
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds } from './session'
import {
$attentionSessionIds,
$sessionStates,
$stalledSessionIds,
$workingSessionIds,
clearAllSessionStates,
getRecentlySettledSessionIds,
publishSessionState,
setWatchdogClearFn
publishSessionState
} from './session-states'
const WATCHDOG_MS = 8 * 60 * 1000
@ -24,8 +23,6 @@ describe('session status transitions', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)
// clearAllSessionStates also disarms watchdog timers + drops settle-grace
// entries, so no leftover state can leak in from a previous test.
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$selectedStoredSessionId.set(null)
@ -45,25 +42,18 @@ describe('session status transitions', () => {
const s = state({ busy: false, storedSessionId: 's1' })
publishSessionState('rt1', s)
// idle → working
const next = { ...s, busy: true }
publishSessionState('rt1', next)
publishSessionState('rt1', { ...s, busy: true })
expect($workingSessionIds.get()).toContain('s1')
})
it('removes a session from $workingSessionIds when busy transitions to false', () => {
const s = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', s)
// Simulate the working state being set
const working = { ...s, busy: true }
const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
expect($workingSessionIds.get()).toContain('s1')
// Now transition to idle
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
publishSessionState('rt1', { ...working, busy: false })
expect($workingSessionIds.get()).not.toContain('s1')
})
@ -72,77 +62,63 @@ describe('session status transitions', () => {
const s = state({ busy: true, needsInput: false, storedSessionId: 's1' })
publishSessionState('rt1', s)
const next = { ...s, needsInput: true }
publishSessionState('rt1', next)
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)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
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)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
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')
const idle = state({ busy: false, storedSessionId: 's1' })
publishSessionState('rt1', idle)
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)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
publishSessionState('rt1', { ...working, busy: false })
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
})
it('does not grant grace on idle→idle re-asserts', () => {
const idle = state({ busy: false, storedSessionId: 's1' })
publishSessionState('rt1', idle)
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'])
// New turn for the same session
const workingAgain = { ...idle, busy: true }
publishSessionState('rt1', workingAgain)
publishSessionState('rt1', { ...idle, busy: true })
expect(getRecentlySettledSessionIds()).toEqual([])
})
@ -166,67 +142,61 @@ describe('session watchdog', () => {
$activeSessionId.set(null)
})
it('drops a stuck session from $workingSessionIds once the silence window elapses', () => {
// Wire a clear fn like use-session-state-cache does in the real app: the
// watchdog hands us the runtime id, we publish the busy:false state.
const clearedRuntimeIds: string[] = []
setWatchdogClearFn(runtimeId => {
clearedRuntimeIds.push(runtimeId)
const current = $sessionStates.get()[runtimeId]
it('marks a silent session stalled without pretending it finished', () => {
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
if (current) {
publishSessionState(runtimeId, { ...current, busy: false, needsInput: false })
}
})
const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
vi.advanceTimersByTime(WATCHDOG_MS)
expect($workingSessionIds.get()).toContain('s1')
// Watchdog fires after 8 min of silence → the wired clear fn runs and the
// computed working set drops the session. This asserts the timer→callback
// wiring, not just the projection.
vi.advanceTimersByTime(WATCHDOG_MS)
expect(clearedRuntimeIds).toEqual(['rt1'])
expect($workingSessionIds.get()).not.toContain('s1')
setWatchdogClearFn(null)
expect($stalledSessionIds.get()).toContain('s1')
})
it('never fires for a session that settles before the window', () => {
const clearedRuntimeIds: string[] = []
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
it('clears stalled on new activity and rearms the watchdog', () => {
const working = state({ busy: true, storedSessionId: 's2' })
publishSessionState('rt2', working)
// Session settles before the watchdog window
const idle = { ...working, busy: false }
publishSessionState('rt2', idle)
vi.advanceTimersByTime(WATCHDOG_MS)
expect($stalledSessionIds.get()).toContain('s2')
// The watchdog was disarmed — the clear fn never ran.
expect(clearedRuntimeIds).toEqual([])
expect($workingSessionIds.get()).not.toContain('s2')
publishSessionState('rt2', { ...working, awaitingResponse: true })
expect($stalledSessionIds.get()).not.toContain('s2')
setWatchdogClearFn(null)
vi.advanceTimersByTime(WATCHDOG_MS - 1)
expect($stalledSessionIds.get()).not.toContain('s2')
expect($workingSessionIds.get()).toContain('s2')
})
it('does not fire after clearAllSessionStates disarms every timer', () => {
const clearedRuntimeIds: string[] = []
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
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('rt1', state({ busy: true, storedSessionId: 's1' }))
clearAllSessionStates()
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(clearedRuntimeIds).toEqual([])
expect($workingSessionIds.get()).not.toContain('s4')
expect($stalledSessionIds.get()).not.toContain('s4')
})
setWatchdogClearFn(null)
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([])
})
})