mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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:
parent
55af6c447a
commit
6e096a850a
2 changed files with 113 additions and 0 deletions
65
apps/desktop/src/store/background-delegation.test.ts
Normal file
65
apps/desktop/src/store/background-delegation.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
48
apps/desktop/src/store/background-delegation.ts
Normal file
48
apps/desktop/src/store/background-delegation.ts
Normal 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 }
|
||||
}
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue