mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
feat(tui): subagent spawn observability overlay
Adds a live + post-hoc audit surface for recursive delegate_task fan-out. None of cc/oc/oclaw tackle nested subagent trees inside an Ink overlay; this ships a view-switched dashboard that handles arbitrary depth + width. Python - delegate_tool: every subagent event now carries subagent_id, parent_id, depth, model, tool_count; subagent.complete also ships input/output/ reasoning tokens, cost, api_calls, files_read/files_written, and a tail of tool-call outputs - delegate_tool: new subagent.spawn_requested event + _active_subagents registry so the overlay can kill a branch by id and pause new spawns - tui_gateway: new RPCs delegation.status, delegation.pause, subagent.interrupt, spawn_tree.save/list/load (disk under \$HERMES_HOME/spawn-trees/<session>/<ts>.json) TUI - /agents overlay: full-width list mode (gantt strip + row picker) and Enter-to-drill full-width scrollable detail mode; inverse+amber selection, heat-coloured branch markers, wall-clock gantt with tick ruler, per-branch rollups - Detail pane: collapsible accordions (Budget, Files, Tool calls, Output, Progress, Summary); open-state persists across agents + mode switches via a shared atom - /replay [N|last|list|load <path>] for in-memory + disk history; /replay-diff <a> <b> for side-by-side tree comparison - Status-bar SpawnHud warns as depth/concurrency approaches caps; overlay auto-follows the just-finished turn onto history[1] - Theme: bump DARK dim #B8860B → #CC9B1F for readable secondary text globally; keep LIGHT untouched Tests: +29 new subagentTree unit tests; 215/215 passing.
This commit is contained in:
parent
ba7e8b0df9
commit
7785654ad5
19 changed files with 4329 additions and 426 deletions
369
ui-tui/src/__tests__/subagentTree.test.ts
Normal file
369
ui-tui/src/__tests__/subagentTree.test.ts
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSubagentTree,
|
||||
descendantIds,
|
||||
flattenTree,
|
||||
fmtCost,
|
||||
fmtTokens,
|
||||
formatSummary,
|
||||
hotnessBucket,
|
||||
peakHotness,
|
||||
sparkline,
|
||||
treeTotals,
|
||||
widthByDepth
|
||||
} from '../lib/subagentTree.js'
|
||||
import type { SubagentProgress } from '../types.js'
|
||||
|
||||
const makeItem = (overrides: Partial<SubagentProgress> & Pick<SubagentProgress, 'id' | 'index'>): SubagentProgress => ({
|
||||
depth: 0,
|
||||
goal: overrides.id,
|
||||
notes: [],
|
||||
parentId: null,
|
||||
status: 'running',
|
||||
taskCount: 1,
|
||||
thinking: [],
|
||||
toolCount: 0,
|
||||
tools: [],
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('aggregate: tokens, cost, files, hotness', () => {
|
||||
it('sums tokens and cost across subtree', () => {
|
||||
const items = [
|
||||
makeItem({ costUsd: 0.01, id: 'p', index: 0, inputTokens: 1000, outputTokens: 500 }),
|
||||
makeItem({
|
||||
costUsd: 0.005,
|
||||
depth: 1,
|
||||
id: 'c1',
|
||||
index: 0,
|
||||
inputTokens: 500,
|
||||
outputTokens: 100,
|
||||
parentId: 'p'
|
||||
}),
|
||||
makeItem({
|
||||
costUsd: 0.008,
|
||||
depth: 1,
|
||||
id: 'c2',
|
||||
index: 1,
|
||||
inputTokens: 300,
|
||||
outputTokens: 200,
|
||||
parentId: 'p'
|
||||
})
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate).toMatchObject({
|
||||
costUsd: 0.023,
|
||||
inputTokens: 1800,
|
||||
outputTokens: 800
|
||||
})
|
||||
})
|
||||
|
||||
it('counts files read + written across subtree', () => {
|
||||
const items = [
|
||||
makeItem({ filesRead: ['a.ts', 'b.ts'], id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, filesWritten: ['c.ts'], id: 'c', index: 0, parentId: 'p' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.filesTouched).toBe(3)
|
||||
})
|
||||
|
||||
it('hotness = totalTools / totalDuration', () => {
|
||||
const items = [
|
||||
makeItem({
|
||||
durationSeconds: 10,
|
||||
id: 'p',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
toolCount: 20
|
||||
})
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.hotness).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it('hotness is zero when duration is zero', () => {
|
||||
const items = [makeItem({ id: 'p', index: 0, toolCount: 10 })]
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.hotness).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hotnessBucket + peakHotness', () => {
|
||||
it('peakHotness walks subtree', () => {
|
||||
const items = [
|
||||
makeItem({ durationSeconds: 100, id: 'p', index: 0, status: 'completed', toolCount: 1 }),
|
||||
makeItem({
|
||||
depth: 1,
|
||||
durationSeconds: 1,
|
||||
id: 'c',
|
||||
index: 0,
|
||||
parentId: 'p',
|
||||
status: 'completed',
|
||||
toolCount: 5
|
||||
})
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(peakHotness(tree)).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('hotnessBucket clamps and normalizes', () => {
|
||||
expect(hotnessBucket(0, 10, 4)).toBe(0)
|
||||
expect(hotnessBucket(10, 10, 4)).toBe(3)
|
||||
expect(hotnessBucket(5, 10, 4)).toBe(2)
|
||||
expect(hotnessBucket(100, 10, 4)).toBe(3) // clamped
|
||||
expect(hotnessBucket(5, 0, 4)).toBe(0) // guard against divide-by-zero
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtCost + fmtTokens', () => {
|
||||
it('fmtCost handles ranges', () => {
|
||||
expect(fmtCost(0)).toBe('')
|
||||
expect(fmtCost(0.001)).toBe('<$0.01')
|
||||
expect(fmtCost(0.42)).toBe('$0.42')
|
||||
expect(fmtCost(1.23)).toBe('$1.23')
|
||||
expect(fmtCost(12.5)).toBe('$12.5')
|
||||
})
|
||||
|
||||
it('fmtTokens handles ranges', () => {
|
||||
expect(fmtTokens(0)).toBe('0')
|
||||
expect(fmtTokens(542)).toBe('542')
|
||||
expect(fmtTokens(1234)).toBe('1.2k')
|
||||
expect(fmtTokens(45678)).toBe('46k')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSummary with tokens + cost', () => {
|
||||
it('includes token + cost when present', () => {
|
||||
expect(
|
||||
formatSummary({
|
||||
activeCount: 0,
|
||||
costUsd: 0.42,
|
||||
descendantCount: 3,
|
||||
filesTouched: 0,
|
||||
hotness: 0,
|
||||
inputTokens: 8000,
|
||||
maxDepthFromHere: 2,
|
||||
outputTokens: 2000,
|
||||
totalDuration: 30,
|
||||
totalTools: 14
|
||||
})
|
||||
).toBe('d2 · 3 agents · 14 tools · 30s · 10k tok · $0.42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSubagentTree', () => {
|
||||
it('returns empty list for empty input', () => {
|
||||
expect(buildSubagentTree([])).toEqual([])
|
||||
})
|
||||
|
||||
it('treats flat list as top-level when no parentId is given', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 }), makeItem({ id: 'c', index: 2 })]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree).toHaveLength(3)
|
||||
expect(tree.map(n => n.item.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(tree.every(n => n.children.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('nests children under their parent by subagent_id', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'parent', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'child-1', index: 0, parentId: 'parent' }),
|
||||
makeItem({ depth: 1, id: 'child-2', index: 1, parentId: 'parent' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0]!.children).toHaveLength(2)
|
||||
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['child-1', 'child-2'])
|
||||
})
|
||||
|
||||
it('builds multi-level nesting', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }),
|
||||
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.children[0]!.children[0]!.item.id).toBe('gc')
|
||||
expect(tree[0]!.aggregate.maxDepthFromHere).toBe(2)
|
||||
expect(tree[0]!.aggregate.descendantCount).toBe(2)
|
||||
})
|
||||
|
||||
it('promotes orphaned children (missing parent) to top level', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree).toHaveLength(2)
|
||||
expect(tree.map(n => n.item.id)).toEqual(['a', 'orphan'])
|
||||
})
|
||||
|
||||
it('stable sort: children ordered by (depth, index) not insert order', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c3', index: 2, parentId: 'p' }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['c1', 'c2', 'c3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('aggregate', () => {
|
||||
it('sums tool counts and durations across subtree', () => {
|
||||
const items = [
|
||||
makeItem({ durationSeconds: 10, id: 'p', index: 0, status: 'completed', toolCount: 5 }),
|
||||
makeItem({ depth: 1, durationSeconds: 4, id: 'c1', index: 0, parentId: 'p', status: 'completed', toolCount: 3 }),
|
||||
makeItem({ depth: 1, durationSeconds: 2, id: 'c2', index: 1, parentId: 'p', status: 'completed', toolCount: 1 })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate).toMatchObject({
|
||||
activeCount: 0,
|
||||
descendantCount: 2,
|
||||
totalDuration: 16,
|
||||
totalTools: 9
|
||||
})
|
||||
})
|
||||
|
||||
it('counts queued + running as active', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0, status: 'running' }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p', status: 'queued' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p', status: 'completed' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.activeCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('widthByDepth', () => {
|
||||
it('returns empty array for empty tree', () => {
|
||||
expect(widthByDepth([])).toEqual([])
|
||||
})
|
||||
|
||||
it('tallies nodes at each depth', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p1', index: 0 }),
|
||||
makeItem({ id: 'p2', index: 1 }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p1' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p1' }),
|
||||
makeItem({ depth: 1, id: 'c3', index: 0, parentId: 'p2' }),
|
||||
makeItem({ depth: 2, id: 'gc1', index: 0, parentId: 'c1' })
|
||||
]
|
||||
|
||||
expect(widthByDepth(buildSubagentTree(items))).toEqual([2, 3, 1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('treeTotals', () => {
|
||||
it('folds a full tree into a single rollup', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p1', index: 0, toolCount: 5 }),
|
||||
makeItem({ id: 'p2', index: 1, toolCount: 2 }),
|
||||
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p1', toolCount: 3 })
|
||||
]
|
||||
|
||||
const totals = treeTotals(buildSubagentTree(items))
|
||||
expect(totals.descendantCount).toBe(3)
|
||||
expect(totals.totalTools).toBe(10)
|
||||
expect(totals.maxDepthFromHere).toBe(2)
|
||||
})
|
||||
|
||||
it('returns zeros for empty tree', () => {
|
||||
expect(treeTotals([])).toEqual({
|
||||
activeCount: 0,
|
||||
costUsd: 0,
|
||||
descendantCount: 0,
|
||||
filesTouched: 0,
|
||||
hotness: 0,
|
||||
inputTokens: 0,
|
||||
maxDepthFromHere: 0,
|
||||
outputTokens: 0,
|
||||
totalDuration: 0,
|
||||
totalTools: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenTree + descendantIds', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c1' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||
]
|
||||
|
||||
it('flattens in visit order (depth-first, pre-order)', () => {
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(flattenTree(tree).map(n => n.item.id)).toEqual(['p', 'c1', 'gc', 'c2'])
|
||||
})
|
||||
|
||||
it('collects descendant ids excluding the node itself', () => {
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(descendantIds(tree[0]!)).toEqual(['c1', 'gc', 'c2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sparkline', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(sparkline([])).toBe('')
|
||||
})
|
||||
|
||||
it('renders zeroes as spaces (not bottom glyph)', () => {
|
||||
expect(sparkline([0, 0])).toBe(' ')
|
||||
})
|
||||
|
||||
it('scales to the max value', () => {
|
||||
const out = sparkline([1, 8])
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out[1]).toBe('█')
|
||||
})
|
||||
|
||||
it('sparse widths render as expected', () => {
|
||||
const out = sparkline([2, 3, 7, 4])
|
||||
expect(out).toHaveLength(4)
|
||||
expect([...out].every(ch => /[\s▁-█]/.test(ch))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSummary', () => {
|
||||
const emptyTotals = {
|
||||
activeCount: 0,
|
||||
costUsd: 0,
|
||||
descendantCount: 0,
|
||||
filesTouched: 0,
|
||||
hotness: 0,
|
||||
inputTokens: 0,
|
||||
maxDepthFromHere: 0,
|
||||
outputTokens: 0,
|
||||
totalDuration: 0,
|
||||
totalTools: 0
|
||||
}
|
||||
|
||||
it('collapses zero-valued components', () => {
|
||||
expect(formatSummary({ ...emptyTotals, descendantCount: 1 })).toBe('d0 · 1 agent')
|
||||
})
|
||||
|
||||
it('emits rich summary with all pieces', () => {
|
||||
expect(
|
||||
formatSummary({
|
||||
...emptyTotals,
|
||||
activeCount: 2,
|
||||
descendantCount: 7,
|
||||
maxDepthFromHere: 3,
|
||||
totalDuration: 134,
|
||||
totalTools: 124
|
||||
})
|
||||
).toBe('d3 · 7 agents · 124 tools · 2m 14s · ⚡2')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue