import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { EventEmitter } from 'events'; import { PassThrough } from 'stream'; import fs from 'fs'; // Sentinel markers must match agent-runner.ts const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; // Mock config vi.mock('./config.js', () => ({ AGENT_MAX_OUTPUT_SIZE: 10485760, AGENT_TIMEOUT: 1800000, // 30min DATA_DIR: '/tmp/ejclaw-test-data', GROUPS_DIR: '/tmp/ejclaw-test-groups', IDLE_TIMEOUT: 1800000, // 30min SERVICE_ID: 'claude', SERVICE_AGENT_TYPE: 'claude-code', TIMEZONE: 'America/Los_Angeles', })); // Mock logger vi.mock('./logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); // Mock fs vi.mock('fs', async () => { const actual = await vi.importActual('fs'); return { ...actual, default: { ...actual, existsSync: vi.fn((p: string) => { // Return true for runner dist entry so tests proceed past the build check if (typeof p === 'string' && p.includes('dist/index.js')) return true; return false; }), mkdirSync: vi.fn(), writeFileSync: vi.fn(), readFileSync: vi.fn(() => ''), readdirSync: vi.fn(() => []), statSync: vi.fn(() => ({ isDirectory: () => false })), copyFileSync: vi.fn(), cpSync: vi.fn(), }, }; }); // Mock env vi.mock('./env.js', () => ({ readEnvFile: vi.fn(() => ({})), })); // Create a controllable fake ChildProcess function createFakeProcess() { const proc = new EventEmitter() as EventEmitter & { stdin: PassThrough; stdout: PassThrough; stderr: PassThrough; kill: ReturnType; pid: number; }; proc.stdin = new PassThrough(); proc.stdout = new PassThrough(); proc.stderr = new PassThrough(); proc.kill = vi.fn(); proc.pid = 12345; return proc; } let fakeProc: ReturnType; // Mock child_process.spawn vi.mock('child_process', async () => { const actual = await vi.importActual('child_process'); return { ...actual, spawn: vi.fn(() => fakeProc), exec: vi.fn( (_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => { if (cb) cb(null); return new EventEmitter(); }, ), }; }); import { runAgentProcess, AgentOutput } from './agent-runner.js'; import type { RegisteredGroup } from './types.js'; const testGroup: RegisteredGroup = { name: 'Test Group', folder: 'test-group', trigger: '@Andy', added_at: new Date().toISOString(), }; const testInput = { prompt: 'Hello', groupFolder: 'test-group', chatJid: 'test@g.us', isMain: false, }; function emitOutputMarker( proc: ReturnType, output: AgentOutput, ) { const json = JSON.stringify(output); proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`); } describe('agent-runner timeout behavior', () => { beforeEach(() => { vi.useFakeTimers(); fakeProc = createFakeProcess(); }); afterEach(() => { vi.useRealTimers(); }); it('timeout after output resolves as success', async () => { const onOutput = vi.fn(async () => {}); const resultPromise = runAgentProcess( testGroup, testInput, () => {}, onOutput, ); // Emit output with a result emitOutputMarker(fakeProc, { status: 'success', result: 'Here is my response', newSessionId: 'session-123', }); // Let output processing settle await vi.advanceTimersByTimeAsync(10); // Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms) await vi.advanceTimersByTimeAsync(1830000); // Emit close event (as if agent was stopped by the timeout) fakeProc.emit('close', 137); // Let the promise resolve await vi.advanceTimersByTimeAsync(10); const result = await resultPromise; expect(result.status).toBe('success'); expect(result.newSessionId).toBe('session-123'); expect(onOutput).toHaveBeenCalledWith( expect.objectContaining({ result: 'Here is my response' }), ); }); it('timeout with no output resolves as error', async () => { const onOutput = vi.fn(async () => {}); const resultPromise = runAgentProcess( testGroup, testInput, () => {}, onOutput, ); // No output emitted — fire the hard timeout await vi.advanceTimersByTimeAsync(1830000); // Emit close event fakeProc.emit('close', 137); await vi.advanceTimersByTimeAsync(10); const result = await resultPromise; expect(result.status).toBe('error'); expect(result.error).toContain('timed out'); expect(onOutput).not.toHaveBeenCalled(); }); it('normal exit after output resolves as success', async () => { const onOutput = vi.fn(async () => {}); const resultPromise = runAgentProcess( testGroup, testInput, () => {}, onOutput, ); // Emit output emitOutputMarker(fakeProc, { status: 'success', result: 'Done', newSessionId: 'session-456', }); await vi.advanceTimersByTimeAsync(10); // Normal exit (no timeout) fakeProc.emit('close', 0); await vi.advanceTimersByTimeAsync(10); const result = await resultPromise; expect(result.status).toBe('success'); expect(result.newSessionId).toBe('session-456'); }); it('materializes repo-managed Claude rules into the session CLAUDE.md', async () => { vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { const path = String(p); return ( path.includes('dist/index.js') || path.endsWith('/prompts/claude-platform.md') || path.endsWith('/global/CLAUDE.md') ); }); vi.mocked(fs.readFileSync).mockImplementation( (p: fs.PathOrFileDescriptor) => { const path = String(p); if (path.endsWith('/prompts/claude-platform.md')) { return 'Platform Claude Rules'; } if (path.endsWith('/global/CLAUDE.md')) { return 'Global Claude Memory'; } return ''; }, ); const resultPromise = runAgentProcess( testGroup, testInput, () => {}, undefined, ); fakeProc.emit('close', 0); await vi.advanceTimersByTimeAsync(10); await resultPromise; expect(fs.writeFileSync).toHaveBeenCalledWith( '/tmp/ejclaw-test-data/sessions/test-group/.claude/CLAUDE.md', 'Platform Claude Rules\n\n---\n\nGlobal Claude Memory\n', ); }); it('injects the real chat JID into Codex MCP config', async () => { vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { const path = String(p); return ( path.includes('dist/index.js') || path.endsWith('/runners/agent-runner/dist/ipc-mcp-stdio.js') ); }); vi.mocked(fs.readFileSync).mockReturnValue(''); const codexGroup: RegisteredGroup = { ...testGroup, folder: 'eyejokerdb-4', agentType: 'codex', }; const codexInput = { ...testInput, groupFolder: 'eyejokerdb-4', chatJid: 'dc:1481348008183595170', }; const resultPromise = runAgentProcess( codexGroup, codexInput, () => {}, undefined, ); fakeProc.emit('close', 0); await vi.advanceTimersByTimeAsync(10); await resultPromise; expect(fs.writeFileSync).toHaveBeenCalledWith( '/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml', expect.stringContaining('NANOCLAW_CHAT_JID = "dc:1481348008183595170"'), ); expect(fs.writeFileSync).toHaveBeenCalledWith( '/tmp/ejclaw-test-data/sessions/eyejokerdb-4/.codex/config.toml', expect.stringContaining('NANOCLAW_GROUP_FOLDER = "eyejokerdb-4"'), ); }); });