feat(desktop): add $backgroundResume store for parked delegate_task

Track top-level delegate_task work that dispatches in the background and
re-enters as a fresh turn. $backgroundResume returns {count, activity} for
the active session while idle — count of parked tasks plus the primary
child's latest stream line (tool/progress/thinking) when readable.
This commit is contained in:
Brooklyn Nicholson 2026-06-25 19:57:45 -05:00
parent 55af6c447a
commit 6e096a850a
2 changed files with 113 additions and 0 deletions

View file

@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $backgroundResume } from './background-delegation'
import { $activeSessionId, $busy } from './session'
import { $subagentsBySession, type SubagentProgress, type SubagentStreamEntry } from './subagents'
const sub = (over: Partial<SubagentProgress> = {}): 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
})
const stream = (text: string): SubagentStreamEntry => ({ at: 0, kind: 'progress', text })
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()
})
})

View file

@ -0,0 +1,48 @@
import { computed } from 'nanostores'
import { $activeSessionId, $busy } from './session'
import { $subagentsBySession, type SubagentProgress } 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. */
count: number
}
const RUNNING = (s: SubagentProgress) => s.status === 'running' || s.status === 'queued'
/**
* "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 }
}
)