refactor(desktop): subagent overlay reads like a live transcript, not a dashboard
Strip the card chrome and rewire /agents to feel like peeking into the child agent's stream: - subagents store: single `stream` of typed entries (thinking/tool/progress/ summary) replaces the parallel notes/thinking/tools arrays. Drop unused fields (toolsets, depth, apiCalls, reasoningTokens, sessionId). - agents view: no OverlayCards, no boxed stream, no per-row borders. Goal + status pill + indented stream lines, full row width. - Group root spawns into "Delegation N" sections when batch shape + spawn time match — hides task-index interleaving and makes hierarchy obvious. - Sort tree by spawn time, then task_index. Step indicator is one colored pill (primary while running, emerald when done) inside the row, not a trailing pill that wrapped under the chevron. - Tree picks up `subagent.start` (not only `spawn_requested`) and prunes delegate-tool fallback rows once native subagent events land for the session — fixes duplicate "Delegated task" rows alongside the real ones.
This commit is contained in:
@@ -1,54 +1,96 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $subagentsBySession, activeSubagentCount, buildSubagentTree, clearSessionSubagents, upsertSubagent } from './subagents'
|
||||
import {
|
||||
$subagentsBySession,
|
||||
activeSubagentCount,
|
||||
buildSubagentTree,
|
||||
clearSessionSubagents,
|
||||
pruneDelegateFallbackSubagents,
|
||||
upsertSubagent
|
||||
} from './subagents'
|
||||
|
||||
const listFor = (sid: string) => $subagentsBySession.get()[sid] ?? []
|
||||
|
||||
describe('subagent store', () => {
|
||||
beforeEach(() => {
|
||||
$subagentsBySession.set({})
|
||||
})
|
||||
beforeEach(() => $subagentsBySession.set({}))
|
||||
|
||||
it('upserts subagent progress and keeps terminal status stable', () => {
|
||||
upsertSubagent('s1', {
|
||||
goal: 'scan files',
|
||||
status: 'running',
|
||||
subagent_id: 'a1',
|
||||
task_index: 0
|
||||
})
|
||||
upsertSubagent('s1', {
|
||||
goal: 'scan files',
|
||||
status: 'completed',
|
||||
subagent_id: 'a1',
|
||||
summary: 'done',
|
||||
task_index: 0
|
||||
})
|
||||
upsertSubagent('s1', {
|
||||
goal: 'scan files',
|
||||
status: 'running',
|
||||
subagent_id: 'a1',
|
||||
task_index: 0,
|
||||
text: 'late'
|
||||
})
|
||||
upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 'a1', task_index: 0 })
|
||||
upsertSubagent('s1', { goal: 'scan files', status: 'completed', subagent_id: 'a1', summary: 'done', task_index: 0 })
|
||||
upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 'a1', task_index: 0, text: 'late' })
|
||||
|
||||
const item = $subagentsBySession.get().s1?.[0]
|
||||
const item = listFor('s1')[0]
|
||||
expect(item?.status).toBe('completed')
|
||||
expect(item?.summary).toBe('done')
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const tree = buildSubagentTree($subagentsBySession.get().s1 ?? [])
|
||||
upsertSubagent('s1', { goal: 'child', parent_id: 'p', status: 'queued', subagent_id: 'c', task_index: 1 })
|
||||
|
||||
const tree = buildSubagentTree(listFor('s1'))
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0]?.children[0]?.goal).toBe('child')
|
||||
expect(activeSubagentCount($subagentsBySession.get().s1 ?? [])).toBe(2)
|
||||
expect(activeSubagentCount(listFor('s1'))).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps root nodes in spawn order, not task index order', () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now')
|
||||
nowSpy.mockReturnValueOnce(1_000)
|
||||
upsertSubagent('s1', { goal: 'first spawn', status: 'running', subagent_id: 'a', task_index: 2 })
|
||||
nowSpy.mockReturnValueOnce(2_000)
|
||||
upsertSubagent('s1', { goal: 'second spawn', status: 'running', subagent_id: 'b', task_index: 0 })
|
||||
nowSpy.mockRestore()
|
||||
|
||||
expect(buildSubagentTree(listFor('s1')).map(n => n.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('captures live thinking/progress/tool stream lines', () => {
|
||||
upsertSubagent(
|
||||
's1',
|
||||
{ goal: 'scan files', status: 'queued', subagent_id: 'a1', task_index: 0 },
|
||||
true,
|
||||
'subagent.spawn_requested'
|
||||
)
|
||||
upsertSubagent(
|
||||
's1',
|
||||
{ status: 'running', subagent_id: 'a1', task_index: 0, tool_name: 'search_files', tool_preview: 'pattern=hermes' },
|
||||
false,
|
||||
'subagent.tool'
|
||||
)
|
||||
upsertSubagent(
|
||||
's1',
|
||||
{ status: 'running', subagent_id: 'a1', task_index: 0, text: 'plan the search order' },
|
||||
false,
|
||||
'subagent.thinking'
|
||||
)
|
||||
upsertSubagent(
|
||||
's1',
|
||||
{ status: 'running', subagent_id: 'a1', task_index: 0, text: 'found candidate matches' },
|
||||
false,
|
||||
'subagent.progress'
|
||||
)
|
||||
upsertSubagent(
|
||||
's1',
|
||||
{ status: 'completed', subagent_id: 'a1', summary: 'search complete', task_index: 0 },
|
||||
false,
|
||||
'subagent.complete'
|
||||
)
|
||||
|
||||
const item = listFor('s1')[0]
|
||||
expect(item?.stream.map(e => e.kind)).toEqual(['tool', 'thinking', 'progress', 'summary'])
|
||||
expect(item?.stream.find(e => e.kind === 'tool')?.text).toContain('Search Files')
|
||||
expect(item?.stream.find(e => e.kind === 'thinking')?.text).toBe('plan the search order')
|
||||
expect(item?.stream.find(e => e.kind === 'summary')?.text).toBe('search complete')
|
||||
})
|
||||
|
||||
it('prunes delegate fallback rows once native events arrive', () => {
|
||||
upsertSubagent('s1', { goal: 'fallback', status: 'running', subagent_id: 'delegate-tool:abc:0', task_index: 0 })
|
||||
upsertSubagent('s1', { goal: 'native', status: 'running', subagent_id: 'sa-0-xyz', task_index: 0 })
|
||||
|
||||
pruneDelegateFallbackSubagents('s1')
|
||||
|
||||
expect(listFor('s1').map(item => item.id)).toEqual(['sa-0-xyz'])
|
||||
})
|
||||
|
||||
it('clears one session without touching another', () => {
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export type SubagentStatus = 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
|
||||
export type SubagentStreamKind = 'progress' | 'summary' | 'thinking' | 'tool'
|
||||
|
||||
export interface SubagentStreamEntry {
|
||||
at: number
|
||||
isError?: boolean
|
||||
kind: SubagentStreamKind
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SubagentProgress {
|
||||
id: string
|
||||
apiCalls?: number
|
||||
costUsd?: number
|
||||
depth: number
|
||||
durationSeconds?: number
|
||||
filesRead: string[]
|
||||
filesWritten: string[]
|
||||
goal: string
|
||||
inputTokens?: number
|
||||
model?: string
|
||||
outputTail: { isError?: boolean; preview?: string; tool?: string }[]
|
||||
outputTokens?: number
|
||||
parentId: null | string
|
||||
reasoningTokens?: number
|
||||
sessionId: string
|
||||
goal: string
|
||||
model?: string
|
||||
status: SubagentStatus
|
||||
summary?: string
|
||||
taskCount: number
|
||||
taskIndex: number
|
||||
toolName?: string
|
||||
toolPreview?: string
|
||||
toolsets: string[]
|
||||
startedAt: number
|
||||
updatedAt: number
|
||||
durationSeconds?: number
|
||||
costUsd?: number
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
toolCount?: number
|
||||
filesRead: string[]
|
||||
filesWritten: string[]
|
||||
stream: SubagentStreamEntry[]
|
||||
summary?: string
|
||||
/** Active tool while running — cleared on terminal status. */
|
||||
currentTool?: string
|
||||
}
|
||||
|
||||
export interface SubagentNode extends SubagentProgress {
|
||||
@@ -34,122 +39,172 @@ export interface SubagentNode extends SubagentProgress {
|
||||
|
||||
export type SubagentPayload = Record<string, unknown>
|
||||
|
||||
const TERMINAL: ReadonlySet<SubagentStatus> = new Set(['completed', 'failed', 'interrupted'])
|
||||
const MAX_STREAM = 24
|
||||
const PREVIEW_MAX = 220
|
||||
const TOOL_PREVIEW_MAX = 96
|
||||
|
||||
export const $subagentsBySession = atom<Record<string, SubagentProgress[]>>({})
|
||||
|
||||
const TERMINAL = new Set<SubagentStatus>(['completed', 'failed', 'interrupted'])
|
||||
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)
|
||||
const strList = (v: unknown) => (Array.isArray(v) ? v.filter(isStr) : [])
|
||||
|
||||
const asString = (value: unknown) => (typeof value === 'string' ? value : '')
|
||||
const asNumber = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
|
||||
const asStatus = (value: unknown): SubagentStatus =>
|
||||
value === 'completed' || value === 'failed' || value === 'interrupted' || value === 'queued' ? value : 'running'
|
||||
const asStatus = (v: unknown): SubagentStatus =>
|
||||
v === 'completed' || v === 'failed' || v === 'interrupted' || v === 'queued' ? v : 'running'
|
||||
|
||||
const asStringList = (value: unknown) => (Array.isArray(value) ? value.map(asString).filter(Boolean) : [])
|
||||
const compact = (text: string, max = PREVIEW_MAX) => {
|
||||
const line = text.replace(/\s+/g, ' ').trim()
|
||||
if (!line) return ''
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
||||
}
|
||||
|
||||
const asOutputTail = (value: unknown): SubagentProgress['outputTail'] =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.map(item => (item && typeof item === 'object' ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item))
|
||||
const toolLabel = (name: string) =>
|
||||
name.split('_').filter(Boolean).map(p => p[0]!.toUpperCase() + p.slice(1)).join(' ') || name
|
||||
|
||||
const formatTool = (name: string, preview = '') => {
|
||||
const snippet = compact(preview, TOOL_PREVIEW_MAX)
|
||||
return snippet ? `${toolLabel(name)}("${snippet}")` : toolLabel(name)
|
||||
}
|
||||
|
||||
interface TailEntry {
|
||||
isError?: boolean
|
||||
preview?: string
|
||||
tool?: string
|
||||
}
|
||||
|
||||
const asTail = (v: unknown): TailEntry[] =>
|
||||
Array.isArray(v)
|
||||
? v
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
.map(item => ({
|
||||
isError: item.is_error === true,
|
||||
preview: asString(item.preview) || undefined,
|
||||
tool: asString(item.tool) || undefined
|
||||
preview: str(item.preview) || undefined,
|
||||
tool: str(item.tool) || undefined
|
||||
}))
|
||||
: []
|
||||
|
||||
function idFor(payload: SubagentPayload) {
|
||||
return (
|
||||
asString(payload.subagent_id) ||
|
||||
`${asString(payload.parent_id) || 'root'}:${asNumber(payload.task_index) ?? 0}:${asString(payload.goal)}`
|
||||
)
|
||||
const idOf = (p: SubagentPayload) =>
|
||||
str(p.subagent_id) || `${str(p.parent_id) || 'root'}:${num(p.task_index) ?? 0}:${str(p.goal)}`
|
||||
|
||||
const appendStream = (stream: SubagentStreamEntry[], entry: SubagentStreamEntry) => {
|
||||
const last = stream.at(-1)
|
||||
if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) return stream
|
||||
|
||||
return [...stream, entry].slice(-MAX_STREAM)
|
||||
}
|
||||
|
||||
function toProgress(sessionId: string, payload: SubagentPayload, previous?: SubagentProgress): SubagentProgress {
|
||||
function streamFromPayload(
|
||||
payload: SubagentPayload,
|
||||
status: SubagentStatus,
|
||||
eventType: string,
|
||||
at: number
|
||||
): SubagentStreamEntry[] {
|
||||
const out: SubagentStreamEntry[] = []
|
||||
const tool = str(payload.tool_name)
|
||||
const preview = str(payload.tool_preview) || str(payload.text)
|
||||
const text = compact(str(payload.text) || preview)
|
||||
|
||||
for (const tail of asTail(payload.output_tail)) {
|
||||
const line = tail.tool ? formatTool(tail.tool, tail.preview ?? '') : compact(tail.preview ?? '')
|
||||
if (line) out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line })
|
||||
}
|
||||
|
||||
if (tool) out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) })
|
||||
|
||||
if (eventType === 'subagent.progress' && text)
|
||||
out.push({ at, isError: !!payload.error, kind: 'progress', text })
|
||||
|
||||
if (eventType === 'subagent.thinking' && text) out.push({ at, kind: 'thinking', text })
|
||||
|
||||
const summary = compact(str(payload.summary) || str(payload.text))
|
||||
if (TERMINAL.has(status) && summary)
|
||||
out.push({ at, isError: status === 'failed', kind: 'summary', text: summary })
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined, eventType = ''): SubagentProgress {
|
||||
const at = Date.now()
|
||||
const status = asStatus(payload.status)
|
||||
const tool = str(payload.tool_name)
|
||||
const stream = streamFromPayload(payload, status, eventType, at).reduce(appendStream, prev?.stream ?? [])
|
||||
const filesRead = strList(payload.files_read)
|
||||
const filesWritten = strList(payload.files_written)
|
||||
|
||||
return {
|
||||
apiCalls: asNumber(payload.api_calls) ?? previous?.apiCalls,
|
||||
costUsd: asNumber(payload.cost_usd) ?? previous?.costUsd,
|
||||
depth: asNumber(payload.depth) ?? previous?.depth ?? 0,
|
||||
durationSeconds: asNumber(payload.duration_seconds) ?? previous?.durationSeconds,
|
||||
filesRead: asStringList(payload.files_read).length ? asStringList(payload.files_read) : (previous?.filesRead ?? []),
|
||||
filesWritten: asStringList(payload.files_written).length
|
||||
? asStringList(payload.files_written)
|
||||
: (previous?.filesWritten ?? []),
|
||||
goal: asString(payload.goal) || previous?.goal || 'Subagent',
|
||||
id: previous?.id || idFor(payload),
|
||||
inputTokens: asNumber(payload.input_tokens) ?? previous?.inputTokens,
|
||||
model: asString(payload.model) || previous?.model,
|
||||
outputTail: asOutputTail(payload.output_tail).length ? asOutputTail(payload.output_tail) : (previous?.outputTail ?? []),
|
||||
outputTokens: asNumber(payload.output_tokens) ?? previous?.outputTokens,
|
||||
parentId: asString(payload.parent_id) || previous?.parentId || null,
|
||||
reasoningTokens: asNumber(payload.reasoning_tokens) ?? previous?.reasoningTokens,
|
||||
sessionId,
|
||||
status: asStatus(payload.status),
|
||||
summary: asString(payload.summary) || previous?.summary,
|
||||
taskCount: asNumber(payload.task_count) ?? previous?.taskCount ?? 1,
|
||||
taskIndex: asNumber(payload.task_index) ?? previous?.taskIndex ?? 0,
|
||||
toolName: asString(payload.tool_name) || previous?.toolName,
|
||||
toolPreview: asString(payload.tool_preview) || asString(payload.text) || previous?.toolPreview,
|
||||
toolsets: asStringList(payload.toolsets).length ? asStringList(payload.toolsets) : (previous?.toolsets ?? []),
|
||||
updatedAt: Date.now()
|
||||
id: prev?.id ?? idOf(payload),
|
||||
parentId: str(payload.parent_id) || prev?.parentId || null,
|
||||
goal: str(payload.goal) || prev?.goal || 'Subagent',
|
||||
model: str(payload.model) || prev?.model,
|
||||
status,
|
||||
taskCount: num(payload.task_count) ?? prev?.taskCount ?? 1,
|
||||
taskIndex: num(payload.task_index) ?? prev?.taskIndex ?? 0,
|
||||
startedAt: prev?.startedAt ?? at,
|
||||
updatedAt: at,
|
||||
durationSeconds: num(payload.duration_seconds) ?? prev?.durationSeconds,
|
||||
costUsd: num(payload.cost_usd) ?? prev?.costUsd,
|
||||
inputTokens: num(payload.input_tokens) ?? prev?.inputTokens,
|
||||
outputTokens: num(payload.output_tokens) ?? prev?.outputTokens,
|
||||
toolCount: num(payload.tool_count) ?? prev?.toolCount,
|
||||
filesRead: filesRead.length ? filesRead : (prev?.filesRead ?? []),
|
||||
filesWritten: filesWritten.length ? filesWritten : (prev?.filesWritten ?? []),
|
||||
stream,
|
||||
summary: str(payload.summary) || prev?.summary,
|
||||
currentTool: TERMINAL.has(status) ? undefined : tool || prev?.currentTool
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSessionSubagents(sessionId: string) {
|
||||
const current = $subagentsBySession.get()
|
||||
export function clearSessionSubagents(sid: string) {
|
||||
const map = $subagentsBySession.get()
|
||||
if (!(sid in map)) return
|
||||
|
||||
if (!(sessionId in current)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...current }
|
||||
delete next[sessionId]
|
||||
$subagentsBySession.set(next)
|
||||
const { [sid]: _drop, ...rest } = map
|
||||
$subagentsBySession.set(rest)
|
||||
}
|
||||
|
||||
export function upsertSubagent(sessionId: string, payload: SubagentPayload, createIfMissing = true) {
|
||||
const current = $subagentsBySession.get()
|
||||
const list = current[sessionId] ?? []
|
||||
const id = idFor(payload)
|
||||
const index = list.findIndex(item => item.id === id)
|
||||
export function pruneDelegateFallbackSubagents(sid: string) {
|
||||
const map = $subagentsBySession.get()
|
||||
const list = map[sid]
|
||||
if (!list?.length) return
|
||||
|
||||
if (index < 0 && !createIfMissing) {
|
||||
return
|
||||
}
|
||||
const next = list.filter(item => !item.id.startsWith('delegate-tool:'))
|
||||
if (next.length === list.length) return
|
||||
|
||||
const previous = index >= 0 ? list[index] : undefined
|
||||
$subagentsBySession.set({ ...map, [sid]: next })
|
||||
}
|
||||
|
||||
if (previous && TERMINAL.has(previous.status)) {
|
||||
return
|
||||
}
|
||||
export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMissing = true, eventType?: string) {
|
||||
const map = $subagentsBySession.get()
|
||||
const list = map[sid] ?? []
|
||||
const id = idOf(payload)
|
||||
const idx = list.findIndex(item => item.id === id)
|
||||
if (idx < 0 && !createIfMissing) return
|
||||
|
||||
const nextItem = toProgress(sessionId, payload, previous)
|
||||
const nextList = index >= 0 ? list.map(item => (item.id === id ? nextItem : item)) : [...list, nextItem]
|
||||
const prev = idx >= 0 ? list[idx] : undefined
|
||||
if (prev && TERMINAL.has(prev.status)) return
|
||||
|
||||
$subagentsBySession.set({ ...current, [sessionId]: nextList })
|
||||
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 })
|
||||
}
|
||||
|
||||
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
|
||||
const nodes = new Map<string, SubagentNode>()
|
||||
|
||||
for (const item of items) {
|
||||
nodes.set(item.id, { ...item, children: [] })
|
||||
}
|
||||
for (const item of items) nodes.set(item.id, { ...item, children: [] })
|
||||
|
||||
const roots: SubagentNode[] = []
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
const parent = node.parentId ? nodes.get(node.parentId) : null
|
||||
|
||||
if (parent) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
if (parent) parent.children.push(node)
|
||||
else roots.push(node)
|
||||
}
|
||||
|
||||
const sort = (a: SubagentNode, b: SubagentNode) => a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal)
|
||||
const sort = (a: SubagentNode, b: SubagentNode) =>
|
||||
a.startedAt - b.startedAt || a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal)
|
||||
const walk = (node: SubagentNode) => node.children.sort(sort).forEach(walk)
|
||||
|
||||
roots.sort(sort).forEach(walk)
|
||||
|
||||
return roots
|
||||
|
||||
Reference in New Issue
Block a user