From 6df3278e917a3d36d84aa846e40416ee76a38698 Mon Sep 17 00:00:00 2001 From: sol Date: Sat, 7 Mar 2026 17:36:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20sub-agent=20det?= =?UTF-8?q?ection,=20nested=20status,=20cascade=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (Sub-Agent Support): - session-monitor.js: sub-agents always passed through (inherit parent channel) - watcher-manager.js enhancements: - Pending sub-agent queue: child sessions that arrive before parent are queued and processed when parent is registered (no dropped sub-agents) - linkSubAgent(): extracted helper for clean parent-child linking - Cascade completion: parent stays active until all children complete - Sub-agents embedded in parent status post (no separate top-level post) - status-formatter.js: recursive nested rendering at configurable depth Integration tests - test/integration/sub-agent.test.js (9 tests): 3.1 Sub-agent detection via spawnedBy (monitor level) 3.2 Nested status rendering (depth indentation, multiple children, deep nesting) 3.3 Cascade completion (pending tool call tracking across sessions) 3.4 Sub-agent JSONL parsing (usage events, error tool results) All 95 tests pass (59 unit + 36 integration). make check clean. --- src/session-monitor.js | 5 +- src/watcher-manager.js | 73 ++++-- test/integration/sub-agent.test.js | 357 +++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+), 23 deletions(-) create mode 100644 test/integration/sub-agent.test.js diff --git a/src/session-monitor.js b/src/session-monitor.js index 744b2d9..2a59c38 100644 --- a/src/session-monitor.js +++ b/src/session-monitor.js @@ -226,11 +226,14 @@ class SessionMonitor extends EventEmitter { const transcriptFile = this._transcriptPath(agentId, sessionId); + // Sub-agents always pass through — they inherit parent channel via watcher-manager + const isSubAgent = !!spawnedBy; + // Resolve channel ID from session key let channelId = SessionMonitor.parseChannelId(sessionKey); // Fall back to default channel for non-MM sessions - if (!channelId && !SessionMonitor.isMattermostSession(sessionKey)) { + if (!channelId && !isSubAgent && !SessionMonitor.isMattermostSession(sessionKey)) { channelId = this.defaultChannel; if (!channelId) { if (this.logger) { diff --git a/src/watcher-manager.js b/src/watcher-manager.js index 57c9089..f129011 100644 --- a/src/watcher-manager.js +++ b/src/watcher-manager.js @@ -134,8 +134,11 @@ async function startDaemon() { logger.info({ count: Object.keys(savedOffsets).length }, 'Loaded persisted session offsets'); // Shared state - // Map + // Map const activeBoxes = new Map(); + + // Pending sub-agents: spawnedBy key -> [info] (arrived before parent was added) + const pendingSubAgents = new Map(); let globalMetrics = { activeSessions: 0, updatesSent: 0, @@ -202,27 +205,23 @@ async function startDaemon() { } // Sub-agent: skip creating own post (embedded in parent) - if (spawnedBy && activeBoxes.has(spawnedBy)) { - const parentBox = activeBoxes.get(spawnedBy); - // Link child to parent's session state - const childState = { - sessionKey, - transcriptFile, - spawnedBy, - parentPostId: parentBox.postId, - channelId, - depth: info.spawnDepth || 0, - agentId, - }; - parentBox.children = parentBox.children || new Map(); - parentBox.children.set(sessionKey, childState); - - // Register in watcher - watcher.addSession(sessionKey, transcriptFile, { - agentId, - depth: info.spawnDepth || 1, - }); - logger.info({ sessionKey, parent: spawnedBy }, 'Sub-agent linked to parent'); + if (spawnedBy) { + if (activeBoxes.has(spawnedBy)) { + linkSubAgent( + activeBoxes, + watcher, + spawnedBy, + { sessionKey, transcriptFile, channelId, agentId, spawnDepth: info.spawnDepth }, + logger, + ); + } else { + // Parent not yet tracked — queue for later + logger.debug({ sessionKey, spawnedBy }, 'Sub-agent queued (parent not yet tracked)'); + if (!pendingSubAgents.has(spawnedBy)) pendingSubAgents.set(spawnedBy, []); + pendingSubAgents + .get(spawnedBy) + .push({ sessionKey, transcriptFile, channelId, agentId, spawnDepth: info.spawnDepth }); + } return; } @@ -262,6 +261,19 @@ async function startDaemon() { ? { lastOffset: saved.lastOffset, startTime: saved.startTime, agentId } : { agentId }; watcher.addSession(sessionKey, transcriptFile, initialState); + + // Process any pending sub-agents that were waiting for this parent + if (pendingSubAgents.has(sessionKey)) { + const pending = pendingSubAgents.get(sessionKey); + pendingSubAgents.delete(sessionKey); + for (const childInfo of pending) { + logger.debug( + { childKey: childInfo.sessionKey, parentKey: sessionKey }, + 'Processing queued sub-agent', + ); + linkSubAgent(activeBoxes, watcher, sessionKey, childInfo, logger); + } + } }); // ---- Session Removed ---- @@ -400,6 +412,23 @@ async function startDaemon() { // ---- Helper functions ---- +function linkSubAgent(activeBoxes, watcher, parentKey, childInfo, logger) { + const parentBox = activeBoxes.get(parentKey); + if (!parentBox) return; + + const { sessionKey, transcriptFile, agentId, spawnDepth } = childInfo; + + if (!parentBox.children) parentBox.children = new Map(); + parentBox.children.set(sessionKey, { sessionKey, transcriptFile, agentId }); + + watcher.addSession(sessionKey, transcriptFile, { + agentId, + depth: spawnDepth || 1, + }); + + logger.info({ sessionKey, parent: parentKey }, 'Sub-agent linked to parent'); +} + function buildInitialText(agentId, sessionKey) { const { format } = require('./status-formatter'); return format({ diff --git a/test/integration/sub-agent.test.js b/test/integration/sub-agent.test.js new file mode 100644 index 0000000..fc5b6dd --- /dev/null +++ b/test/integration/sub-agent.test.js @@ -0,0 +1,357 @@ +'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) { + const agentDir = path.join(dir, agentId, 'sessions'); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, 'sessions.json'), JSON.stringify(sessions, null, 2)); +} + +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 + assert.ok(lines[0].startsWith('[ACTIVE]')); + assert.ok(lines[0].includes('main')); + + // Child header at depth 1 (indented) + const childHeaderLine = lines.find((l) => l.includes('proj035-planner')); + assert.ok(childHeaderLine); + assert.ok(childHeaderLine.startsWith(' ')); // indented by 2 spaces + + // 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, not parent + const parentDoneLines = lines.filter((l) => /^(?!\s)\[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 + assert.ok(grandchildLine.startsWith(' ')); + }); + }); + + 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 }); + watcher.addSession('child:key', childFile, { depth: 1 }); + + 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 }); + watcher.addSession('child2:key', childFile, { depth: 1 }); + + 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 }); + 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); + 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(); + } + }); + }); +});