diff --git a/CLAUDE.md b/CLAUDE.md index 0ce4668b..5a832ec3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs. -**Version**: 0.1358 (must match `package.json`) +**Version**: 0.1359 (must match `package.json`) **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty @@ -102,10 +102,16 @@ npx vitest run -t "should create session" # By pattern | 3120 | session-cleanup.test.ts | | 3125 | ralph-integration.test.ts | | 3127 | respawn-integration.test.ts (reserved) | +| 3130 | hooks-config.test.ts (Hook Event API) | +| 3131 | hooks-config.test.ts (Hook Data Sanitization) | +| 3150 | browser-e2e.test.ts (main browser tests) | +| 3151 | browser-e2e.test.ts (SSE events tests) | +| 3152 | browser-e2e.test.ts (hook events tests) | +| 3153 | browser-e2e.test.ts (Ralph panel tests) | -**Next available port**: 3128 +**Next available port**: 3154 -Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator, hooks-config, ai-idle-checker, ai-plan-checker +Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator, ai-idle-checker, ai-plan-checker **Test Utilities**: `test/respawn-test-utils.ts` provides MockSession, MockAiIdleChecker, MockAiPlanChecker, time controller, state tracker, and event recorder for respawn controller testing. See `test/respawn-test-plan.md` for architecture and `test/respawn-scenarios.md` for comprehensive test scenarios. diff --git a/package.json b/package.json index 2b235c99..3e210c0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1358", + "version": "0.1359", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 9a5fd392..ed983c91 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -61,7 +61,7 @@ export interface SubagentTranscriptEntry { sessionId: string; message?: { role: string; - content: Array<{ + content: string | Array<{ type: 'text' | 'tool_use' | 'tool_result'; text?: string; name?: string; @@ -78,6 +78,7 @@ export interface SubagentTranscriptEntry { export interface SubagentEvents { 'subagent:discovered': (info: SubagentInfo) => void; + 'subagent:updated': (info: SubagentInfo) => void; 'subagent:tool_call': (data: SubagentToolCall) => void; 'subagent:progress': (data: SubagentProgress) => void; 'subagent:message': (data: SubagentMessage) => void; @@ -377,24 +378,41 @@ export class SubagentWatcher extends EventEmitter { if (entry.type === 'progress' && entry.data) { lines.push(this.formatProgress(entry)); } else if (entry.type === 'assistant' && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === 'tool_use' && content.name) { - lines.push(this.formatToolCall(entry.timestamp, content.name, content.input || {})); - } else if (content.type === 'text' && content.text) { - const text = content.text.trim(); - if (text.length > 0) { - const preview = text.length > 200 ? text.substring(0, 200) + '...' : text; - lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); + // Handle both string and array content formats + if (typeof entry.message.content === 'string') { + const text = entry.message.content.trim(); + if (text.length > 0) { + const preview = text.length > 200 ? text.substring(0, 200) + '...' : text; + lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); + } + } else { + for (const content of entry.message.content) { + if (content.type === 'tool_use' && content.name) { + lines.push(this.formatToolCall(entry.timestamp, content.name, content.input || {})); + } else if (content.type === 'text' && content.text) { + const text = content.text.trim(); + if (text.length > 0) { + const preview = text.length > 200 ? text.substring(0, 200) + '...' : text; + lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); + } } } } } else if (entry.type === 'user' && entry.message?.content) { - const firstContent = entry.message.content[0]; - if (firstContent?.type === 'text' && firstContent.text) { - const text = firstContent.text.trim(); + // Handle both string and array content formats + if (typeof entry.message.content === 'string') { + const text = entry.message.content.trim(); if (text.length < 100 && !text.includes('{')) { lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, 80)}`); } + } else { + const firstContent = entry.message.content[0]; + if (firstContent?.type === 'text' && firstContent.text) { + const text = firstContent.text.trim(); + if (text.length < 100 && !text.includes('{')) { + lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, 80)}`); + } + } } } } @@ -469,6 +487,41 @@ export class SubagentWatcher extends EventEmitter { return truncated.replace(/[.!?,:\s]+$/, ''); } + /** + * Extract description from agent file by finding first user message + */ + private extractDescriptionFromFile(filePath: string): string | undefined { + try { + const content = readFileSync(filePath, 'utf8'); + const lines = content.split('\n').filter((l) => l.trim()); + + for (const line of lines.slice(0, 5)) { + try { + const entry = JSON.parse(line); + if (entry.type === 'user' && entry.message?.content) { + let text: string | undefined; + if (typeof entry.message.content === 'string') { + text = entry.message.content.trim(); + } else if (Array.isArray(entry.message.content)) { + const firstContent = entry.message.content[0]; + if (firstContent?.type === 'text' && firstContent.text) { + text = firstContent.text.trim(); + } + } + if (text) { + return this.extractSmartTitle(text); + } + } + } catch { + // Skip malformed lines + } + } + } catch { + // Failed to read file + } + return undefined; + } + /** * Scan for all subagent directories */ @@ -530,14 +583,20 @@ export class SubagentWatcher extends EventEmitter { return; } - // Watch for new files + // Watch for new files with debounce to allow content to be written try { const watcher = watch(dir, (_eventType, filename) => { if (filename?.endsWith('.jsonl')) { const filePath = join(dir, filename); - if (existsSync(filePath)) { - this.watchAgentFile(filePath, projectHash, sessionId); - } + // Wait 100ms for file content to be written before processing + // Even if file is empty after debounce, we still watch it - the + // description retry mechanisms in processEntry and the file change + // handler will extract description when content arrives + setTimeout(() => { + if (existsSync(filePath)) { + this.watchAgentFile(filePath, projectHash, sessionId); + } + }, 100); } }); @@ -637,6 +696,15 @@ export class SubagentWatcher extends EventEmitter { // Stat failed } + // Retry description extraction if missing (race condition fix) + if (!existingInfo.description) { + const extractedDescription = this.extractDescriptionFromFile(filePath); + if (extractedDescription) { + existingInfo.description = extractedDescription; + this.emit('subagent:updated', existingInfo); + } + } + // Reset idle timer this.resetIdleTimer(agentId); } @@ -666,11 +734,12 @@ export class SubagentWatcher extends EventEmitter { const rl = createInterface({ input: stream }); rl.on('line', (line) => { - position += Buffer.byteLength(line, 'utf8') + 1; + const lineBytes = Buffer.byteLength(line, 'utf8') + 1; try { const entry = JSON.parse(line) as SubagentTranscriptEntry; this.processEntry(entry, agentId, sessionId); + position += lineBytes; // Only advance on successful parse // Update entry count const info = this.agentInfo.get(agentId); @@ -678,7 +747,7 @@ export class SubagentWatcher extends EventEmitter { info.entryCount++; } } catch { - // Skip malformed lines + // Don't advance position - will retry on next change event } }); @@ -696,6 +765,24 @@ export class SubagentWatcher extends EventEmitter { * Process a transcript entry and emit appropriate events */ private processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void { + // Check if this is first user message and description is missing + const info = this.agentInfo.get(agentId); + if (info && !info.description && entry.type === 'user' && entry.message?.content) { + let text: string | undefined; + if (typeof entry.message.content === 'string') { + text = entry.message.content.trim(); + } else if (Array.isArray(entry.message.content)) { + const firstContent = entry.message.content[0]; + if (firstContent?.type === 'text' && firstContent.text) { + text = firstContent.text.trim(); + } + } + if (text) { + info.description = this.extractSmartTitle(text); + this.emit('subagent:updated', info); + } + } + if (entry.type === 'progress' && entry.data) { const progress: SubagentProgress = { agentId, @@ -707,50 +794,71 @@ export class SubagentWatcher extends EventEmitter { }; this.emit('subagent:progress', progress); } else if (entry.type === 'assistant' && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === 'tool_use' && content.name) { - const toolCall: SubagentToolCall = { - agentId, - sessionId, - timestamp: entry.timestamp, - tool: content.name, - input: content.input || {}, - }; - this.emit('subagent:tool_call', toolCall); - - // Update tool call count - const info = this.agentInfo.get(agentId); - if (info) { - info.toolCallCount++; - } - } else if (content.type === 'text' && content.text) { - const text = content.text.trim(); - if (text.length > 0) { - const message: SubagentMessage = { - agentId, - sessionId, - timestamp: entry.timestamp, - role: 'assistant', - text: text.substring(0, 500), // Limit text length - }; - this.emit('subagent:message', message); - } - } - } - } else if (entry.type === 'user' && entry.message?.content) { - const firstContent = entry.message.content[0]; - if (firstContent?.type === 'text' && firstContent.text) { - const text = firstContent.text.trim(); - if (text.length > 0 && text.length < 500) { + // Handle both string and array content formats + if (typeof entry.message.content === 'string') { + const text = entry.message.content.trim(); + if (text.length > 0) { const message: SubagentMessage = { agentId, sessionId, timestamp: entry.timestamp, - role: 'user', - text, + role: 'assistant', + text: text.substring(0, 500), }; this.emit('subagent:message', message); } + } else { + for (const content of entry.message.content) { + if (content.type === 'tool_use' && content.name) { + const toolCall: SubagentToolCall = { + agentId, + sessionId, + timestamp: entry.timestamp, + tool: content.name, + input: content.input || {}, + }; + this.emit('subagent:tool_call', toolCall); + + // Update tool call count + const agentInfo = this.agentInfo.get(agentId); + if (agentInfo) { + agentInfo.toolCallCount++; + } + } else if (content.type === 'text' && content.text) { + const text = content.text.trim(); + if (text.length > 0) { + const message: SubagentMessage = { + agentId, + sessionId, + timestamp: entry.timestamp, + role: 'assistant', + text: text.substring(0, 500), // Limit text length + }; + this.emit('subagent:message', message); + } + } + } + } + } else if (entry.type === 'user' && entry.message?.content) { + // Handle both string and array content formats + let userText: string | undefined; + if (typeof entry.message.content === 'string') { + userText = entry.message.content.trim(); + } else { + const firstContent = entry.message.content[0]; + if (firstContent?.type === 'text' && firstContent.text) { + userText = firstContent.text.trim(); + } + } + if (userText && userText.length > 0 && userText.length < 500) { + const message: SubagentMessage = { + agentId, + sessionId, + timestamp: entry.timestamp, + role: 'user', + text: userText, + }; + this.emit('subagent:message', message); } } } diff --git a/src/web/public/app.js b/src/web/public/app.js index 777850eb..f0c8d47d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1308,6 +1308,23 @@ class ClaudemanApp { } }); + this.eventSource.addEventListener('subagent:updated', (e) => { + const data = JSON.parse(e.data); + const existing = this.subagents.get(data.agentId); + if (existing) { + // Merge updated fields (especially description) + Object.assign(existing, data); + this.subagents.set(data.agentId, existing); + } else { + this.subagents.set(data.agentId, data); + } + this.renderSubagentPanel(); + // Update floating window if open + if (this.subagentWindows.has(data.agentId)) { + this.renderSubagentWindowContent(data.agentId); + } + }); + this.eventSource.addEventListener('subagent:tool_call', (e) => { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; diff --git a/src/web/server.ts b/src/web/server.ts index 5e3ea1af..96a90c72 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -303,6 +303,10 @@ export class WebServer extends EventEmitter { this.broadcast('subagent:discovered', info); }); + subagentWatcher.on('subagent:updated', (info: SubagentInfo) => { + this.broadcast('subagent:updated', info); + }); + subagentWatcher.on('subagent:tool_call', (data: SubagentToolCall) => { this.broadcast('subagent:tool_call', data); }); diff --git a/test/subagent-watcher.test.ts b/test/subagent-watcher.test.ts new file mode 100644 index 00000000..17bf5e9d --- /dev/null +++ b/test/subagent-watcher.test.ts @@ -0,0 +1,1734 @@ +/** + * @fileoverview Tests for subagent-watcher module + * + * Tests the SubagentWatcher class which monitors Claude Code background agents + * by watching ~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl files. + * + * These are unit tests that mock filesystem operations to test the parsing and + * event emission logic without requiring real subagent files. + */ + +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { EventEmitter } from 'events'; + +// Increase max listeners for test mock EventEmitters to suppress warnings +// These warnings come from reusing mock EventEmitters across many tests +EventEmitter.defaultMaxListeners = 100; + +// Mock the fs module before importing SubagentWatcher +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + existsSync: vi.fn(), + statSync: vi.fn(), + readdirSync: vi.fn(), + readFileSync: vi.fn(), + watch: vi.fn(), + createReadStream: vi.fn(), + }; +}); + +vi.mock('readline', () => ({ + createInterface: vi.fn(), +})); + +vi.mock('child_process', () => ({ + execSync: vi.fn(), +})); + +// Import after mocking +import { + SubagentWatcher, + SubagentInfo, + SubagentToolCall, + SubagentProgress, + SubagentMessage, + SubagentTranscriptEntry, +} from '../src/subagent-watcher.js'; +import * as fs from 'fs'; +import { createInterface } from 'readline'; +import { execSync } from 'child_process'; + +// Helper to create mock JSONL entries as Claude Code produces them +function createUserEntry(text: string, timestamp?: string): string { + return JSON.stringify({ + type: 'user', + timestamp: timestamp || new Date().toISOString(), + message: { + role: 'user', + content: [{ type: 'text', text }], + }, + }); +} + +function createAssistantTextEntry(text: string, timestamp?: string): string { + return JSON.stringify({ + type: 'assistant', + timestamp: timestamp || new Date().toISOString(), + message: { + role: 'assistant', + content: [{ type: 'text', text }], + }, + }); +} + +function createToolUseEntry( + toolName: string, + input: Record, + timestamp?: string +): string { + return JSON.stringify({ + type: 'assistant', + timestamp: timestamp || new Date().toISOString(), + message: { + role: 'assistant', + content: [{ type: 'tool_use', name: toolName, input }], + }, + }); +} + +function createProgressEntry(progressType: string, data: Record, timestamp?: string): string { + return JSON.stringify({ + type: 'progress', + timestamp: timestamp || new Date().toISOString(), + data: { type: progressType, ...data }, + }); +} + +function createToolResultEntry(content: string, timestamp?: string): string { + return JSON.stringify({ + type: 'assistant', + timestamp: timestamp || new Date().toISOString(), + message: { + role: 'assistant', + content: [{ type: 'tool_result', content }], + }, + }); +} + +describe('SubagentWatcher', () => { + let watcher: SubagentWatcher; + let mockExistsSync: Mock; + let mockStatSync: Mock; + let mockReaddirSync: Mock; + let mockReadFileSync: Mock; + let mockWatch: Mock; + let mockCreateReadStream: Mock; + let mockCreateInterface: Mock; + let mockExecSync: Mock; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + mockExistsSync = fs.existsSync as Mock; + mockStatSync = fs.statSync as Mock; + mockReaddirSync = fs.readdirSync as Mock; + mockReadFileSync = fs.readFileSync as Mock; + mockWatch = fs.watch as Mock; + mockCreateReadStream = (fs as unknown as { createReadStream: Mock }).createReadStream; + mockCreateInterface = createInterface as Mock; + mockExecSync = execSync as Mock; + + // Default mocks - no projects exist + mockExistsSync.mockReturnValue(false); + mockStatSync.mockReturnValue({ + isDirectory: () => true, + birthtime: new Date(), + mtime: new Date(), + size: 0, + }); + mockReaddirSync.mockReturnValue([]); + mockReadFileSync.mockReturnValue(''); + mockWatch.mockReturnValue({ close: vi.fn() }); + mockExecSync.mockImplementation(() => { + throw new Error('No processes'); + }); + + // Create new watcher for each test + watcher = new SubagentWatcher(); + }); + + afterEach(() => { + watcher.stop(); + vi.useRealTimers(); + }); + + describe('Initialization', () => { + it('should start with no subagents', () => { + expect(watcher.getSubagents()).toHaveLength(0); + }); + + it('should not be running initially', () => { + expect(watcher.isRunning()).toBe(false); + }); + + it('should be running after start()', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([]); + + watcher.start(); + + expect(watcher.isRunning()).toBe(true); + }); + + it('should not be running after stop()', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([]); + + watcher.start(); + expect(watcher.isRunning()).toBe(true); + + watcher.stop(); + expect(watcher.isRunning()).toBe(false); + }); + + it('should not start twice', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([]); + + watcher.start(); + watcher.start(); // Should be no-op + + expect(watcher.isRunning()).toBe(true); + }); + }); + + describe('JSONL Parsing', () => { + it('should parse valid JSONL entries', async () => { + const validEntry = createUserEntry('Test task description'); + const lines = [validEntry]; + + // Setup mock readline interface + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + // Setup file discovery + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-abc123.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(lines.join('\n')); + + const discoveredHandler = vi.fn(); + watcher.on('subagent:discovered', discoveredHandler); + + watcher.start(); + + // Simulate readline events + for (const line of lines) { + mockRl.emit('line', line); + } + mockRl.emit('close'); + + // Wait for async processing + await vi.advanceTimersByTimeAsync(100); + + expect(discoveredHandler).toHaveBeenCalled(); + const info = discoveredHandler.mock.calls[0][0] as SubagentInfo; + expect(info.agentId).toBe('abc123'); + }); + + it('should skip malformed JSON lines', async () => { + const validEntry = createUserEntry('Valid entry'); + const malformedLines = [ + 'not json at all', + '{"incomplete": true', + validEntry, + '}{bad json}{', + ]; + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-test1.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(malformedLines.join('\n')); + + const errorHandler = vi.fn(); + watcher.on('subagent:error', errorHandler); + + watcher.start(); + + // Emit all lines including malformed ones + for (const line of malformedLines) { + mockRl.emit('line', line); + } + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Should not emit error for malformed lines (they're silently skipped) + // This is the expected behavior per the implementation + expect(errorHandler).not.toHaveBeenCalled(); + }); + + it('should handle partial writes gracefully', async () => { + // Simulate partial write where line is incomplete + const partialContent = '{"type": "user", "timestamp": "2024-01-01T00:00:00Z"'; + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-partial.jsonl']; + 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: 50, + }); + mockReadFileSync.mockReturnValue(partialContent); + + const messageHandler = vi.fn(); + watcher.on('subagent:message', messageHandler); + + watcher.start(); + + // Emit partial line + mockRl.emit('line', partialContent); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Should not crash and should not emit message for incomplete JSON + expect(messageHandler).not.toHaveBeenCalled(); + }); + + it('should handle empty lines', async () => { + const validEntry = createUserEntry('Valid'); + const contentWithEmptyLines = ['', validEntry, ' ', '', validEntry].join('\n'); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-empty.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(contentWithEmptyLines); + + watcher.start(); + + // Emit lines including empty ones + mockRl.emit('line', ''); + mockRl.emit('line', validEntry); + mockRl.emit('line', ' '); + mockRl.emit('line', ''); + mockRl.emit('line', validEntry); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Should not crash on empty lines + expect(watcher.getSubagents()).toHaveLength(1); + }); + }); + + describe('Status Lifecycle', () => { + it('should start agents as active', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-active.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + const discoveredHandler = vi.fn(); + watcher.on('subagent:discovered', discoveredHandler); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + expect(discoveredHandler).toHaveBeenCalled(); + const info = discoveredHandler.mock.calls[0][0] as SubagentInfo; + expect(info.status).toBe('active'); + }); + + it('should transition to idle after IDLE_TIMEOUT_MS (30s)', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-idle.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Initially active + const agents = watcher.getSubagents(); + expect(agents).toHaveLength(1); + expect(agents[0].status).toBe('active'); + + // Advance past IDLE_TIMEOUT_MS (30000ms) + await vi.advanceTimersByTimeAsync(31000); + + // Should now be idle + const agentsAfter = watcher.getSubagents(); + expect(agentsAfter[0].status).toBe('idle'); + }); + + it('should reset to active on new activity', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + const mockWatcher = { close: vi.fn() }; + mockWatch.mockReturnValue(mockWatcher); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-reactive.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Go to idle + await vi.advanceTimersByTimeAsync(31000); + expect(watcher.getSubagents()[0].status).toBe('idle'); + + // Simulate file change event - get the callback from mockWatch + const watchCallback = mockWatch.mock.calls.find( + (call: unknown[]) => typeof call[1] === 'function' + )?.[1]; + + if (watchCallback) { + // Need to reset the readline mock for the new read + const newMockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(newMockRl); + + // Trigger file change + watchCallback('change', 'agent-reactive.jsonl'); + + // Complete the new readline + newMockRl.emit('close'); + await vi.advanceTimersByTimeAsync(100); + + // Should be active again + expect(watcher.getSubagents()[0].status).toBe('active'); + } + }); + + it('should transition to completed when file becomes stale', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-stale.jsonl']; + if (path.includes('session1')) return ['subagents']; + if (path.includes('project1')) return ['session1']; + return ['project1']; + }); + + // Start with recent mtime + const recentTime = new Date(); + mockStatSync.mockReturnValue({ + isDirectory: () => true, + birthtime: recentTime, + mtime: recentTime, + size: 100, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + const completedHandler = vi.fn(); + watcher.on('subagent:completed', completedHandler); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Change statSync to return stale mtime (> 60s ago) + const staleTime = new Date(Date.now() - 120000); // 2 minutes ago + mockStatSync.mockReturnValue({ + isDirectory: () => true, + birthtime: staleTime, + mtime: staleTime, + size: 100, + }); + + // Advance to trigger liveness check (10s interval) + await vi.advanceTimersByTimeAsync(11000); + + expect(completedHandler).toHaveBeenCalled(); + expect(watcher.getSubagents()[0].status).toBe('completed'); + }); + }); + + describe('Transcript Extraction', () => { + it('should extract tool_use entries', async () => { + const toolEntry = createToolUseEntry('WebSearch', { query: 'test query' }); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-tools.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(toolEntry); + + const toolCallHandler = vi.fn(); + watcher.on('subagent:tool_call', toolCallHandler); + + watcher.start(); + mockRl.emit('line', toolEntry); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + expect(toolCallHandler).toHaveBeenCalled(); + const call = toolCallHandler.mock.calls[0][0] as SubagentToolCall; + expect(call.tool).toBe('WebSearch'); + expect(call.input).toEqual({ query: 'test query' }); + }); + + it('should extract text messages', async () => { + const textEntry = createAssistantTextEntry('This is the assistant response'); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-text.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(textEntry); + + const messageHandler = vi.fn(); + watcher.on('subagent:message', messageHandler); + + watcher.start(); + mockRl.emit('line', textEntry); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + expect(messageHandler).toHaveBeenCalled(); + const msg = messageHandler.mock.calls[0][0] as SubagentMessage; + expect(msg.role).toBe('assistant'); + expect(msg.text).toBe('This is the assistant response'); + }); + + it('should handle both string and array content formats', async () => { + // Array format (standard) + const arrayFormat = JSON.stringify({ + type: 'user', + timestamp: new Date().toISOString(), + message: { + role: 'user', + content: [{ type: 'text', text: 'Array format message' }], + }, + }); + + // String format (alternative) + const stringFormat = JSON.stringify({ + type: 'user', + timestamp: new Date().toISOString(), + message: { + role: 'user', + content: 'String format message', + }, + }); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-formats.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue([arrayFormat, stringFormat].join('\n')); + + const discoveredHandler = vi.fn(); + watcher.on('subagent:discovered', discoveredHandler); + + watcher.start(); + mockRl.emit('line', arrayFormat); + mockRl.emit('line', stringFormat); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Both formats should be handled without errors + expect(discoveredHandler).toHaveBeenCalled(); + }); + + it('should limit message length to 500 chars', async () => { + const longText = 'x'.repeat(1000); + const textEntry = createAssistantTextEntry(longText); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-long.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(textEntry); + + const messageHandler = vi.fn(); + watcher.on('subagent:message', messageHandler); + + watcher.start(); + mockRl.emit('line', textEntry); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + expect(messageHandler).toHaveBeenCalled(); + const msg = messageHandler.mock.calls[0][0] as SubagentMessage; + expect(msg.text.length).toBe(500); + }); + + it('should extract progress events', async () => { + const progressEntry = createProgressEntry('query_update', { query: 'searching for files' }); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-progress.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(progressEntry); + + const progressHandler = vi.fn(); + watcher.on('subagent:progress', progressHandler); + + watcher.start(); + mockRl.emit('line', progressEntry); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + expect(progressHandler).toHaveBeenCalled(); + const progress = progressHandler.mock.calls[0][0] as SubagentProgress; + expect(progress.progressType).toBe('query_update'); + expect(progress.query).toBe('searching for files'); + }); + }); + + describe('Memory Management', () => { + it('should track agents in agentInfo map', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-mem1.jsonl', 'agent-mem2.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + const agents = watcher.getSubagents(); + expect(agents.length).toBeGreaterThanOrEqual(1); + + // Can retrieve by ID + const agent = watcher.getSubagent('mem1'); + expect(agent).toBeDefined(); + }); + + it('should increment toolCallCount on tool use', async () => { + const toolEntry1 = createToolUseEntry('Read', { file_path: '/test1.ts' }); + const toolEntry2 = createToolUseEntry('Write', { file_path: '/test2.ts' }); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-toolcount.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue([toolEntry1, toolEntry2].join('\n')); + + watcher.start(); + mockRl.emit('line', toolEntry1); + mockRl.emit('line', toolEntry2); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + const agent = watcher.getSubagent('toolcount'); + expect(agent).toBeDefined(); + expect(agent!.toolCallCount).toBe(2); + }); + + it('should increment entryCount for each entry', async () => { + const entries = [ + createUserEntry('User message'), + createAssistantTextEntry('Response'), + createToolUseEntry('Read', { file_path: '/test.ts' }), + ]; + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-entrycount.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(entries.join('\n')); + + watcher.start(); + for (const entry of entries) { + mockRl.emit('line', entry); + } + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + const agent = watcher.getSubagent('entrycount'); + expect(agent).toBeDefined(); + expect(agent!.entryCount).toBe(3); + }); + + // Note: Current implementation has no cleanup/eviction policy + // This documents the behavior as a known issue + it('should retain all agents indefinitely (no cleanup policy)', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + + // Simulate many agents + const agentFiles = Array.from({ length: 100 }, (_, i) => `agent-many${i}.jsonl`); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return agentFiles; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // All agents should be retained (no cleanup) + // Note: This is a known memory management issue + expect(watcher.getSubagents().length).toBeGreaterThan(0); + }); + }); + + describe('getTranscript', () => { + it('should return transcript entries', async () => { + const entries = [ + createUserEntry('Task 1'), + createAssistantTextEntry('Working on it'), + createToolUseEntry('Read', { file_path: '/test.ts' }), + ]; + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-transcript.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(entries.join('\n')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + const transcript = await watcher.getTranscript('transcript'); + expect(transcript.length).toBe(3); + }); + + it('should limit transcript entries when limit is specified', async () => { + const entries = Array.from({ length: 10 }, (_, i) => + createUserEntry(`Message ${i}`) + ); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-limited.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(entries.join('\n')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + const transcript = await watcher.getTranscript('limited', 3); + expect(transcript.length).toBe(3); + }); + + it('should return empty array for unknown agent', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([]); + + watcher.start(); + + const transcript = await watcher.getTranscript('nonexistent'); + expect(transcript).toEqual([]); + }); + }); + + describe('formatTranscript', () => { + it('should format tool calls with icons', () => { + const entries: SubagentTranscriptEntry[] = [ + { + type: 'assistant', + timestamp: '2024-01-01T00:00:00Z', + agentId: 'test', + sessionId: 'sess1', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'WebSearch', input: { query: 'test query' } }, + ], + }, + }, + ]; + + const formatted = watcher.formatTranscript(entries); + expect(formatted.length).toBe(1); + expect(formatted[0]).toContain('WebSearch'); + expect(formatted[0]).toContain('test query'); + }); + + it('should format progress events', () => { + const entries: SubagentTranscriptEntry[] = [ + { + type: 'progress', + timestamp: '2024-01-01T00:00:00Z', + agentId: 'test', + sessionId: 'sess1', + data: { type: 'query_update', query: 'searching' }, + }, + ]; + + const formatted = watcher.formatTranscript(entries); + expect(formatted.length).toBe(1); + expect(formatted[0]).toContain('Searching'); + }); + + it('should truncate long text messages', () => { + const longText = 'x'.repeat(300); + const entries: SubagentTranscriptEntry[] = [ + { + type: 'assistant', + timestamp: '2024-01-01T00:00:00Z', + agentId: 'test', + sessionId: 'sess1', + message: { + role: 'assistant', + content: [{ type: 'text', text: longText }], + }, + }, + ]; + + const formatted = watcher.formatTranscript(entries); + expect(formatted.length).toBe(1); + expect(formatted[0]).toContain('...'); + expect(formatted[0].length).toBeLessThan(300); + }); + }); + + describe('Description Extraction', () => { + it('should extract description from first user message', async () => { + const userEntry = createUserEntry('Create comprehensive tests for the module'); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-desc.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(userEntry); + + const discoveredHandler = vi.fn(); + watcher.on('subagent:discovered', discoveredHandler); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + expect(discoveredHandler).toHaveBeenCalled(); + const info = discoveredHandler.mock.calls[0][0] as SubagentInfo; + expect(info.description).toBeDefined(); + expect(info.description).toContain('Create'); + }); + + it('should truncate long descriptions (extractSmartTitle)', async () => { + const longPrompt = + 'Please create a very detailed and comprehensive implementation of the feature ' + + 'including all edge cases, error handling, documentation, and thorough test coverage ' + + 'for every single function and method in the module'; + + const userEntry = createUserEntry(longPrompt); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-longdesc.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(userEntry); + + const discoveredHandler = vi.fn(); + watcher.on('subagent:discovered', discoveredHandler); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + expect(discoveredHandler).toHaveBeenCalled(); + const info = discoveredHandler.mock.calls[0][0] as SubagentInfo; + expect(info.description).toBeDefined(); + expect(info.description!.length).toBeLessThanOrEqual(45); + }); + + it('should emit subagent:updated when description is extracted from processEntry', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-update1.jsonl']; + 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, + }); + + // Initially return empty content (simulating race condition) + mockReadFileSync.mockReturnValue(''); + + const discoveredHandler = vi.fn(); + const updatedHandler = vi.fn(); + watcher.on('subagent:discovered', discoveredHandler); + watcher.on('subagent:updated', updatedHandler); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Should be discovered without description + expect(discoveredHandler).toHaveBeenCalled(); + const initialInfo = discoveredHandler.mock.calls[0][0] as SubagentInfo; + expect(initialInfo.description).toBeUndefined(); + + // Simulate file change - now emit user entry line (processEntry path) + const userEntry = createUserEntry('Create unit tests for subagent watcher'); + mockRl.emit('line', userEntry); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Should emit updated event with description + expect(updatedHandler).toHaveBeenCalled(); + const updatedInfo = updatedHandler.mock.calls[0][0] as SubagentInfo; + expect(updatedInfo.description).toBeDefined(); + expect(updatedInfo.description).toContain('Create unit tests'); + }); + }); + + describe('getRecentSubagents', () => { + it('should return only recent subagents', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-recent.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + const recent = watcher.getRecentSubagents(60); + expect(recent.length).toBe(1); + }); + + it('should sort by lastActivityAt descending', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-sort1.jsonl', 'agent-sort2.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + const recent = watcher.getRecentSubagents(60); + // Should be sorted by most recent first + if (recent.length > 1) { + const time1 = new Date(recent[0].lastActivityAt).getTime(); + const time2 = new Date(recent[1].lastActivityAt).getTime(); + expect(time1).toBeGreaterThanOrEqual(time2); + } + }); + }); + + describe('getSubagentsForSession', () => { + it('should filter subagents by working directory', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-session.jsonl']; + if (path.includes('session1')) return ['subagents']; + if (path.includes('-home-user-project')) return ['session1']; + return ['-home-user-project']; + }); + mockStatSync.mockReturnValue({ + isDirectory: () => true, + birthtime: new Date(), + mtime: new Date(), + size: 100, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Get subagents for the matching working directory + const agents = watcher.getSubagentsForSession('/home/user/project'); + expect(agents.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('killSubagent', () => { + it('should return false for unknown agent', async () => { + const result = await watcher.killSubagent('nonexistent'); + expect(result).toBe(false); + }); + + it('should return false for already completed agent', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-killcomplete.jsonl']; + if (path.includes('session1')) return ['subagents']; + if (path.includes('project1')) return ['session1']; + return ['project1']; + }); + + // Return stale file to trigger completion + const staleTime = new Date(Date.now() - 120000); + mockStatSync.mockReturnValue({ + isDirectory: () => true, + birthtime: staleTime, + mtime: staleTime, + size: 100, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Wait for liveness check to mark as completed + await vi.advanceTimersByTimeAsync(11000); + + const result = await watcher.killSubagent('killcomplete'); + expect(result).toBe(false); + }); + + it('should emit completed event when killing active agent', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-killactive.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + // Mock process finding - return no process found + mockExecSync.mockImplementation(() => { + throw new Error('No processes'); + }); + + const completedHandler = vi.fn(); + watcher.on('subagent:completed', completedHandler); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + const result = await watcher.killSubagent('killactive'); + expect(result).toBe(true); + expect(completedHandler).toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('should emit error on directory scan failure', async () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const errorHandler = vi.fn(); + watcher.on('subagent:error', errorHandler); + + watcher.start(); + + await vi.advanceTimersByTimeAsync(100); + + expect(errorHandler).toHaveBeenCalled(); + }); + + it('should handle readline errors gracefully', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-rlerror.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + + // Emit readline error + mockRl.emit('error', new Error('Read error')); + + await vi.advanceTimersByTimeAsync(100); + + // Should not crash + expect(watcher.isRunning()).toBe(true); + }); + }); + + describe('File Watcher Management', () => { + it('should close file watchers on stop', async () => { + const mockFileWatcher = { close: vi.fn() }; + const mockDirWatcher = { close: vi.fn() }; + + mockWatch.mockImplementation((path: string) => { + if (path.endsWith('.jsonl')) return mockFileWatcher; + return mockDirWatcher; + }); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-watch.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + watcher.stop(); + + // Watchers should be closed + expect(mockFileWatcher.close).toHaveBeenCalled(); + }); + + it('should clear idle timers on stop', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-timer.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Agent should be active + expect(watcher.getSubagents()[0].status).toBe('active'); + + watcher.stop(); + + // Advance time past idle timeout - status shouldn't change since watcher is stopped + await vi.advanceTimersByTimeAsync(35000); + + // Status remains as it was when stopped (may still be active) + // The important thing is no errors occur + }); + }); + + describe('Project Hash Conversion', () => { + it('should convert working directory to project hash format', async () => { + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + + // Simulate project hash format: /home/user/project -> -home-user-project + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-hash.jsonl']; + if (path.includes('session1')) return ['subagents']; + if (path.includes('-home-user-myproject')) return ['session1']; + return ['-home-user-myproject']; + }); + mockStatSync.mockReturnValue({ + isDirectory: () => true, + birthtime: new Date(), + mtime: new Date(), + size: 100, + }); + mockReadFileSync.mockReturnValue(createUserEntry('Test')); + + watcher.start(); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // The conversion /home/user/myproject -> -home-user-myproject should work + const agents = watcher.getSubagentsForSession('/home/user/myproject'); + // The actual filtering depends on projectHash matching + expect(Array.isArray(agents)).toBe(true); + }); + }); + + describe('Tool Call Formatting', () => { + it('should format WebSearch tool call correctly', () => { + const entries: SubagentTranscriptEntry[] = [ + { + type: 'assistant', + timestamp: '2024-01-01T00:00:00Z', + agentId: 'test', + sessionId: 'sess1', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'WebSearch', input: { query: 'nodejs best practices' } }, + ], + }, + }, + ]; + + const formatted = watcher.formatTranscript(entries); + expect(formatted[0]).toContain('WebSearch'); + expect(formatted[0]).toContain('nodejs best practices'); + }); + + it('should format Read tool call with file path', () => { + const entries: SubagentTranscriptEntry[] = [ + { + type: 'assistant', + timestamp: '2024-01-01T00:00:00Z', + agentId: 'test', + sessionId: 'sess1', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'Read', input: { file_path: '/src/index.ts' } }, + ], + }, + }, + ]; + + const formatted = watcher.formatTranscript(entries); + expect(formatted[0]).toContain('Read'); + expect(formatted[0]).toContain('/src/index.ts'); + }); + + it('should format Bash tool call with command', () => { + const entries: SubagentTranscriptEntry[] = [ + { + type: 'assistant', + timestamp: '2024-01-01T00:00:00Z', + agentId: 'test', + sessionId: 'sess1', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'Bash', input: { command: 'npm test' } }, + ], + }, + }, + ]; + + const formatted = watcher.formatTranscript(entries); + expect(formatted[0]).toContain('Bash'); + expect(formatted[0]).toContain('npm test'); + }); + + it('should truncate long Bash commands', () => { + const longCommand = 'npm run very-long-command-name --with-many-options --flag1 --flag2 --flag3 --more-flags'; + const entries: SubagentTranscriptEntry[] = [ + { + type: 'assistant', + timestamp: '2024-01-01T00:00:00Z', + agentId: 'test', + sessionId: 'sess1', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'Bash', input: { command: longCommand } }, + ], + }, + }, + ]; + + const formatted = watcher.formatTranscript(entries); + expect(formatted[0]).toContain('...'); + }); + }); + + describe('Progress Event Formatting', () => { + it('should format query_update progress', () => { + const entries: SubagentTranscriptEntry[] = [ + { + type: 'progress', + timestamp: '2024-01-01T00:00:00Z', + agentId: 'test', + sessionId: 'sess1', + data: { type: 'query_update', query: 'finding files' }, + }, + ]; + + const formatted = watcher.formatTranscript(entries); + expect(formatted[0]).toContain('Searching'); + expect(formatted[0]).toContain('finding files'); + }); + + it('should format search_results_received progress', () => { + const entries: SubagentTranscriptEntry[] = [ + { + type: 'progress', + timestamp: '2024-01-01T00:00:00Z', + agentId: 'test', + sessionId: 'sess1', + data: { type: 'search_results_received', resultCount: 42 }, + }, + ]; + + const formatted = watcher.formatTranscript(entries); + expect(formatted[0]).toContain('42 results'); + }); + }); + + describe('User Message Handling', () => { + it('should emit user messages under 500 chars', async () => { + const userEntry = createUserEntry('Short user message'); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-user.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(userEntry); + + const messageHandler = vi.fn(); + watcher.on('subagent:message', messageHandler); + + watcher.start(); + mockRl.emit('line', userEntry); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + expect(messageHandler).toHaveBeenCalled(); + const msg = messageHandler.mock.calls[0][0] as SubagentMessage; + expect(msg.role).toBe('user'); + }); + + it('should not emit long user messages (over 500 chars)', async () => { + const longUserEntry = createUserEntry('x'.repeat(600)); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-longuser.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(longUserEntry); + + const messageHandler = vi.fn(); + watcher.on('subagent:message', messageHandler); + + watcher.start(); + mockRl.emit('line', longUserEntry); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Long user messages are filtered out + const userMessages = messageHandler.mock.calls.filter( + (call) => (call[0] as SubagentMessage).role === 'user' + ); + expect(userMessages.length).toBe(0); + }); + }); + + describe('Empty Content Handling', () => { + it('should not emit message for empty text content', async () => { + const emptyTextEntry = createAssistantTextEntry(' '); + + const mockRl = new EventEmitter(); + mockCreateInterface.mockReturnValue(mockRl); + mockCreateReadStream.mockReturnValue({}); + + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-empty.jsonl']; + 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, + }); + mockReadFileSync.mockReturnValue(emptyTextEntry); + + const messageHandler = vi.fn(); + watcher.on('subagent:message', messageHandler); + + watcher.start(); + mockRl.emit('line', emptyTextEntry); + mockRl.emit('close'); + + await vi.advanceTimersByTimeAsync(100); + + // Empty/whitespace messages are filtered out + expect(messageHandler).not.toHaveBeenCalled(); + }); + }); +});