From 19d84d10715bf43986cdab9afe445d2a79c1bf04 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 19:02:46 -0500 Subject: [PATCH] test(desktop): pin the sidebar spinner's liveness contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../contrib/hooks/live-status-spinner.test.ts | 77 +++++++++++++++++++ .../src/store/working-ids-stored-id.test.ts | 32 ++++++++ 2 files changed, 109 insertions(+) create mode 100644 apps/desktop/src/app/contrib/hooks/live-status-spinner.test.ts create mode 100644 apps/desktop/src/store/working-ids-stored-id.test.ts diff --git a/apps/desktop/src/app/contrib/hooks/live-status-spinner.test.ts b/apps/desktop/src/app/contrib/hooks/live-status-spinner.test.ts new file mode 100644 index 000000000000..4fcad3b1a2ed --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/live-status-spinner.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/store/working-ids-stored-id.test.ts b/apps/desktop/src/store/working-ids-stored-id.test.ts new file mode 100644 index 000000000000..f302385a4442 --- /dev/null +++ b/apps/desktop/src/store/working-ids-stored-id.test.ts @@ -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']) + }) +})