feat: Phase 3 — sub-agent detection, nested status, cascade completion
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.
This commit is contained in:
@@ -226,11 +226,14 @@ class SessionMonitor extends EventEmitter {
|
|||||||
|
|
||||||
const transcriptFile = this._transcriptPath(agentId, sessionId);
|
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
|
// Resolve channel ID from session key
|
||||||
let channelId = SessionMonitor.parseChannelId(sessionKey);
|
let channelId = SessionMonitor.parseChannelId(sessionKey);
|
||||||
|
|
||||||
// Fall back to default channel for non-MM sessions
|
// Fall back to default channel for non-MM sessions
|
||||||
if (!channelId && !SessionMonitor.isMattermostSession(sessionKey)) {
|
if (!channelId && !isSubAgent && !SessionMonitor.isMattermostSession(sessionKey)) {
|
||||||
channelId = this.defaultChannel;
|
channelId = this.defaultChannel;
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
if (this.logger) {
|
if (this.logger) {
|
||||||
|
|||||||
@@ -134,8 +134,11 @@ async function startDaemon() {
|
|||||||
logger.info({ count: Object.keys(savedOffsets).length }, 'Loaded persisted session offsets');
|
logger.info({ count: Object.keys(savedOffsets).length }, 'Loaded persisted session offsets');
|
||||||
|
|
||||||
// Shared state
|
// Shared state
|
||||||
// Map<sessionKey, { postId, statusBox }>
|
// Map<sessionKey, { postId, agentId, channelId, rootPostId, children: Map }>
|
||||||
const activeBoxes = new Map();
|
const activeBoxes = new Map();
|
||||||
|
|
||||||
|
// Pending sub-agents: spawnedBy key -> [info] (arrived before parent was added)
|
||||||
|
const pendingSubAgents = new Map();
|
||||||
let globalMetrics = {
|
let globalMetrics = {
|
||||||
activeSessions: 0,
|
activeSessions: 0,
|
||||||
updatesSent: 0,
|
updatesSent: 0,
|
||||||
@@ -202,27 +205,23 @@ async function startDaemon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sub-agent: skip creating own post (embedded in parent)
|
// Sub-agent: skip creating own post (embedded in parent)
|
||||||
if (spawnedBy && activeBoxes.has(spawnedBy)) {
|
if (spawnedBy) {
|
||||||
const parentBox = activeBoxes.get(spawnedBy);
|
if (activeBoxes.has(spawnedBy)) {
|
||||||
// Link child to parent's session state
|
linkSubAgent(
|
||||||
const childState = {
|
activeBoxes,
|
||||||
sessionKey,
|
watcher,
|
||||||
transcriptFile,
|
spawnedBy,
|
||||||
spawnedBy,
|
{ sessionKey, transcriptFile, channelId, agentId, spawnDepth: info.spawnDepth },
|
||||||
parentPostId: parentBox.postId,
|
logger,
|
||||||
channelId,
|
);
|
||||||
depth: info.spawnDepth || 0,
|
} else {
|
||||||
agentId,
|
// Parent not yet tracked — queue for later
|
||||||
};
|
logger.debug({ sessionKey, spawnedBy }, 'Sub-agent queued (parent not yet tracked)');
|
||||||
parentBox.children = parentBox.children || new Map();
|
if (!pendingSubAgents.has(spawnedBy)) pendingSubAgents.set(spawnedBy, []);
|
||||||
parentBox.children.set(sessionKey, childState);
|
pendingSubAgents
|
||||||
|
.get(spawnedBy)
|
||||||
// Register in watcher
|
.push({ sessionKey, transcriptFile, channelId, agentId, spawnDepth: info.spawnDepth });
|
||||||
watcher.addSession(sessionKey, transcriptFile, {
|
}
|
||||||
agentId,
|
|
||||||
depth: info.spawnDepth || 1,
|
|
||||||
});
|
|
||||||
logger.info({ sessionKey, parent: spawnedBy }, 'Sub-agent linked to parent');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +261,19 @@ async function startDaemon() {
|
|||||||
? { lastOffset: saved.lastOffset, startTime: saved.startTime, agentId }
|
? { lastOffset: saved.lastOffset, startTime: saved.startTime, agentId }
|
||||||
: { agentId };
|
: { agentId };
|
||||||
watcher.addSession(sessionKey, transcriptFile, initialState);
|
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 ----
|
// ---- Session Removed ----
|
||||||
@@ -400,6 +412,23 @@ async function startDaemon() {
|
|||||||
|
|
||||||
// ---- Helper functions ----
|
// ---- 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) {
|
function buildInitialText(agentId, sessionKey) {
|
||||||
const { format } = require('./status-formatter');
|
const { format } = require('./status-formatter');
|
||||||
return format({
|
return format({
|
||||||
|
|||||||
357
test/integration/sub-agent.test.js
Normal file
357
test/integration/sub-agent.test.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user