mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
fix(subagent-watcher): discover subagents via agent-*.meta.json (CC format change)
Claude Code changed its subagent on-disk format (~2026-06-14): TUI Task
subagents now write `agent-{id}.meta.json` ({agentType,description,toolUseId})
into the session's `subagents/` dir and no longer reliably write a per-agent
`agent-{id}.jsonl` transcript there. The watcher discovered agents ONLY by
`.jsonl`, so it tracked zero — subagent windows and the monitor's "N TRACKED"
showed nothing.
- Add `registerAgentMeta()`: discover from the meta sidecar (description from
meta.description/agentType), prefer a sibling `.jsonl` transcript when present
(richer), never tail a meta file.
- Initial scan + directory watcher now handle `.meta.json` alongside `.jsonl`.
- Tests: 2 new cases (meta-only discovery; prefer-.jsonl-when-present).
Verified e2e against a real ~/.claude/projects fixture.
Known follow-ups (not in scope): meta-only agents have no per-agent transcript
to tail (no live tool-call feed, status stays 'active'); workflow agents under
`subagents/workflows/{wf}/agent-*.jsonl` are still missed by the flat scan.
Also adds the README screenshot tooling used to surface this:
- capture-real-overview.mjs: DSF=2 + ?nowebgl crisp path (DOM renderer avoids
the WebGL glyph-doubling at deviceScaleFactor>1).
- capture-readme-real.mjs: real-instance desktop-scene capture (dashboard/
monitor/subagent) for an isolated beta seeded from prod settings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* capture-readme-real.mjs
|
||||||
|
*
|
||||||
|
* Captures README desktop scenes (multi-session dashboard, monitor, subagent
|
||||||
|
* windows) from a REAL Codeman instance — intended to run against an ISOLATED
|
||||||
|
* dev/beta instance (CODEMAN_INSTANCE=beta on :5000) seeded from prod's settings,
|
||||||
|
* NOT prod itself (never touch prod's live sessions).
|
||||||
|
*
|
||||||
|
* Reuses the high-quality capture recipe proven in capture-real-overview.mjs:
|
||||||
|
* - DSF=2 + ?nowebgl → crisp retina at the TRUE font size (WebGL doubles
|
||||||
|
* glyphs under DSF=2; the DOM renderer respects devicePixelRatio).
|
||||||
|
* - per-device localStorage seeding so the capture matches a real device.
|
||||||
|
*
|
||||||
|
* SCENE=dashboard|monitor|subagent|all BASE=http://localhost:5000 \
|
||||||
|
* OUT=screenshots-readme-real/desktop node scripts/capture-readme-real.mjs
|
||||||
|
*/
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||||
|
const OUT = process.env.OUT || 'screenshots-readme-real/desktop';
|
||||||
|
const SKIN = process.env.SKIN || 'daylight-blue';
|
||||||
|
const SCENE = process.env.SCENE || 'all';
|
||||||
|
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 13)));
|
||||||
|
const VIEWPORT = { width: Number(process.env.VW || 1280), height: Number(process.env.VH || 720) };
|
||||||
|
const DSF = Number(process.env.DSF || 2);
|
||||||
|
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const url = (extra = '') => {
|
||||||
|
const sep = BASE.includes('?') ? '&' : '?';
|
||||||
|
const params = [];
|
||||||
|
if (DSF > 1) params.push('nowebgl'); // DOM renderer → correct font size at DSF>1
|
||||||
|
if (extra) params.push(extra);
|
||||||
|
return params.length ? `${BASE}${sep}${params.join('&')}` : BASE;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function newCtx(browser) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: VIEWPORT,
|
||||||
|
deviceScaleFactor: DSF,
|
||||||
|
ignoreHTTPSErrors: BASE.startsWith('https'),
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(30000);
|
||||||
|
await page.addInitScript(
|
||||||
|
([skin, planUsage, font]) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('codeman:skin', skin);
|
||||||
|
localStorage.setItem('codeman-font-size', String(font));
|
||||||
|
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
|
||||||
|
if (planUsage) blob.showPlanUsageLimits = true;
|
||||||
|
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[SKIN, PLAN_USAGE, FONT]
|
||||||
|
);
|
||||||
|
return { context, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap(page) {
|
||||||
|
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
|
||||||
|
await sleep(1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSessions(page) {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
Array.from(window.app.sessions.values()).map((s) => ({ id: s.id, name: s.name, mode: s.mode }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shoot(page, name) {
|
||||||
|
const out = join(OUT, name);
|
||||||
|
await page.evaluate((f) => {
|
||||||
|
try {
|
||||||
|
if (window.app.setFontSize) window.app.setFontSize(f);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
window.app.fitAddon && window.app.fitAddon.fit();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
|
||||||
|
} catch {}
|
||||||
|
}, FONT);
|
||||||
|
await sleep(1500);
|
||||||
|
await page.screenshot({ path: out, fullPage: false });
|
||||||
|
console.log(' Saved: ' + out);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sceneDashboard(browser) {
|
||||||
|
console.log('Scene: dashboard');
|
||||||
|
const { context, page } = await newCtx(browser);
|
||||||
|
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||||
|
await bootstrap(page);
|
||||||
|
const sessions = await listSessions(page);
|
||||||
|
// Select a claude session so the active terminal shows rich content; all tabs render.
|
||||||
|
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
||||||
|
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
||||||
|
await sleep(4000);
|
||||||
|
await shoot(page, 'multi-session-dashboard.png');
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sceneMonitor(browser) {
|
||||||
|
console.log('Scene: monitor');
|
||||||
|
const { context, page } = await newCtx(browser);
|
||||||
|
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||||
|
await bootstrap(page);
|
||||||
|
const sessions = await listSessions(page);
|
||||||
|
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
||||||
|
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
||||||
|
await sleep(2500);
|
||||||
|
// toggleMonitorPanel() opens the panel, clears the hidden state, loads REAL
|
||||||
|
// mux sessions (/api/mux), starts stats, and renders the task panel.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
await window.app.toggleMonitorPanel();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
await sleep(3000);
|
||||||
|
await shoot(page, 'multi-session-monitor.png');
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sceneSubagent(browser) {
|
||||||
|
console.log('Scene: subagent');
|
||||||
|
const { context, page } = await newCtx(browser);
|
||||||
|
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||||
|
await bootstrap(page);
|
||||||
|
// Select the session whose subagents we want (subagentActiveTabOnly means
|
||||||
|
// app.subagents only fills for the active tab). Prefer SUBAGENT_SID env.
|
||||||
|
const sessions = await listSessions(page);
|
||||||
|
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
|
||||||
|
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
|
||||||
|
// Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents.
|
||||||
|
let agents = [];
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
agents = await page.evaluate(() =>
|
||||||
|
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
|
||||||
|
);
|
||||||
|
if (agents.length >= 1) break;
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
console.log(' live in-browser subagents:', JSON.stringify(agents));
|
||||||
|
if (agents.length === 0) {
|
||||||
|
console.log(' NO live subagents — skipping (stage a longer subagent task and run this while it runs).');
|
||||||
|
await context.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.evaluate(
|
||||||
|
(ids) => {
|
||||||
|
ids.slice(0, 2).forEach((id) => {
|
||||||
|
try {
|
||||||
|
window.app.openSubagentWindow(id);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
agents.map((a) => a.id)
|
||||||
|
);
|
||||||
|
await sleep(2000);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const wins = Array.from(window.app.subagentWindows.values());
|
||||||
|
const place = [
|
||||||
|
{ left: 360, top: 60, w: 430, h: 330 },
|
||||||
|
{ left: 810, top: 60, w: 430, h: 330 },
|
||||||
|
];
|
||||||
|
wins.slice(0, 2).forEach((win, i) => {
|
||||||
|
const el = win.element;
|
||||||
|
const p = place[i];
|
||||||
|
el.style.left = p.left + 'px';
|
||||||
|
el.style.top = p.top + 'px';
|
||||||
|
el.style.width = p.w + 'px';
|
||||||
|
el.style.height = p.h + 'px';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await sleep(1500);
|
||||||
|
await shoot(page, 'subagent-spawn.png');
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||||
|
});
|
||||||
|
console.log(`BASE=${BASE} SKIN=${SKIN} DSF=${DSF} VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height} SCENE=${SCENE}`);
|
||||||
|
if (SCENE === 'dashboard' || SCENE === 'all') await sceneDashboard(browser);
|
||||||
|
if (SCENE === 'monitor' || SCENE === 'all') await sceneMonitor(browser);
|
||||||
|
if (SCENE === 'subagent' || SCENE === 'all') await sceneSubagent(browser);
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('FATAL', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -89,8 +89,13 @@ const main = async () => {
|
|||||||
[SKIN, PLAN_USAGE, FONT]
|
[SKIN, PLAN_USAGE, FONT]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Loading ${BASE} ...`);
|
// At DSF>1, xterm's WebGL renderer draws glyphs at ~2x (see DSF comment above).
|
||||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
// The app honors a `?nowebgl` URL param that switches to xterm's DOM renderer,
|
||||||
|
// which respects devicePixelRatio correctly — so DSF=2 + nowebgl yields a crisp
|
||||||
|
// 2x (retina) capture at the TRUE font size. Auto-enable it whenever DSF>1.
|
||||||
|
const url = DSF > 1 ? `${BASE}${BASE.includes('?') ? '&' : '?'}nowebgl` : BASE;
|
||||||
|
console.log(`Loading ${url} (DSF=${DSF}) ...`);
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
|
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
|
|
||||||
|
|||||||
+94
-3
@@ -30,7 +30,7 @@ import { watch, existsSync, FSWatcher } from 'node:fs';
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { createInterface } from 'node:readline';
|
import { createInterface } from 'node:readline';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename, dirname } from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
|
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
|
||||||
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
|
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
|
||||||
@@ -1181,6 +1181,10 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith('.jsonl')) {
|
if (file.endsWith('.jsonl')) {
|
||||||
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
|
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
|
||||||
|
} else if (file.endsWith('.meta.json')) {
|
||||||
|
// Claude Code (2026-06) writes a `agent-{id}.meta.json` sidecar for TUI
|
||||||
|
// Task subagents and no longer always writes a per-agent `.jsonl` here.
|
||||||
|
await this.registerAgentMeta(join(dir, file), projectHash, sessionId, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1190,13 +1194,23 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// Single directory watcher handles both new files and file content changes
|
// Single directory watcher handles both new files and file content changes
|
||||||
try {
|
try {
|
||||||
const watcher = watch(dir, (_eventType, filename) => {
|
const watcher = watch(dir, (_eventType, filename) => {
|
||||||
if (!filename?.endsWith('.jsonl')) return;
|
const isJsonl = filename?.endsWith('.jsonl');
|
||||||
const filePath = join(dir, filename);
|
const isMeta = filename?.endsWith('.meta.json');
|
||||||
|
if (!isJsonl && !isMeta) return;
|
||||||
|
const filePath = join(dir, filename as string);
|
||||||
|
|
||||||
// Debounce 100ms to batch rapid writes
|
// Debounce 100ms to batch rapid writes
|
||||||
this.fileDeb.schedule(filePath, () => {
|
this.fileDeb.schedule(filePath, () => {
|
||||||
if (!existsSync(filePath)) return;
|
if (!existsSync(filePath)) return;
|
||||||
|
|
||||||
|
if (isMeta) {
|
||||||
|
// Meta sidecar — discovery only (not a transcript; never tail it).
|
||||||
|
if (!this.fileAgentContext.has(filePath)) {
|
||||||
|
this.registerAgentMeta(filePath, projectHash, sessionId).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.fileAgentContext.has(filePath)) {
|
if (this.fileAgentContext.has(filePath)) {
|
||||||
// Known file — handle content change
|
// Known file — handle content change
|
||||||
this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
|
this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
|
||||||
@@ -1356,6 +1370,83 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
this.resetIdleTimer(agentId);
|
this.resetIdleTimer(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a subagent discovered via its `agent-{id}.meta.json` sidecar.
|
||||||
|
*
|
||||||
|
* As of the 2026-06 Claude Code format change, TUI Task subagents write a
|
||||||
|
* `agent-{id}.meta.json` (`{ agentType, description, toolUseId }`) into the
|
||||||
|
* session's `subagents/` dir and no longer reliably write a per-agent
|
||||||
|
* `agent-{id}.jsonl` transcript there. The legacy `.jsonl`-only discovery
|
||||||
|
* therefore saw nothing ("0 tracked"); this surfaces the agent from the
|
||||||
|
* sidecar so it is tracked again. If a real `.jsonl` transcript exists
|
||||||
|
* alongside (older agents / workflow agents), defer to registerAgentFile —
|
||||||
|
* it's richer (live tool-call activity).
|
||||||
|
*
|
||||||
|
* Limitation: a meta-only agent has no transcript to tail, so there is no
|
||||||
|
* live tool-call feed and no completion signal in the sidecar — status is
|
||||||
|
* left 'active' and ages out via the normal idle timer / stale cleanup.
|
||||||
|
*/
|
||||||
|
private async registerAgentMeta(
|
||||||
|
metaPath: string,
|
||||||
|
projectHash: string,
|
||||||
|
sessionId: string,
|
||||||
|
isInitialScan: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.fileAgentContext.has(metaPath)) return;
|
||||||
|
const agentId = basename(metaPath).replace('agent-', '').replace('.meta.json', '');
|
||||||
|
if (this.agentInfo.has(agentId)) return;
|
||||||
|
|
||||||
|
// Prefer a real transcript if one was written alongside the sidecar.
|
||||||
|
const jsonlPath = join(dirname(metaPath), `agent-${agentId}.jsonl`);
|
||||||
|
if (existsSync(jsonlPath)) {
|
||||||
|
await this.registerAgentFile(jsonlPath, projectHash, sessionId, isInitialScan);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileStat;
|
||||||
|
try {
|
||||||
|
fileStat = await statAsync(metaPath);
|
||||||
|
} catch {
|
||||||
|
return; // deleted between discovery and stat
|
||||||
|
}
|
||||||
|
if (isInitialScan && Date.now() - fileStat.mtime.getTime() > STARTUP_MAX_FILE_AGE_MS) {
|
||||||
|
return; // skip stale historical agents on startup
|
||||||
|
}
|
||||||
|
|
||||||
|
let description: string | undefined;
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(await readFile(metaPath, 'utf8')) as { agentType?: string; description?: string };
|
||||||
|
description = meta.description || meta.agentType;
|
||||||
|
} catch {
|
||||||
|
return; // unreadable / not yet fully written — a later watch event retries
|
||||||
|
}
|
||||||
|
if (this.isInternalAgent(description)) return;
|
||||||
|
|
||||||
|
const info: SubagentInfo = {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
projectHash,
|
||||||
|
filePath: metaPath,
|
||||||
|
startedAt: fileStat.birthtime.toISOString(),
|
||||||
|
lastActivityAt: fileStat.mtime.getTime(),
|
||||||
|
status: 'active',
|
||||||
|
toolCallCount: 0,
|
||||||
|
entryCount: 0,
|
||||||
|
fileSize: fileStat.size,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.agentInfo.size >= MAX_TRACKED_AGENTS) {
|
||||||
|
const oldestId = this.findOldestInactiveAgent();
|
||||||
|
if (oldestId) this.removeAgent(oldestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fileAgentContext.set(metaPath, { projectHash, sessionId });
|
||||||
|
this.agentInfo.set(agentId, info);
|
||||||
|
this.emit('subagent:discovered', info);
|
||||||
|
this.resetIdleTimer(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tail a file from a specific position
|
* Tail a file from a specific position
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -404,6 +404,77 @@ describe('SubagentWatcher', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Meta-sidecar discovery (2026-06 Claude Code format)', () => {
|
||||||
|
it('should discover a subagent from agent-{id}.meta.json when no .jsonl exists', async () => {
|
||||||
|
// New format: TUI Task subagents write only a meta sidecar (no per-agent .jsonl).
|
||||||
|
mockExistsSync.mockImplementation((p: string) => !String(p).endsWith('.jsonl'));
|
||||||
|
mockReaddirSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes('subagents')) return ['agent-meta1.meta.json'];
|
||||||
|
if (path.includes('session1')) return ['subagents'];
|
||||||
|
if (path.includes('project1')) return ['session1'];
|
||||||
|
return ['project1'];
|
||||||
|
});
|
||||||
|
mockStatSync.mockReturnValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
birthtime: new Date(),
|
||||||
|
mtime: new Date(),
|
||||||
|
size: 120,
|
||||||
|
});
|
||||||
|
mockReadFileSync.mockReturnValue(
|
||||||
|
JSON.stringify({ agentType: 'general-purpose', description: 'Audit server.js', toolUseId: 'toolu_x' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const discoveredHandler = vi.fn();
|
||||||
|
watcher.on('subagent:discovered', discoveredHandler);
|
||||||
|
|
||||||
|
watcher.start();
|
||||||
|
await flushAsyncScan();
|
||||||
|
|
||||||
|
expect(discoveredHandler).toHaveBeenCalled();
|
||||||
|
const info = discoveredHandler.mock.calls[0][0] as SubagentInfo;
|
||||||
|
expect(info.agentId).toBe('meta1');
|
||||||
|
expect(info.description).toBe('Audit server.js');
|
||||||
|
expect(info.status).toBe('active');
|
||||||
|
expect(watcher.getSubagents()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer the real .jsonl transcript when one exists alongside the meta', async () => {
|
||||||
|
// Both sidecar and transcript present → defer to the richer .jsonl path.
|
||||||
|
mockExistsSync.mockReturnValue(true); // sibling agent-both.jsonl exists
|
||||||
|
mockReaddirSync.mockImplementation((path: string) => {
|
||||||
|
if (path.includes('subagents')) return ['agent-both.meta.json'];
|
||||||
|
if (path.includes('session1')) return ['subagents'];
|
||||||
|
if (path.includes('project1')) return ['session1'];
|
||||||
|
return ['project1'];
|
||||||
|
});
|
||||||
|
mockStatSync.mockReturnValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
birthtime: new Date(),
|
||||||
|
mtime: new Date(),
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
// Mirror the passing discovery tests: leave createReadStream unmocked so
|
||||||
|
// _resolveDescription fails gracefully (undefined) and discovery still fires.
|
||||||
|
mockReadFileSync.mockReturnValue('');
|
||||||
|
const mockRl = createMockRl();
|
||||||
|
mockCreateInterface.mockReturnValue(mockRl);
|
||||||
|
|
||||||
|
const discoveredHandler = vi.fn();
|
||||||
|
watcher.on('subagent:discovered', discoveredHandler);
|
||||||
|
|
||||||
|
watcher.start();
|
||||||
|
await flushAsyncScan();
|
||||||
|
mockRl.emit('close');
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
expect(discoveredHandler).toHaveBeenCalled();
|
||||||
|
const info = discoveredHandler.mock.calls[0][0] as SubagentInfo;
|
||||||
|
expect(info.agentId).toBe('both');
|
||||||
|
// filePath points at the transcript, not the sidecar.
|
||||||
|
expect(info.filePath.endsWith('.jsonl')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Status Lifecycle', () => {
|
describe('Status Lifecycle', () => {
|
||||||
it('should start agents as active', async () => {
|
it('should start agents as active', async () => {
|
||||||
const mockRl = createMockRl();
|
const mockRl = createMockRl();
|
||||||
|
|||||||
Reference in New Issue
Block a user