'use strict'; /** * Integration tests for sub-agent support (Phase 3). * * Tests: * 3.1 Sub-agent detection via spawnedBy in sessions.json * 3.2 Nested status rendering in status-formatter * 3.3 Cascade completion: parent waits for all children * 3.4 Status watcher handles child session transcript */ const { describe, it, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { SessionMonitor } = require('../../src/session-monitor'); const { StatusWatcher } = require('../../src/status-watcher'); const { format } = require('../../src/status-formatter'); function createTmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'subagent-test-')); } function writeSessionsJson(dir, agentId, sessions) { var agentDir = path.join(dir, agentId, 'sessions'); fs.mkdirSync(agentDir, { recursive: true }); fs.writeFileSync(path.join(agentDir, 'sessions.json'), JSON.stringify(sessions, null, 2)); // Create stub transcript files so SessionMonitor doesn't skip them as missing for (var key in sessions) { var entry = sessions[key]; // eslint-disable-line security/detect-object-injection var sessionId = entry.sessionId || entry.uuid; if (sessionId) { var transcriptPath = path.join(agentDir, sessionId + '.jsonl'); if (!fs.existsSync(transcriptPath)) { fs.writeFileSync(transcriptPath, ''); } } } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function appendLine(file, obj) { fs.appendFileSync(file, JSON.stringify(obj) + '\n'); } describe('Sub-Agent Support (Phase 3)', () => { let tmpDir; beforeEach(() => { tmpDir = createTmpDir(); }); afterEach(() => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_e) { /* ignore */ } }); describe('3.1 Sub-agent detection via spawnedBy', () => { it('detects sub-agent session with spawnedBy field', async () => { const parentKey = 'agent:main:mattermost:channel:chan1:thread:root1'; const childKey = 'agent:main:subagent:uuid-child-123'; const parentId = 'parent-uuid'; const childId = 'child-uuid'; writeSessionsJson(tmpDir, 'main', { [parentKey]: { sessionId: parentId, spawnedBy: null, spawnDepth: 0, channel: 'mattermost', }, [childKey]: { sessionId: childId, spawnedBy: parentKey, spawnDepth: 1, label: 'proj035-planner', channel: 'mattermost', }, }); const monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 }); const added = []; monitor.on('session-added', (info) => added.push(info)); monitor.start(); await sleep(200); monitor.stop(); // Both sessions should be detected assert.equal(added.length, 2); const child = added.find((s) => s.sessionKey === childKey); assert.ok(child); assert.equal(child.spawnedBy, parentKey); assert.equal(child.spawnDepth, 1); assert.equal(child.agentId, 'proj035-planner'); }); it('sub-agent inherits parent channel ID', async () => { const parentKey = 'agent:main:mattermost:channel:mychan:thread:myroot'; const childKey = 'agent:main:subagent:child-uuid'; writeSessionsJson(tmpDir, 'main', { [parentKey]: { sessionId: 'p-uuid', spawnedBy: null, channel: 'mattermost', }, [childKey]: { sessionId: 'c-uuid', spawnedBy: parentKey, spawnDepth: 1, channel: 'mattermost', }, }); const monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 }); const added = []; monitor.on('session-added', (info) => added.push(info)); monitor.start(); await sleep(200); monitor.stop(); const child = added.find((s) => s.sessionKey === childKey); assert.ok(child); // Child session key has the parent's channel embedded in spawnedBy key assert.equal(child.spawnedBy, parentKey); }); }); describe('3.2 Nested status rendering', () => { it('renders parent with nested child status', () => { const parentState = { sessionKey: 'agent:main:mattermost:channel:c1:thread:t1', status: 'active', startTime: Date.now() - 60000, lines: ['Starting implementation...', ' exec: ls [OK]'], agentId: 'main', depth: 0, tokenCount: 5000, children: [ { sessionKey: 'agent:main:subagent:planner-uuid', status: 'done', startTime: Date.now() - 30000, lines: ['Reading protocol...', ' Read: PLAN.md [OK]'], agentId: 'proj035-planner', depth: 1, tokenCount: 2000, children: [], }, ], }; const result = format(parentState); const lines = result.split('\n'); // Parent header at depth 0 — wrapped in blockquote ("> ") assert.ok(lines[0].includes('[ACTIVE]')); assert.ok(lines[0].includes('main')); // Child header at depth 1 (indented with "> " + 2 spaces) const childHeaderLine = lines.find((l) => l.includes('proj035-planner')); assert.ok(childHeaderLine); // Child lines appear as "> ..." (blockquote + 2-space indent) assert.ok(/^> {2}/.test(childHeaderLine)); // Child should have [DONE] line assert.ok(result.includes('[DONE]')); // Parent should not have [DONE] footer (still active) // The top-level [DONE] is from child, wrapped in blockquote const parentDoneLines = lines.filter((l) => /^\[DONE\]/.test(l)); assert.equal(parentDoneLines.length, 0); }); it('renders multiple nested children', () => { const child1 = { sessionKey: 'agent:main:subagent:child1', status: 'done', startTime: Date.now() - 20000, lines: ['Child 1 done'], agentId: 'child-agent-1', depth: 1, tokenCount: 1000, children: [], }; const child2 = { sessionKey: 'agent:main:subagent:child2', status: 'active', startTime: Date.now() - 10000, lines: ['Child 2 working...'], agentId: 'child-agent-2', depth: 1, tokenCount: 500, children: [], }; const parent = { sessionKey: 'agent:main:mattermost:channel:c1:thread:t1', status: 'active', startTime: Date.now() - 30000, lines: ['Orchestrating...'], agentId: 'main', depth: 0, tokenCount: 3000, children: [child1, child2], }; const result = format(parent); assert.ok(result.includes('child-agent-1')); assert.ok(result.includes('child-agent-2')); }); it('indents deeply nested children', () => { const grandchild = { sessionKey: 'agent:main:subagent:grandchild', status: 'active', startTime: Date.now() - 5000, lines: ['Deep work...'], agentId: 'grandchild-agent', depth: 2, tokenCount: 100, children: [], }; const child = { sessionKey: 'agent:main:subagent:child', status: 'active', startTime: Date.now() - 15000, lines: ['Spawning grandchild...'], agentId: 'child-agent', depth: 1, tokenCount: 500, children: [grandchild], }; const parent = { sessionKey: 'agent:main:mattermost:channel:c1:thread:t1', status: 'active', startTime: Date.now() - 30000, lines: ['Top level'], agentId: 'main', depth: 0, tokenCount: 2000, children: [child], }; const result = format(parent); const lines = result.split('\n'); const grandchildLine = lines.find((l) => l.includes('grandchild-agent')); assert.ok(grandchildLine); // Grandchild at depth 2 should have 4 spaces of indent after blockquote prefix ("> ") assert.ok(/^> {4}/.test(grandchildLine)); }); }); describe('3.3 Cascade completion', () => { it('status-watcher tracks pending tool calls across child sessions', () => { const parentFile = path.join(tmpDir, 'parent.jsonl'); const childFile = path.join(tmpDir, 'child.jsonl'); fs.writeFileSync(parentFile, ''); fs.writeFileSync(childFile, ''); appendLine(parentFile, { type: 'assistant', text: 'Starting...' }); appendLine(childFile, { type: 'tool_call', name: 'exec', id: '1' }); const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); try { watcher.addSession('parent:key', parentFile, { depth: 0, lastOffset: 0 }); watcher.addSession('child:key', childFile, { depth: 1, lastOffset: 0 }); const parentState = watcher.getSessionState('parent:key'); const childState = watcher.getSessionState('child:key'); assert.ok(parentState); assert.ok(childState); assert.equal(childState.pendingToolCalls, 1); assert.equal(parentState.pendingToolCalls, 0); } finally { watcher.stop(); } }); it('child session idle does not affect parent tracking', async () => { const parentFile = path.join(tmpDir, 'parent2.jsonl'); const childFile = path.join(tmpDir, 'child2.jsonl'); fs.writeFileSync(parentFile, ''); fs.writeFileSync(childFile, ''); appendLine(parentFile, { type: 'assistant', text: 'Orchestrating' }); appendLine(childFile, { type: 'assistant', text: 'Child working' }); const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 0.2 }); const idleEvents = []; watcher.on('session-idle', (key) => idleEvents.push(key)); try { watcher.addSession('parent2:key', parentFile, { depth: 0, lastOffset: 0 }); watcher.addSession('child2:key', childFile, { depth: 1, lastOffset: 0 }); await sleep(600); // Both should eventually idle (since both have no pending tool calls) assert.ok(idleEvents.includes('child2:key') || idleEvents.includes('parent2:key')); } finally { watcher.stop(); } }); }); describe('3.4 Sub-agent JSONL parsing', () => { it('correctly parses sub-agent transcript with usage events', () => { const file = path.join(tmpDir, 'subagent.jsonl'); fs.writeFileSync(file, ''); appendLine(file, { type: 'assistant', text: 'Reading PLAN.md...' }); appendLine(file, { type: 'tool_call', name: 'Read', id: '1' }); appendLine(file, { type: 'tool_result', name: 'Read', id: '1' }); appendLine(file, { type: 'usage', input_tokens: 2000, output_tokens: 800 }); appendLine(file, { type: 'assistant', text: 'Plan ready.' }); const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); try { watcher.addSession('subagent:key', file, { agentId: 'planner', depth: 1, lastOffset: 0 }); const state = watcher.getSessionState('subagent:key'); assert.ok(state.lines.some((l) => l.includes('Reading PLAN.md'))); assert.ok(state.lines.some((l) => l.includes('Read') && l.includes('[OK]'))); assert.ok(state.lines.some((l) => l.includes('Plan ready'))); assert.equal(state.tokenCount, 2800); assert.equal(state.pendingToolCalls, 0); } finally { watcher.stop(); } }); it('handles error tool result', () => { const file = path.join(tmpDir, 'err.jsonl'); fs.writeFileSync(file, ''); appendLine(file, { type: 'tool_call', name: 'exec', id: '1' }); appendLine(file, { type: 'tool_result', name: 'exec', id: '1', error: true }); const watcher = new StatusWatcher({ transcriptDir: tmpDir, idleTimeoutS: 600 }); try { watcher.addSession('err:key', file, { lastOffset: 0 }); const state = watcher.getSessionState('err:key'); const execLine = state.lines.find((l) => l.includes('exec')); assert.ok(execLine); assert.ok(execLine.includes('[ERR]')); assert.equal(state.pendingToolCalls, 0); } finally { watcher.stop(); } }); }); });