| import { describe, expect, it } from 'vitest' |
|
|
| import { |
| buildSubagentTree, |
| descendantIds, |
| flattenTree, |
| fmtCost, |
| fmtDuration, |
| fmtTokens, |
| formatSummary, |
| hotnessBucket, |
| peakHotness, |
| sparkline, |
| topLevelSubagents, |
| 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) |
| expect(hotnessBucket(5, 0, 4)).toBe(0) |
| }) |
| }) |
|
|
| 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') |
| }) |
| }) |
|
|
| describe('fmtDuration', () => { |
| it('formats under a minute as plain seconds', () => { |
| expect(fmtDuration(0)).toBe('0s') |
| expect(fmtDuration(42)).toBe('42s') |
| expect(fmtDuration(59.4)).toBe('59s') |
| }) |
|
|
| it('formats whole minutes without trailing seconds', () => { |
| expect(fmtDuration(60)).toBe('1m') |
| expect(fmtDuration(180)).toBe('3m') |
| }) |
|
|
| it('mixes minutes and seconds', () => { |
| expect(fmtDuration(134)).toBe('2m 14s') |
| expect(fmtDuration(605)).toBe('10m 5s') |
| }) |
| }) |
|
|
| describe('topLevelSubagents', () => { |
| it('returns items with no parent', () => { |
| const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 })] |
| expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'b']) |
| }) |
|
|
| it('excludes children whose parent is present', () => { |
| const items = [makeItem({ id: 'p', index: 0 }), makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })] |
|
|
| expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p']) |
| }) |
|
|
| it('promotes orphans whose parent is missing', () => { |
| const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })] |
| expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'orphan']) |
| }) |
| }) |
|
|