diff --git a/apps/desktop/src/components/assistant-ui/thread/background-resume.test.tsx b/apps/desktop/src/components/assistant-ui/thread/background-resume.test.tsx new file mode 100644 index 0000000000..2346930d8d --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/background-resume.test.tsx @@ -0,0 +1,42 @@ +import { act, cleanup, render } from '@testing-library/react' +import { atom } from 'nanostores' +import { afterEach, expect, it } from 'vitest' + +import { PRIMARY_SESSION_VIEW, SessionViewProvider } from '@/app/chat/session-view' +import { $activeSessionId, $busy } from '@/store/session' +import { $subagentsBySession, upsertSubagent } from '@/store/subagents' + +import { BackgroundResumeNotice } from './status' + +afterEach(() => { + cleanup() + $activeSessionId.set(null) + $busy.set(false) + $subagentsBySession.set({}) +}) + +it('keeps background waiting local to its idle thread without echoing child thinking chrome', () => { + $activeSessionId.set('other') + $busy.set(true) + upsertSubagent('owner', { subagent_id: 'child', goal: 'Check relevance', status: 'running' }) + upsertSubagent('owner', { subagent_id: 'child', text: '(°□°) pondering...' }, false, 'subagent.thinking') + const $ownerBusy = atom(false) + const view = { ...PRIMARY_SESSION_VIEW, $runtimeId: atom('owner'), $busy: $ownerBusy } + + const { container } = render( + + + + ) + + expect(container.querySelector('[role="status"]')).toBeTruthy() + expect(container.textContent).not.toContain('pondering') + expect(container.querySelector('.shimmer')).toBeNull() + + act(() => $ownerBusy.set(true)) + expect(container.querySelector('[role="status"]')).toBeNull() + act(() => $ownerBusy.set(false)) + expect(container.querySelector('[role="status"]')).toBeTruthy() + act(() => upsertSubagent('owner', { subagent_id: 'child', status: 'completed' }, true, 'subagent.complete')) + expect(container.querySelector('[role="status"]')).toBeNull() +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index c3f40ac255..9f7fd9b249 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -14,7 +14,7 @@ import { StatusPulse } from '@/components/ui/status-pulse' import { getLocalModelsStatus } from '@/hermes' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' -import { $backgroundResume } from '@/store/background-delegation' +import { sessionBackgroundResume } from '@/store/background-delegation' import { sessionCompacting } from '@/store/compaction' import { $localModelsEnabled } from '@/store/local-models-flag' import { sessionAwaitingInput } from '@/store/prompts' @@ -267,33 +267,26 @@ export const ResponseLoadingIndicator: FC = () => { ) } -// Parked-background affordance: a top-level delegate_task runs in the -// background, so the parent turn ends and the app goes idle while the subagent -// keeps working and its result re-enters as a fresh turn later. Instead of a -// spinner (reads as "stuck"), reuse the same compact, centered system-note -// chrome as the steer / slash-status lines (SystemMessage above) so it sits in -// the thread like every other meta line. Idle-only (gated upstream). Null when -// nothing is parked. +// The parent is idle while its delegated children work. Name that wait rather +// than echoing the child's CLI thinking spinner as if this thread were running. export const BackgroundResumeNotice: FC = () => { const { t } = useI18n() - const resume = useStore($backgroundResume) + const view = useSessionView() + const sessionId = useStore(view.$runtimeId) + const busy = useStore(view.$busy) + const resume = useStore(useMemo(() => sessionBackgroundResume(sessionId), [sessionId])) - if (!resume) { + if (busy || !resume) { return null } - const label = resume.activity ?? t.assistant.thread.resumeWhenBackgroundDone(resume.count) + const label = t.assistant.thread.resumeWhenBackgroundDone(resume.count) return ( -
- - {label} -
+ + + {label} + ) } diff --git a/apps/desktop/src/store/background-delegation.test.ts b/apps/desktop/src/store/background-delegation.test.ts index cd918c681d..0c8948e2ea 100644 --- a/apps/desktop/src/store/background-delegation.test.ts +++ b/apps/desktop/src/store/background-delegation.test.ts @@ -1,65 +1,34 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, expect, it } from 'vitest' -import { $backgroundResume } from './background-delegation' +import { sessionBackgroundResume } from './background-delegation' import { $activeSessionId, $busy } from './session' -import { $subagentsBySession, type SubagentProgress, type SubagentStreamEntry } from './subagents' +import { $subagentsBySession, upsertSubagent } from './subagents' -const sub = (over: Partial = {}): SubagentProgress => ({ - id: over.id ?? 'deleg:1', - parentId: null, - goal: 'do the thing', - status: 'running', - taskCount: 1, - taskIndex: 0, - startedAt: 0, - updatedAt: 0, - filesRead: [], - filesWritten: [], - stream: [], - ...over +afterEach(() => { + $busy.set(false) + $activeSessionId.set(null) + $subagentsBySession.set({}) }) -const stream = (text: string): SubagentStreamEntry => ({ at: 0, kind: 'progress', text }) +it('tracks only the owning runtime session, independent of foreground busy state and child stream text', () => { + const owner = sessionBackgroundResume('owner') + const other = sessionBackgroundResume('other') + const unbound = sessionBackgroundResume(null) + upsertSubagent('owner', { subagent_id: 'working', status: 'running' }, true, 'subagent.start') + upsertSubagent('owner', { subagent_id: 'waiting', status: 'queued' }, true, 'subagent.spawn_requested') + upsertSubagent('other', { subagent_id: 'working', status: 'running' }, true, 'subagent.start') + $activeSessionId.set('other') + $busy.set(true) + upsertSubagent('owner', { subagent_id: 'working', text: '(°□°) pondering...' }, false, 'subagent.thinking') + expect(owner.get()).toEqual({ count: 2 }) + expect(other.get()).toEqual({ count: 1 }) + expect(unbound.get()).toBeNull() -describe('$backgroundResume', () => { - beforeEach(() => { - $busy.set(false) - $activeSessionId.set('s1') - $subagentsBySession.set({}) - }) - - it('counts running/queued children for the active session while idle', () => { - $subagentsBySession.set({ s1: [sub({ id: 'a' }), sub({ id: 'b', status: 'queued' })] }) - expect($backgroundResume.get()?.count).toBe(2) - }) - - it('surfaces the primary child latest stream line as live activity', () => { - $subagentsBySession.set({ s1: [sub({ id: 'a', stream: [stream('Searching the web…')] })] }) - expect($backgroundResume.get()?.activity).toBe('Searching the web…') - }) - - it('activity is null when no stream line has arrived (UI uses generic copy)', () => { - $subagentsBySession.set({ s1: [sub({ id: 'a' })] }) - expect($backgroundResume.get()?.activity).toBeNull() - }) - - it('is null while a turn is busy (the turn owns the main loader)', () => { - $subagentsBySession.set({ s1: [sub({ id: 'a' })] }) - $busy.set(true) - expect($backgroundResume.get()).toBeNull() - }) - - it('is null when only terminal children or other sessions have work', () => { - $subagentsBySession.set({ - s1: [sub({ id: 'a', status: 'completed' }), sub({ id: 'b', status: 'failed' })], - s2: [sub({ id: 'c' })] - }) - expect($backgroundResume.get()).toBeNull() - }) - - it('is null when there is no active session', () => { - $subagentsBySession.set({ s1: [sub({ id: 'a' })] }) - $activeSessionId.set(null) - expect($backgroundResume.get()).toBeNull() - }) + upsertSubagent('owner', { subagent_id: 'working', status: 'completed' }, false, 'subagent.complete') + expect(owner.get()).toEqual({ count: 1 }) + upsertSubagent('owner', { subagent_id: 'waiting', status: 'timeout' }, false, 'subagent.complete') + $activeSessionId.set(null) + $busy.set(false) + expect(owner.get()).toBeNull() + expect(other.get()).toEqual({ count: 1 }) }) diff --git a/apps/desktop/src/store/background-delegation.ts b/apps/desktop/src/store/background-delegation.ts index 72819cb74d..7698769c42 100644 --- a/apps/desktop/src/store/background-delegation.ts +++ b/apps/desktop/src/store/background-delegation.ts @@ -1,48 +1,19 @@ import { computed } from 'nanostores' -import { $activeSessionId, $busy } from './session' -import { $subagentsBySession, type SubagentProgress } from './subagents' +import { $subagentsBySession, activeSubagentCount } from './subagents' export interface BackgroundResume { - /** Latest live activity from the primary child (its newest stream line), or - * null when nothing readable has arrived yet — the UI then falls back to the - * generic "will resume" copy. */ - activity: string | null - /** Running/queued background children for the active session. */ + /** Running/queued background children belonging to this runtime session. */ count: number } -const RUNNING = (s: SubagentProgress) => s.status === 'running' || s.status === 'queued' +/** Background work outlives its spawning turn. The owning view gates this + * signal on its own busy state; global focus and raw stream text are unrelated. */ +export function sessionBackgroundResume(sessionId: null | string) { + const $count = computed($subagentsBySession, bySession => + sessionId ? activeSubagentCount(bySession[sessionId] ?? []) : 0 + ) -/** - * "Parked" background-delegation signal for the active session. - * - * A top-level `delegate_task` always runs in the background: the parent turn - * ends (`$busy` -> false) while the subagent keeps running, and its result - * re-enters the conversation as a fresh turn when it finishes. During that - * window the app is genuinely idle but work is still happening elsewhere, so we - * surface a calm, shimmering status line (its latest activity, or a generic - * "will resume" fallback) instead of a spinner that reads as "stuck." - * - * Null while `$busy`: an active turn already owns the main loader, and subagents - * spawned inside a running turn (synchronous orchestrator children) are part of - * that turn, not parked background work the user is waiting on. - */ -export const $backgroundResume = computed( - [$subagentsBySession, $activeSessionId, $busy], - (bySession, sid, busy): BackgroundResume | null => { - if (busy || !sid) { - return null - } - - const running = (bySession[sid] ?? []).filter(RUNNING) - - if (running.length === 0) { - return null - } - - const activity = (running[0]!.stream.at(-1)?.text ?? '').trim() || null - - return { activity, count: running.length } - } -) + // Stream frames must not repaint a count-only notice. + return computed($count, (count): BackgroundResume | null => (count ? { count } : null)) +} diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts index 74a604a026..a5054d246e 100644 --- a/apps/desktop/src/store/subagents.test.ts +++ b/apps/desktop/src/store/subagents.test.ts @@ -9,6 +9,7 @@ import { failedSubagentCount, pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, + reconcileSubagentSnapshot, upsertSubagent } from './subagents' @@ -27,6 +28,39 @@ describe('subagent store', () => { expect(item?.summary).toBe('done') }) + it('keeps completed children retired across turn pruning, late frames, and roster refreshes', () => { + const finished = { subagent_id: 'finished', goal: 'Finished task', status: 'running' } + const live = { subagent_id: 'live', goal: 'Background task', status: 'queued' } + upsertSubagent('owner', finished, true, 'subagent.start') + upsertSubagent('owner', live, true, 'subagent.spawn_requested') + upsertSubagent('owner', { ...finished, status: 'completed', summary: 'Done' }, false, 'subagent.complete') + upsertSubagent('owner', { ...finished, text: '(°□°) pondering...' }, false, 'subagent.thinking') + expect(listFor('owner')[0]?.status).toBe('completed') + + // A completion starts a new parent turn before every delayed child frame + // or roster read has drained. Pruning is presentation, not a new child run. + pruneFinishedSessionSubagents('owner') + const pruned = listFor('owner') + reconcileSubagentSnapshot('owner', [finished, live]) + upsertSubagent('owner', finished, true, 'subagent.start') + upsertSubagent('owner', { ...finished, text: '(°□°) pondering...' }, false, 'subagent.thinking') + expect(listFor('owner')).toBe(pruned) + expect(listFor('owner').map(item => item.id)).toEqual(['live']) + + // Roster-discovered terminal state has the same authority as an event. + reconcileSubagentSnapshot('owner', [{ ...live, status: 'interrupted' }]) + pruneFinishedSessionSubagents('owner') + reconcileSubagentSnapshot('owner', [finished, live]) + expect(activeSubagentCount(listFor('owner'))).toBe(0) + + // Retirement is scoped to this runtime session, not an ID-global ban. + upsertSubagent('other', finished, true, 'subagent.start') + expect(activeSubagentCount(listFor('other'))).toBe(1) + clearSessionSubagents('owner') + upsertSubagent('owner', finished, true, 'subagent.start') + expect(activeSubagentCount(listFor('owner'))).toBe(1) + }) + it('builds parent/child trees', () => { upsertSubagent('s1', { goal: 'parent', status: 'running', subagent_id: 'p', task_index: 0 }) upsertSubagent('s1', { goal: 'child', parent_id: 'p', status: 'queued', subagent_id: 'c', task_index: 1 }) diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts index 4c43c1890e..e855cf4726 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/apps/desktop/src/store/subagents.ts @@ -53,6 +53,27 @@ const TOOL_PREVIEW_MAX = 96 export const $subagentsBySession = atom>({}) +// A turn prunes display rows, not child identities. Keep retired IDs with the +// session's current list so late starts/rosters cannot recreate completed work. +// Clearing the session (or resetting the store) releases this history too. +const retiredSubagents = new WeakMap>() + +function setSessionSubagents(sid: string, previous: SubagentProgress[], next: SubagentProgress[]) { + const retired = retiredSubagents.get(previous) ?? new Set() + + for (const item of previous) { + if (TERMINAL.has(item.status)) { + retired.add(item.id) + } + } + + if (retired.size) { + retiredSubagents.set(next, retired) + } + + $subagentsBySession.set({ ...$subagentsBySession.get(), [sid]: next }) +} + const isStr = (v: unknown): v is string => typeof v === 'string' const str = (v: unknown) => (isStr(v) ? v : '') const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) @@ -222,7 +243,7 @@ export function reconcileSubagentSnapshot(sid: string, children: SubagentPayload for (const payload of children) { const id = str(payload.subagent_id) - if (!id) { + if (!id || retiredSubagents.get(previous)?.has(id)) { continue } @@ -251,7 +272,7 @@ export function reconcileSubagentSnapshot(sid: string, children: SubagentPayload } if (next.length !== previous.length || next.some((item, index) => item !== previous[index])) { - $subagentsBySession.set({ ...map, [sid]: next }) + setSessionSubagents(sid, previous, next) } } @@ -292,7 +313,7 @@ export function pruneFinishedSessionSubagents(sid: string) { return } - $subagentsBySession.set({ ...map, [sid]: next }) + setSessionSubagents(sid, list, next) } export function pruneDelegateFallbackSubagents(sid: string) { @@ -309,7 +330,7 @@ export function pruneDelegateFallbackSubagents(sid: string) { return } - $subagentsBySession.set({ ...map, [sid]: next }) + setSessionSubagents(sid, list, next) } export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMissing = true, eventType?: string) { @@ -318,7 +339,7 @@ export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMi const id = idOf(payload) const idx = list.findIndex(item => item.id === id) - if (idx < 0 && !createIfMissing) { + if (retiredSubagents.get(list)?.has(id) || (idx < 0 && !createIfMissing)) { return } @@ -331,7 +352,7 @@ export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMi const next = toProgress(payload, prev, eventType) const nextList = idx >= 0 ? list.map(item => (item.id === id ? next : item)) : [...list, next] - $subagentsBySession.set({ ...map, [sid]: nextList }) + setSessionSubagents(sid, list, nextList) } export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {