Files
MATTERMOST_OPENCLAW_LIVESTATUS/test/integration/session-monitor.test.js
sol e3bd6c52dd feat: Phase 2 — session monitor, lifecycle, watcher manager
Phase 2 (Session Monitor + Lifecycle):
- src/session-monitor.js: polls sessions.json every 2s for new/ended sessions
  - Detects agents via transcriptDir subdirectory scan
  - Resolves channelId/rootPostId from session key format
  - Emits session-added/session-removed events
  - Handles multi-agent environments
  - Falls back to defaultChannel for non-MM sessions
- src/watcher-manager.js: top-level orchestrator
  - Starts session-monitor, status-watcher, health-server
  - Creates/updates Mattermost status posts on session events
  - Sub-agent linking: children embedded in parent status
  - Offset persistence (save/restore lastOffset on restart)
  - Post recovery on restart (search channel history for marker)
  - SIGTERM/SIGINT graceful shutdown: mark all boxes interrupted
  - CLI: node watcher-manager.js start|stop|status
  - MAX_ACTIVE_SESSIONS enforcement

Integration tests:
- test/integration/session-monitor.test.js: 14 tests
  - Session detection, removal, multi-agent, malformed JSON handling
- test/integration/status-watcher.test.js: 13 tests
  - JSONL parsing, tool_call/result pairs, idle detection, offset recovery

All 86 tests pass (59 unit + 27 integration). make check clean.
2026-03-07 17:32:28 +00:00

252 lines
7.6 KiB
JavaScript

'use strict';
/**
* Integration tests for session-monitor.js
* Tests session detection by writing mock sessions.json files.
*/
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');
function createTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'sm-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));
}
describe('SessionMonitor', () => {
let tmpDir;
let monitor;
beforeEach(() => {
tmpDir = createTmpDir();
});
afterEach(() => {
if (monitor) {
monitor.stop();
monitor = null;
}
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch (_e) {
/* ignore */
}
});
describe('parseChannelId()', () => {
it('parses channel ID from mattermost session key', () => {
const key = 'agent:main:mattermost:channel:abc123:thread:xyz';
assert.equal(SessionMonitor.parseChannelId(key), 'abc123');
});
it('parses DM channel', () => {
const key = 'agent:main:mattermost:dm:user456';
assert.equal(SessionMonitor.parseChannelId(key), 'user456');
});
it('returns null for non-MM session', () => {
const key = 'agent:main:hook:session:xyz';
assert.equal(SessionMonitor.parseChannelId(key), null);
});
});
describe('parseRootPostId()', () => {
it('parses thread ID from session key', () => {
const key = 'agent:main:mattermost:channel:abc123:thread:rootpost999';
assert.equal(SessionMonitor.parseRootPostId(key), 'rootpost999');
});
it('returns null if no thread', () => {
const key = 'agent:main:mattermost:channel:abc123';
assert.equal(SessionMonitor.parseRootPostId(key), null);
});
});
describe('parseAgentId()', () => {
it('extracts agent ID', () => {
assert.equal(SessionMonitor.parseAgentId('agent:main:mattermost:channel:abc'), 'main');
assert.equal(SessionMonitor.parseAgentId('agent:coder-agent:session'), 'coder-agent');
});
});
describe('isMattermostSession()', () => {
it('detects mattermost sessions', () => {
assert.equal(SessionMonitor.isMattermostSession('agent:main:mattermost:channel:abc'), true);
assert.equal(SessionMonitor.isMattermostSession('agent:main:hook:abc'), false);
});
});
describe('session-added event', () => {
it('emits session-added for new session in sessions.json', async () => {
const sessionKey = 'agent:main:mattermost:channel:testchan:thread:testroot';
const sessionId = 'aaaa-1111-bbbb-2222';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: {
sessionId,
spawnedBy: null,
spawnDepth: 0,
label: null,
channel: 'mattermost',
},
});
monitor = new SessionMonitor({
transcriptDir: tmpDir,
pollMs: 50,
});
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
assert.equal(added.length, 1);
assert.equal(added[0].sessionKey, sessionKey);
assert.equal(added[0].channelId, 'testchan');
assert.equal(added[0].rootPostId, 'testroot');
assert.ok(added[0].transcriptFile.endsWith(`${sessionId}.jsonl`));
});
it('emits session-removed when session disappears', async () => {
const sessionKey = 'agent:main:mattermost:channel:testchan:thread:testroot';
const sessionId = 'cccc-3333-dddd-4444';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: { sessionId, spawnedBy: null, spawnDepth: 0, channel: 'mattermost' },
});
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
const removed = [];
monitor.on('session-removed', (key) => removed.push(key));
monitor.start();
await sleep(200);
assert.equal(removed.length, 0);
// Remove the session
writeSessionsJson(tmpDir, 'main', {});
await sleep(200);
assert.equal(removed.length, 1);
assert.equal(removed[0], sessionKey);
});
it('skips non-MM sessions with no default channel', async () => {
const sessionKey = 'agent:main:hook:session:xyz';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: { sessionId: 'hook-uuid', channel: 'hook' },
});
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50, defaultChannel: null });
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
assert.equal(added.length, 0);
});
it('includes non-MM sessions when defaultChannel is set', async () => {
const sessionKey = 'agent:main:hook:session:xyz';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: { sessionId: 'hook-uuid', channel: 'hook' },
});
monitor = new SessionMonitor({
transcriptDir: tmpDir,
pollMs: 50,
defaultChannel: 'default-channel-id',
});
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
assert.equal(added.length, 1);
assert.equal(added[0].channelId, 'default-channel-id');
});
it('detects multiple agents', async () => {
writeSessionsJson(tmpDir, 'main', {
'agent:main:mattermost:channel:c1:thread:t1': {
sessionId: 'sess-main',
spawnedBy: null,
channel: 'mattermost',
},
});
writeSessionsJson(tmpDir, 'coder-agent', {
'agent:coder-agent:mattermost:channel:c2:thread:t2': {
sessionId: 'sess-coder',
spawnedBy: null,
channel: 'mattermost',
},
});
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
assert.equal(added.length, 2);
const keys = added.map((s) => s.sessionKey).sort();
assert.ok(keys.includes('agent:main:mattermost:channel:c1:thread:t1'));
assert.ok(keys.includes('agent:coder-agent:mattermost:channel:c2:thread:t2'));
});
it('handles malformed sessions.json gracefully', async () => {
const agentDir = path.join(tmpDir, 'main', 'sessions');
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(path.join(agentDir, 'sessions.json'), 'not valid json');
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
const added = [];
monitor.on('session-added', (info) => added.push(info));
monitor.start();
await sleep(200);
// Should not throw and should produce no sessions
assert.equal(added.length, 0);
});
});
describe('getKnownSessions()', () => {
it('returns current known sessions', async () => {
const sessionKey = 'agent:main:mattermost:channel:c1:thread:t1';
writeSessionsJson(tmpDir, 'main', {
[sessionKey]: { sessionId: 'test-uuid', channel: 'mattermost' },
});
monitor = new SessionMonitor({ transcriptDir: tmpDir, pollMs: 50 });
monitor.start();
await sleep(200);
const sessions = monitor.getKnownSessions();
assert.equal(sessions.size, 1);
assert.ok(sessions.has(sessionKey));
});
});
});