Files
MATTERMOST_OPENCLAW_LIVESTATUS/test/integration/sub-agent.test.js
sol 868574d939 fix: remove dead delete+recreate and pin code, add poll fallback test
Phase 1 cleanup:
- Remove deletePost() method (dead code, replaced by PUT in-place updates)
- Remove _postInfo Map tracking (no longer needed)
- Remove pin/unpin API calls from watcher-manager.js (incompatible with PUT updates)
- Add JSDoc note on (edited) label limitation in _flushUpdate()
- Add integration test: test/integration/poll-fallback.test.js
- Fix addSession() lastOffset===0 falsy bug (0 was treated as 'no offset')
- Fix pre-existing test failures: add lastOffset:0 where tests expect backlog reads
- Fix pre-existing session-monitor test: create stub transcript files
- Fix pre-existing status-formatter test: update indent check for blockquote format
- Format plugin/ files with Prettier (pre-existing formatting drift)
2026-03-07 20:31:32 +00:00

370 lines
12 KiB
JavaScript

'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();
}
});
});
});