test(desktop): pin the sidebar spinner's liveness contract

Investigating the missing-spinner report turned up no defect in the seeding
path: the active_list poll already lights a row for a turn the renderer never
saw start, holds it across polls, and follows a recycled runtime id onto its
new stored session. Pin all three so the reap change can't silently regress
turn-start while fixing turn-end.

Two boundaries worth naming rather than rediscovering:

- `starting` is deliberately NOT working. It means agent_build_started without
  agent_ready, and _start_agent_build runs on any incidental RPC that needs the
  agent — not just a prompt — so treating it as a turn would spin the row on
  merely opening a session.
- $workingSessionIds is keyed by STORED id and drops entries whose
  storedSessionId is null, while message.start flips busy without carrying one.
  A runtime that was never seeded with a stored id therefore goes busy
  invisibly. That is the remaining path by which a spinner can go missing.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 19:02:46 -05:00
parent 6b273f419a
commit 19d84d1071
2 changed files with 109 additions and 0 deletions

View file

@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $selectedStoredSessionId, $unreadFinishedSessionIds } from '@/store/session'
import { $workingSessionIds, clearAllSessionStates } from '@/store/session-states'
import { rehydrateLiveSessionStatuses, resetLiveRuntimeTracking } from './use-background-sync'
/**
* (C) The sidebar spinner is driven by `$workingSessionIds`, which is keyed by
* STORED session id. A turn that STARTS while Desktop isn't receiving stream
* events a background profile, a degraded remote socket, a session opened on
* another surface is only ever learned about through the `session.active_list`
* poll. If that poll can't seed a row the renderer has never seen, the thread
* name never gets its arc even though the backend is plainly working.
*/
describe('rehydrateLiveSessionStatuses — seeding a turn the renderer never saw start', () => {
beforeEach(() => {
vi.useFakeTimers()
$selectedStoredSessionId.set(null)
$unreadFinishedSessionIds.set([])
resetLiveRuntimeTracking()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
clearAllSessionStates()
resetLiveRuntimeTracking()
$unreadFinishedSessionIds.set([])
})
it('shows the spinner for a turn that started with no stream events', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
})
expect($workingSessionIds.get()).toContain('stored-cold')
})
it('keeps the spinner across polls while the turn is still running', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
})
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
})
expect($workingSessionIds.get()).toContain('stored-cold')
})
it('shows the spinner when a runtime id is recycled onto a new stored session', () => {
// A respawned backend can mint the same runtime id for a different stored
// session. The row for the NEW stored id must light up, not the stale one.
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-1', session_key: 'stored-old', status: 'working' }]
})
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-1', session_key: 'stored-new', status: 'working' }]
})
expect($workingSessionIds.get()).toContain('stored-new')
expect($workingSessionIds.get()).not.toContain('stored-old')
})
it('leaves a starting session idle — the agent build is not proof of a turn', () => {
// `starting` = `agent_build_started` without `agent_ready`. _start_agent_build
// runs on the first prompt OR any incidental RPC that needs the agent, so it
// is not proof of a turn — lighting the spinner here would fire on merely
// opening a session. A real turn arrives as `working`.
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-boot', session_key: 'stored-boot', status: 'starting' }]
})
expect($workingSessionIds.get()).not.toContain('stored-boot')
})
})

View file

@ -0,0 +1,32 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $workingSessionIds, clearAllSessionStates, publishSessionState } from '@/store/session-states'
/**
* (C) The sidebar spinner reads `$workingSessionIds`, which projects
* `$sessionStates` down to STORED session ids and drops any entry whose
* `storedSessionId` is null. `message.start` flips `busy` without carrying a
* stored id, so a runtime that was never seeded with one goes busy invisibly:
* the backend works, the thread name stays bare.
*/
describe('$workingSessionIds — a busy runtime with no stored id', () => {
afterEach(() => {
clearAllSessionStates()
})
it('cannot show a spinner for a busy runtime that has no stored id', () => {
publishSessionState('runtime-unmapped', { ...createClientSessionState(null), busy: true })
// Documents the constraint rather than asserting the bug is fine: the
// projection is keyed by stored id, so an unmapped runtime is unreachable
// from the sidebar no matter how busy it is.
expect($workingSessionIds.get()).toEqual([])
})
it('shows the spinner as soon as the stored id is known', () => {
publishSessionState('runtime-mapped', { ...createClientSessionState('stored-x'), busy: true })
expect($workingSessionIds.get()).toEqual(['stored-x'])
})
})