Skip to content

feat(tui): extend renderAgentTree to support multi-level agent subtree rendering #557

@JeremyDev87

Description

@JeremyDev87

Overview

renderAgentTree in apps/mcp-server/src/tui/components/activity-visualizer.pure.ts currently renders only the root node and its direct children (depth=1). When agents form a deeper delegation chain (e.g., root → child → grandchild), the grandchild nodes are silently omitted.

This was identified as a [Low] finding during the EVAL phase of #551 and tracked here for future implementation.


Current Behavior

📊 Activity
● main-agent         ← root (depth 0)
  ├ ● solution-arch  ← direct child (depth 1)  ✓ shown
  └ ● researcher     ← direct child (depth 1)  ✓ shown
  
  # sub-agent spawned by solution-arch is NOT shown:
  # solution-arch → code-reviewer (depth 2)     ✗ missing

Root Cause (lines 56–61)

// apps/mcp-server/src/tui/components/activity-visualizer.pure.ts

// Collect children: edge-based agents + activeSkills as leaf nodes
const childIds = childrenOf.get(root.id) ?? [];   // ← only root's children
const agentChildren: TreeChild[] = childIds
  .filter(id => agents.has(id))
  .map(id => ({ type: 'agent', node: agents.get(id)! }));

The childrenOf map is built for all edges, but only root.id's children are queried. Subtrees at depth ≥ 2 are never traversed.


Expected Behavior

📊 Activity
● main-agent               ← depth 0
  ├ ● solution-architect   ← depth 1
  │   └ ● code-reviewer   ← depth 2  ✓ shown
  └ ● researcher           ← depth 1
  ◉ brainstorming (skill)  ← skill leaf

Rules:

  • Depth grows until lines.length >= height (respect height limit)
  • Indentation: 2 spaces per depth level ( × depth)
  • Connector: for non-last child, for last child
  • Continuation: for ancestor is-not-last columns (standard tree drawing)
  • Circular edge detection: skip already-visited node IDs
  • Active skills remain as leaf nodes appended after all agent children of root

Implementation Plan (TDD)

Files to modify

  • Modify: apps/mcp-server/src/tui/components/activity-visualizer.pure.ts
  • Modify: apps/mcp-server/src/tui/components/activity-visualizer.pure.spec.ts

Task 1: Write failing tests for multi-level rendering

Add to activity-visualizer.pure.spec.ts:

it('shows grandchild agent (depth 2)', () => {
  const agents = new Map<string, DashboardNode>();
  agents.set('root', createDefaultDashboardNode({ id: 'root', name: 'root', stage: 'PLAN', status: 'running', isPrimary: true }));
  agents.set('child', createDefaultDashboardNode({ id: 'child', name: 'child', stage: 'PLAN', status: 'running' }));
  agents.set('grandchild', createDefaultDashboardNode({ id: 'grandchild', name: 'grandchild', stage: 'PLAN', status: 'idle' }));
  const edges: Edge[] = [
    { from: 'root', to: 'child', label: '', type: 'delegation' },
    { from: 'child', to: 'grandchild', label: '', type: 'delegation' },
  ];
  const lines = renderAgentTree(agents, edges, [], 80, 10);
  expect(lines.join('\n')).toContain('grandchild');
});

it('does not enter infinite loop on cyclic edges', () => {
  const agents = new Map<string, DashboardNode>();
  agents.set('a', createDefaultDashboardNode({ id: 'a', name: 'a', stage: 'PLAN', status: 'running', isPrimary: true }));
  agents.set('b', createDefaultDashboardNode({ id: 'b', name: 'b', stage: 'PLAN', status: 'running' }));
  const edges: Edge[] = [
    { from: 'a', to: 'b', label: '', type: 'delegation' },
    { from: 'b', to: 'a', label: '', type: 'delegation' }, // cycle
  ];
  expect(() => renderAgentTree(agents, edges, [], 80, 10)).not.toThrow();
});

it('indents grandchildren deeper than direct children', () => {
  // grandchild line should have more leading spaces than child line
  const agents = new Map<string, DashboardNode>();
  agents.set('root', createDefaultDashboardNode({ id: 'root', name: 'root', stage: 'PLAN', status: 'running', isPrimary: true }));
  agents.set('child', createDefaultDashboardNode({ id: 'child', name: 'child', stage: 'PLAN', status: 'running' }));
  agents.set('grandchild', createDefaultDashboardNode({ id: 'grandchild', name: 'grandchild', stage: 'PLAN', status: 'idle' }));
  const edges: Edge[] = [
    { from: 'root', to: 'child', label: '', type: 'delegation' },
    { from: 'child', to: 'grandchild', label: '', type: 'delegation' },
  ];
  const lines = renderAgentTree(agents, edges, [], 80, 10);
  const childLine = lines.find(l => l.includes('child') && !l.includes('grandchild'))!;
  const grandchildLine = lines.find(l => l.includes('grandchild'))!;
  const childIndent = childLine.search(/\S/);
  const grandchildIndent = grandchildLine.search(/\S/);
  expect(grandchildIndent).toBeGreaterThan(childIndent);
});

Run to confirm RED:

cd apps/mcp-server && yarn test --run src/tui/components/activity-visualizer.pure.spec.ts

Task 2: Refactor renderAgentTree to use recursive subtree traversal

Replace the flat child-collection logic with a recursive renderSubtree helper:

function renderSubtree(
  nodeId: string,
  agents: Map<string, DashboardNode>,
  childrenOf: Map<string, string[]>,
  visited: Set<string>,
  depth: number,
  prefix: string,      // e.g. '  │  ' for ancestor columns
  isLast: boolean,
  lines: string[],
  height: number,
  width: number,
): void {
  if (lines.length >= height) return;
  if (visited.has(nodeId)) return;  // cycle guard
  visited.add(nodeId);

  const node = agents.get(nodeId);
  if (!node) return;

  const connector = isLast ? '└' : '├';
  const icon = ACTIVITY_STATUS_ICONS[node.status] ?? '?';
  lines.push(truncateToDisplayWidth(`${prefix}${connector} ${icon} ${node.name}`, width));

  const childIds = (childrenOf.get(nodeId) ?? []).filter(id => agents.has(id));
  const nextPrefix = prefix + (isLast ? '   ' : '│  ');
  for (let i = 0; i < childIds.length; i++) {
    if (lines.length >= height) break;
    renderSubtree(childIds[i], agents, childrenOf, visited, depth + 1, nextPrefix, i === childIds.length - 1, lines, height, width);
  }
}

Update renderAgentTree to call renderSubtree for each direct child of root, then append skill nodes.

Task 3: Run tests to confirm GREEN

cd apps/mcp-server && yarn test --run src/tui/components/activity-visualizer.pure.spec.ts

Expected: All tests pass including the 3 new ones.

Task 4: Run full test suite

cd apps/mcp-server && yarn test --run

Expected: All tests pass, no regressions.

Task 5: TypeScript check

cd apps/mcp-server && yarn tsc --noEmit

Expected: No errors.


Acceptance Criteria

  • Agents at depth ≥ 2 are rendered when connected via edges
  • Cyclic edges do not cause infinite loops (visited set guard)
  • Each depth level has 2 additional leading spaces vs. parent
  • continuation lines drawn for non-last ancestors
  • Height limit (lines.length >= height) respected at all depths
  • Width truncation (truncateToDisplayWidth) applied to all lines
  • Active skills still rendered as leaf nodes after agent subtree
  • All existing 33 tests continue to pass
  • 3 new tests (grandchild, cycle, indentation) pass
  • TypeScript strict mode, no any

Context

  • Discovered during: EVAL of feat(tui): redesign Activity and Live panels in ActivityVisualizer #551 (Activity/Live panel redesign)
  • Affected file: apps/mcp-server/src/tui/components/activity-visualizer.pure.ts (lines 23–77)
  • Current tree rendering: depth=1 only (root + direct children)
  • Related: Edge type from apps/mcp-server/src/tui/dashboard-types.ts
  • Test file: apps/mcp-server/src/tui/components/activity-visualizer.pure.spec.ts

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions