import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // The respawn controller drives the AI idle checker, which spawns real `tmux`/`claude` // via child_process. Neutralize those spawns here (mirrors ai-idle-checker.test.ts) so // tests never launch real processes. Spread the real module to keep `exec` etc. intact — // transitively-imported modules (e.g. tmux-manager) call `promisify(exec)` at load time. vi.mock('node:child_process', async (orig) => { const actual = await orig(); return { ...actual, execSync: vi.fn(), spawn: vi.fn(() => ({ unref: vi.fn(), pid: 12345, on: vi.fn() })), }; }); import { RespawnController, RespawnState, RespawnConfig } from '../src/respawn-controller.js'; import { Session } from '../src/session.js'; import { MockSession } from './mocks/index.js'; /** * RespawnController Tests * * Tests the state machine that manages automatic respawning of Claude sessions * State flow: WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING */ describe('RespawnController', () => { let session: MockSession; let controller: RespawnController; beforeEach(() => { session = new MockSession(); controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 100, // Short timeout for testing interStepDelayMs: 50, completionConfirmMs: 50, // Short confirmation delay for testing noOutputTimeoutMs: 500, // Short fallback timeout for testing aiIdleCheckEnabled: false, // Disable AI check for legacy tests }); }); afterEach(() => { controller.stop(); }); describe('Initialization', () => { it('should start in stopped state', () => { expect(controller.state).toBe('stopped'); expect(controller.isRunning).toBe(false); }); it('should have default configuration', () => { const config = controller.getConfig(); expect(config.enabled).toBe(true); expect(config.updatePrompt).toBe( 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.' ); }); it('should allow custom configuration', () => { const customController = new RespawnController(session as unknown as Session, { updatePrompt: 'custom prompt', idleTimeoutMs: 10000, }); const config = customController.getConfig(); expect(config.updatePrompt).toBe('custom prompt'); expect(config.idleTimeoutMs).toBe(10000); customController.stop(); }); }); describe('State Machine', () => { it('should transition to watching state on start', () => { const states: RespawnState[] = []; controller.on('stateChanged', (state) => states.push(state)); controller.start(); expect(controller.state).toBe('watching'); expect(states).toContain('watching'); }); it('should not start if already running', () => { controller.start(); const initialState = controller.state; controller.start(); // Try to start again expect(controller.state).toBe(initialState); }); it('should transition to stopped on stop', () => { controller.start(); controller.stop(); expect(controller.state).toBe('stopped'); expect(controller.isRunning).toBe(false); }); it('should track cycle count', () => { expect(controller.currentCycle).toBe(0); }); }); describe('Idle Detection', () => { it('should detect completion message pattern', async () => { const logMessages: string[] = []; controller.on('log', (msg) => logMessages.push(msg)); controller.start(); session.simulateCompletionMessage(); // Wait for log await new Promise((resolve) => setTimeout(resolve, 50)); const hasCompletionLog = logMessages.some((msg) => msg.includes('Completion message detected')); expect(hasCompletionLog).toBe(true); }); it('should detect multiple prompt patterns (legacy fallback)', () => { controller.start(); // All these should trigger prompt detection (legacy) const promptPatterns = ['❯', '\u276f', '⏵', '> ', 'tokens']; for (const pattern of promptPatterns) { session.simulateTerminalOutput(pattern); } // Controller should still be running after all patterns expect(controller.isRunning).toBe(true); }); it('should detect working patterns and clear prompt state', () => { controller.start(); session.simulateCompletionMessage(); // Simulate working - should clear completion state and cancel confirmation session.simulateWorking(); const status = controller.getStatus(); expect(status.workingDetected).toBe(true); expect(status.promptDetected).toBe(false); }); }); describe('Respawn Cycle', () => { it('should start cycle when completion message detected and confirmed', async () => { let cycleStarted = false; controller.on('respawnCycleStarted', () => { cycleStarted = true; }); controller.start(); session.simulateCompletionMessage(); // Wait for completion confirmation (completionConfirmMs=50) + processing await new Promise((resolve) => setTimeout(resolve, 200)); expect(cycleStarted).toBe(true); expect(controller.currentCycle).toBe(1); }); it('should send update prompt during cycle', async () => { let stepSent: string | null = null; controller.on('stepSent', (step) => { stepSent = step; }); controller.start(); session.simulateCompletionMessage(); // Wait for completion confirmation + step delay await new Promise((resolve) => setTimeout(resolve, 300)); expect(stepSent).toBe('update'); expect(session.writeBuffer.length).toBeGreaterThan(0); expect(session.writeBuffer[0]).toContain('write a brief progress summary'); }); it('should transition through states during cycle', async () => { const states: RespawnState[] = []; controller.on('stateChanged', (state) => states.push(state)); controller.start(); session.simulateCompletionMessage(); // Wait for state transitions await new Promise((resolve) => setTimeout(resolve, 200)); // Should have transitioned through multiple states (watching -> confirming_idle -> sending_update) expect(states).toContain('watching'); expect(states.length).toBeGreaterThan(1); }); }); describe('Configuration Update', () => { it('should update configuration', () => { controller.updateConfig({ updatePrompt: 'new prompt' }); const config = controller.getConfig(); expect(config.updatePrompt).toBe('new prompt'); }); it('should merge partial configuration', () => { const originalTimeout = controller.getConfig().idleTimeoutMs; controller.updateConfig({ updatePrompt: 'new prompt' }); const config = controller.getConfig(); expect(config.idleTimeoutMs).toBe(originalTimeout); }); it('should not override defaults with explicit undefined values in constructor', () => { const configWithUndefined = { idleTimeoutMs: 5000, aiIdleCheckTimeoutMs: undefined, aiIdleCheckModel: undefined, } as Partial; const newController = new RespawnController(session as unknown as Session, configWithUndefined); const config = newController.getConfig(); // Explicit undefined should not override defaults expect(config.aiIdleCheckTimeoutMs).toBe(90000); // default value expect(config.aiIdleCheckModel).toBe('claude-opus-4-5-20251101'); // default value expect(config.idleTimeoutMs).toBe(5000); // explicit value should be preserved newController.stop(); }); it('should not override existing config with explicit undefined in updateConfig', () => { const originalTimeout = controller.getConfig().aiIdleCheckTimeoutMs; controller.updateConfig({ aiIdleCheckTimeoutMs: undefined } as Partial); const config = controller.getConfig(); expect(config.aiIdleCheckTimeoutMs).toBe(originalTimeout); }); }); describe('Status', () => { it('should provide complete status', () => { controller.start(); const status = controller.getStatus(); expect(status).toHaveProperty('state'); expect(status).toHaveProperty('cycleCount'); expect(status).toHaveProperty('lastActivityTime'); expect(status).toHaveProperty('timeSinceActivity'); expect(status).toHaveProperty('promptDetected'); expect(status).toHaveProperty('workingDetected'); expect(status).toHaveProperty('config'); }); it('should track time since activity', async () => { controller.start(); // Wait a bit await new Promise((resolve) => setTimeout(resolve, 100)); const status = controller.getStatus(); expect(status.timeSinceActivity).toBeGreaterThan(0); }); }); describe('Disabled State', () => { it('should not start when disabled', () => { const disabledController = new RespawnController(session as unknown as Session, { enabled: false, }); disabledController.start(); expect(disabledController.state).toBe('stopped'); disabledController.stop(); }); }); describe('Pause and Resume', () => { it('should pause without changing state', () => { controller.start(); const stateBeforePause = controller.state; controller.pause(); expect(controller.state).toBe(stateBeforePause); }); it('should resume from watching state', () => { controller.start(); controller.pause(); controller.resume(); expect(controller.state).toBe('watching'); }); }); describe('Terminal Buffer Management', () => { it('should handle large terminal output', () => { controller.start(); // Send lots of data const largeData = 'x'.repeat(20000); session.simulateTerminalOutput(largeData); // Should not crash and controller should still work expect(controller.isRunning).toBe(true); }); }); describe('Event Emission', () => { it('should emit stateChanged events', async () => { const events: Array<{ state: RespawnState; prevState: RespawnState }> = []; controller.on('stateChanged', (state, prevState) => { events.push({ state, prevState }); }); controller.start(); await new Promise((resolve) => setTimeout(resolve, 50)); expect(events.length).toBeGreaterThan(0); expect(events[0].state).toBe('watching'); expect(events[0].prevState).toBe('stopped'); }); it('should emit log events', () => { const logs: string[] = []; controller.on('log', (msg) => logs.push(msg)); controller.start(); expect(logs.length).toBeGreaterThan(0); expect(logs.some((l) => l.includes('Starting'))).toBe(true); }); }); }); describe('RespawnController Integration', () => { it('should handle rapid terminal data without errors', () => { const session = new MockSession(); const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); // Simulate rapid terminal output for (let i = 0; i < 100; i++) { session.simulateTerminalOutput(`Line ${i}\n`); } expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle mixed working and idle states', async () => { const session = new MockSession(); const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 100, }); controller.start(); // Alternate between working and idle session.simulatePrompt(); await new Promise((resolve) => setTimeout(resolve, 50)); session.simulateWorking(); await new Promise((resolve) => setTimeout(resolve, 50)); session.simulatePrompt(); await new Promise((resolve) => setTimeout(resolve, 50)); // Should handle transitions gracefully expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle ANSI escape codes in terminal output', () => { const session = new MockSession(); const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); // Simulate output with ANSI codes session.simulateTerminalOutput('\x1b[32mGreen text\x1b[0m'); session.simulateTerminalOutput('\x1b[1;34mBold blue\x1b[0m'); session.simulateTerminalOutput('\x1b[2J\x1b[H'); // Clear screen and move cursor session.simulateTerminalOutput('\x1b[?25l'); // Hide cursor session.simulateTerminalOutput('\x1b[?25h'); // Show cursor // Should handle ANSI codes without crashing expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle empty terminal output', () => { const session = new MockSession(); const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); // Simulate empty and whitespace output session.simulateTerminalOutput(''); session.simulateTerminalOutput(' '); session.simulateTerminalOutput('\n\n\n'); session.simulateTerminalOutput('\t\t'); expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle start/stop cycles without memory leaks', () => { const session = new MockSession(); for (let i = 0; i < 10; i++) { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 100, }); controller.start(); session.simulatePrompt(); session.simulateWorking(); session.simulatePrompt(); controller.stop(); } // If we got here without crashing, the test passes expect(true).toBe(true); }); it('should handle Unicode prompt characters', () => { const session = new MockSession(); const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); // Test various prompt characters session.simulateTerminalOutput('❯ '); session.simulateTerminalOutput('\u276f '); // Unicode variant session.simulateTerminalOutput('⏵ '); // Alternative const status = controller.getStatus(); expect(status.promptDetected).toBe(true); controller.stop(); }); it('should handle spinner animations', () => { const session = new MockSession(); const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); // Simulate spinner animation const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; for (const char of spinnerChars) { session.simulateTerminalOutput(`Working... ${char}`); } const status = controller.getStatus(); expect(status.workingDetected).toBe(true); controller.stop(); }); it('should not trigger cycle when disabled', async () => { const session = new MockSession(); const controller = new RespawnController(session as unknown as Session, { enabled: false, idleTimeoutMs: 50, }); let cycleStarted = false; controller.on('respawnCycleStarted', () => { cycleStarted = true; }); controller.start(); session.simulatePrompt(); await new Promise((resolve) => setTimeout(resolve, 200)); expect(cycleStarted).toBe(false); controller.stop(); }); }); describe('RespawnController Configuration', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should use default sendClear option', () => { const controller = new RespawnController(session as unknown as Session, {}); expect(controller.getConfig().sendClear).toBe(true); controller.stop(); }); it('should use default sendInit option', () => { const controller = new RespawnController(session as unknown as Session, {}); expect(controller.getConfig().sendInit).toBe(true); controller.stop(); }); it('should respect custom sendClear option', () => { const controller = new RespawnController(session as unknown as Session, { sendClear: false, }); expect(controller.getConfig().sendClear).toBe(false); controller.stop(); }); it('should respect custom sendInit option', () => { const controller = new RespawnController(session as unknown as Session, { sendInit: false, }); expect(controller.getConfig().sendInit).toBe(false); controller.stop(); }); it('should support kickstartPrompt option', () => { const controller = new RespawnController(session as unknown as Session, { kickstartPrompt: '/init please start working', }); expect(controller.getConfig().kickstartPrompt).toBe('/init please start working'); controller.stop(); }); it('should handle zero idleTimeoutMs by resetting to default', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 0, }); // Zero is invalid (could cause infinite loops), so it's reset to default expect(controller.getConfig().idleTimeoutMs).toBe(10000); controller.stop(); }); it('should handle large idleTimeoutMs', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 3600000, // 1 hour }); expect(controller.getConfig().idleTimeoutMs).toBe(3600000); controller.stop(); }); it('should handle zero interStepDelayMs by resetting to default', () => { const controller = new RespawnController(session as unknown as Session, { interStepDelayMs: 0, }); // Zero is invalid (could cause issues with step timing), so it's reset to default expect(controller.getConfig().interStepDelayMs).toBe(1000); controller.stop(); }); it('should handle negative timeout values by resetting to defaults', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: -1000, completionConfirmMs: -500, noOutputTimeoutMs: -100, interStepDelayMs: -50, }); // Negative values are invalid, reset to defaults expect(controller.getConfig().idleTimeoutMs).toBe(10000); expect(controller.getConfig().completionConfirmMs).toBe(10000); expect(controller.getConfig().noOutputTimeoutMs).toBe(30000); expect(controller.getConfig().interStepDelayMs).toBe(1000); controller.stop(); }); it('should clamp completionConfirmMs to noOutputTimeoutMs', () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 60000, // Greater than noOutputTimeoutMs noOutputTimeoutMs: 30000, }); // completionConfirmMs should be clamped to noOutputTimeoutMs expect(controller.getConfig().completionConfirmMs).toBe(30000); expect(controller.getConfig().noOutputTimeoutMs).toBe(30000); controller.stop(); }); it('should handle negative autoAcceptDelayMs by resetting to default', () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptDelayMs: -100, }); // Negative is invalid, reset to default (8000) expect(controller.getConfig().autoAcceptDelayMs).toBe(8000); controller.stop(); }); it('should allow zero autoAcceptDelayMs (immediate accept)', () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptDelayMs: 0, }); // Zero is valid for auto-accept (means immediate) expect(controller.getConfig().autoAcceptDelayMs).toBe(0); controller.stop(); }); it('should handle empty updatePrompt', () => { const controller = new RespawnController(session as unknown as Session, { updatePrompt: '', }); expect(controller.getConfig().updatePrompt).toBe(''); controller.stop(); }); it('should handle special characters in updatePrompt', () => { const controller = new RespawnController(session as unknown as Session, { updatePrompt: 'prompt with "quotes" and \'apostrophes\' and $variables', }); expect(controller.getConfig().updatePrompt).toContain('"quotes"'); controller.stop(); }); it('should handle unicode in updatePrompt', () => { const controller = new RespawnController(session as unknown as Session, { updatePrompt: '日本語のプロンプト 🚀', }); expect(controller.getConfig().updatePrompt).toBe('日本語のプロンプト 🚀'); controller.stop(); }); it('should handle multiline updatePrompt', () => { const controller = new RespawnController(session as unknown as Session, { updatePrompt: 'Line 1\nLine 2\nLine 3', }); expect(controller.getConfig().updatePrompt).toContain('\n'); controller.stop(); }); }); describe('RespawnController State Transitions', () => { let session: MockSession; let controller: RespawnController; beforeEach(() => { session = new MockSession(); controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 50, interStepDelayMs: 20, completionConfirmMs: 50, // Short confirmation for testing noOutputTimeoutMs: 300, // Short fallback for testing aiIdleCheckEnabled: false, // Disable AI check for legacy tests }); }); afterEach(() => { controller.stop(); }); it('should record state history', async () => { const stateHistory: RespawnState[] = []; controller.on('stateChanged', (state) => stateHistory.push(state)); controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 200)); expect(stateHistory).toContain('watching'); expect(stateHistory.length).toBeGreaterThan(1); }); it('should handle stop during state transition', async () => { controller.start(); session.simulateCompletionMessage(); // Wait a bit then stop during potential transition await new Promise((resolve) => setTimeout(resolve, 30)); controller.stop(); expect(controller.state).toBe('stopped'); }); it('should emit complete cycle event', async () => { let cycleCompleted = false; controller.on('respawnCycleCompleted', () => { cycleCompleted = true; }); controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 500)); // May or may not complete depending on timing expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle multiple consecutive completion messages', async () => { let completionCount = 0; controller.on('log', (msg) => { if (msg.includes('Completion message detected')) completionCount++; }); controller.start(); // Send multiple completion messages rapidly session.simulateCompletionMessage(); session.simulateCompletionMessage(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 100)); // Should detect completion messages expect(completionCount).toBeGreaterThan(0); }); it('should handle working state interrupting idle confirmation', async () => { let cycleStarted = false; controller.on('respawnCycleStarted', () => { cycleStarted = true; }); controller.start(); session.simulateCompletionMessage(); // Before confirmation timer fires, start working await new Promise((resolve) => setTimeout(resolve, 20)); session.simulateWorking(); await new Promise((resolve) => setTimeout(resolve, 100)); // Cycle should not have started due to working state canceling confirmation expect(controller.getStatus().workingDetected).toBe(true); }); }); describe('RespawnController Edge Cases', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should handle null session events gracefully', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); // Emit with undefined/null data session.emit('terminal', undefined); session.emit('terminal', null); session.emit('terminal', ''); expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle very long terminal lines', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); // Send a very long line const longLine = 'a'.repeat(100000); session.simulateTerminalOutput(longLine); expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle binary data in terminal', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); // Send some binary-like data const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]).toString(); session.simulateTerminalOutput(binaryData); expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle pause when already paused', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); controller.pause(); controller.pause(); // Double pause expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle resume when not paused', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); controller.resume(); // Resume when not paused expect(controller.isRunning).toBe(true); expect(controller.state).toBe('watching'); controller.stop(); }); it('should handle stop when already stopped', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.stop(); // Stop when never started controller.stop(); // Double stop expect(controller.state).toBe('stopped'); }); it('should handle updateConfig while running', async () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 100, updatePrompt: 'original', }); controller.start(); session.simulatePrompt(); // Update config mid-run controller.updateConfig({ updatePrompt: 'updated' }); await new Promise((resolve) => setTimeout(resolve, 200)); expect(controller.getConfig().updatePrompt).toBe('updated'); controller.stop(); }); it('should track cycle count across multiple cycles', async () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 30, interStepDelayMs: 10, completionConfirmMs: 30, // Short confirmation for testing noOutputTimeoutMs: 200, // Short fallback for testing aiIdleCheckEnabled: false, }); expect(controller.currentCycle).toBe(0); controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 200)); expect(controller.currentCycle).toBeGreaterThan(0); controller.stop(); }); it('should provide accurate time since activity', async () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); await new Promise((resolve) => setTimeout(resolve, 100)); const status = controller.getStatus(); // Allow for slight timing variance (timers may fire 1-2ms early) expect(status.timeSinceActivity).toBeGreaterThanOrEqual(95); controller.stop(); }); it('should reset time since activity on terminal input', async () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 1000, }); controller.start(); await new Promise((resolve) => setTimeout(resolve, 100)); session.simulateTerminalOutput('new data'); const status = controller.getStatus(); // Time should be reset or very small expect(status.timeSinceActivity).toBeLessThan(100); controller.stop(); }); describe('Auto-Accept Prompts', () => { it('should have autoAcceptPrompts enabled by default', () => { const defaultController = new RespawnController(session as unknown as Session); const config = defaultController.getConfig(); expect(config.autoAcceptPrompts).toBe(true); expect(config.autoAcceptDelayMs).toBe(8000); defaultController.stop(); }); it('should send Enter after silence without completion message', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, // Short delay for testing completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: false, // Pre-filter only for this test }); let autoAcceptFired = false; autoAcceptController.on('autoAcceptSent', () => { autoAcceptFired = true; }); autoAcceptController.start(); // Simulate plan mode UI with numbered options and selector session.simulateTerminalOutput('Would you like to proceed?\n❯ 1. Yes\n 2. No\n'); // Wait for autoAcceptDelayMs to expire await new Promise((resolve) => setTimeout(resolve, 200)); expect(autoAcceptFired).toBe(true); expect(session.writeBuffer).toContain('\r'); autoAcceptController.stop(); }); it('should NOT send Enter when completion message was detected', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 200, // Longer than autoAcceptDelay noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, }); let autoAcceptFired = false; autoAcceptController.on('autoAcceptSent', () => { autoAcceptFired = true; }); autoAcceptController.start(); // Simulate completion message - normal idle flow should handle this session.simulateCompletionMessage(); // Wait for autoAcceptDelayMs await new Promise((resolve) => setTimeout(resolve, 200)); expect(autoAcceptFired).toBe(false); autoAcceptController.stop(); }); it('should NOT send Enter when disabled', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: false, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, }); let autoAcceptFired = false; autoAcceptController.on('autoAcceptSent', () => { autoAcceptFired = true; }); autoAcceptController.start(); session.simulateTerminalOutput('Plan: Waiting for approval...'); await new Promise((resolve) => setTimeout(resolve, 200)); expect(autoAcceptFired).toBe(false); autoAcceptController.stop(); }); it('should NOT send Enter before any output is received', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, }); let autoAcceptFired = false; autoAcceptController.on('autoAcceptSent', () => { autoAcceptFired = true; }); autoAcceptController.start(); // Don't simulate any output - just wait await new Promise((resolve) => setTimeout(resolve, 200)); expect(autoAcceptFired).toBe(false); autoAcceptController.stop(); }); it('should reset timer when new output arrives', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 150, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: false, }); let autoAcceptFired = false; autoAcceptController.on('autoAcceptSent', () => { autoAcceptFired = true; }); autoAcceptController.start(); session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); // Wait 100ms (less than 150ms delay), then send more output await new Promise((resolve) => setTimeout(resolve, 100)); session.simulateTerminalOutput('More output'); // Wait another 100ms - total 200ms from start but only 100ms from last output await new Promise((resolve) => setTimeout(resolve, 100)); expect(autoAcceptFired).toBe(false); // Wait the remaining time await new Promise((resolve) => setTimeout(resolve, 100)); expect(autoAcceptFired).toBe(true); autoAcceptController.stop(); }); it('should only send Enter once per silence period', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: false, }); let autoAcceptCount = 0; autoAcceptController.on('autoAcceptSent', () => { autoAcceptCount++; }); autoAcceptController.start(); session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); // Wait for first auto-accept await new Promise((resolve) => setTimeout(resolve, 200)); expect(autoAcceptCount).toBe(1); // Wait more - should NOT fire again (hasReceivedOutput is false) await new Promise((resolve) => setTimeout(resolve, 200)); expect(autoAcceptCount).toBe(1); // New output comes in (plan mode again), then silence again - should fire again session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); await new Promise((resolve) => setTimeout(resolve, 200)); expect(autoAcceptCount).toBe(2); autoAcceptController.stop(); }); it('should NOT auto-accept during respawn cycle (non-watching state)', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 50, completionConfirmMs: 50, interStepDelayMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, }); let autoAcceptFired = false; autoAcceptController.on('autoAcceptSent', () => { autoAcceptFired = true; }); autoAcceptController.start(); // Trigger a respawn cycle via completion message session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 150)); // Now in sending_update or waiting_update state expect(autoAcceptController.state).not.toBe('watching'); // Simulate output in the waiting state, then silence session.simulateTerminalOutput('Processing update...'); await new Promise((resolve) => setTimeout(resolve, 150)); // Auto-accept should NOT fire because we're not in watching state expect(autoAcceptFired).toBe(false); autoAcceptController.stop(); }); it('should NOT auto-accept when elicitation dialog is signaled', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, }); let autoAcceptFired = false; autoAcceptController.on('autoAcceptSent', () => { autoAcceptFired = true; }); autoAcceptController.start(); session.simulateTerminalOutput('Which option do you prefer?'); // Signal that an elicitation dialog (AskUserQuestion) was detected autoAcceptController.signalElicitation(); // Wait for autoAcceptDelayMs to expire await new Promise((resolve) => setTimeout(resolve, 200)); // Auto-accept should NOT fire because elicitation was signaled expect(autoAcceptFired).toBe(false); autoAcceptController.stop(); }); it('should clear elicitation flag when working patterns detected', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: false, }); let autoAcceptFired = false; autoAcceptController.on('autoAcceptSent', () => { autoAcceptFired = true; }); autoAcceptController.start(); session.simulateTerminalOutput('Question output'); // Signal elicitation autoAcceptController.signalElicitation(); // Working pattern clears the elicitation flag (new turn started) session.simulateTerminalOutput('Thinking'); // New silence after work - plan mode approval with plan mode UI session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); await new Promise((resolve) => setTimeout(resolve, 200)); // Auto-accept should fire now (elicitation cleared by working pattern) expect(autoAcceptFired).toBe(true); autoAcceptController.stop(); }); }); }); describe('RespawnController AI Idle Check', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should have AI idle check enabled by default', () => { const controller = new RespawnController(session as unknown as Session); const config = controller.getConfig(); expect(config.aiIdleCheckEnabled).toBe(true); expect(config.aiIdleCheckModel).toBe('claude-opus-4-5-20251101'); expect(config.aiIdleCheckMaxContext).toBe(16000); expect(config.aiIdleCheckTimeoutMs).toBe(90000); expect(config.aiIdleCheckCooldownMs).toBe(180000); controller.stop(); }); it('should include AI check state in detection status when enabled', () => { const controller = new RespawnController(session as unknown as Session, { aiIdleCheckEnabled: true, }); controller.start(); const detection = controller.getDetectionStatus(); expect(detection.aiCheck).not.toBeNull(); expect(detection.aiCheck?.status).toBe('ready'); controller.stop(); }); it('should not include AI check state when disabled', () => { const controller = new RespawnController(session as unknown as Session, { aiIdleCheckEnabled: false, }); controller.start(); const detection = controller.getDetectionStatus(); expect(detection.aiCheck).toBeNull(); controller.stop(); }); it('should transition to ai_checking state when pre-filter is met', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 500, // Short timeout for test }); const states: string[] = []; controller.on('stateChanged', (state: string) => states.push(state)); controller.start(); // Simulate completion message session.simulateCompletionMessage(); // Wait for completion confirm timer to fire and AI check to start await new Promise((resolve) => setTimeout(resolve, 200)); // Should have transitioned to ai_checking expect(states).toContain('ai_checking'); controller.stop(); }); it('should cancel AI check when working patterns detected', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 5000, // Long timeout so we can interrupt }); controller.start(); session.simulateCompletionMessage(); // Wait for AI check to start await new Promise((resolve) => setTimeout(resolve, 100)); // Simulate working patterns during AI check session.simulateWorking(); // Should be back to watching await new Promise((resolve) => setTimeout(resolve, 50)); expect(controller.state).toBe('watching'); controller.stop(); }); it('should cancel AI check when substantial output arrives', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 5000, }); controller.start(); session.simulateCompletionMessage(); // Wait for AI check to start await new Promise((resolve) => setTimeout(resolve, 100)); // Simulate substantial output during AI check session.simulateTerminalOutput('Some meaningful output that is more than 2 chars'); // Should be back to watching await new Promise((resolve) => setTimeout(resolve, 50)); expect(controller.state).toBe('watching'); controller.stop(); }); it('should fall back to direct idle when AI check is disabled', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, }); let cycleStarted = false; controller.on('respawnCycleStarted', () => { cycleStarted = true; }); controller.start(); session.simulateCompletionMessage(); // Wait for completion confirm and direct idle await new Promise((resolve) => setTimeout(resolve, 200)); expect(cycleStarted).toBe(true); controller.stop(); }); it('should emit aiCheckStarted event', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 500, }); let aiCheckStarted = false; controller.on('aiCheckStarted', () => { aiCheckStarted = true; }); controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 150)); expect(aiCheckStarted).toBe(true); controller.stop(); }); it('should update AI checker config on updateConfig', () => { const controller = new RespawnController(session as unknown as Session, { aiIdleCheckEnabled: true, }); controller.updateConfig({ aiIdleCheckModel: 'claude-sonnet-4-20250514', aiIdleCheckCooldownMs: 60000, }); const config = controller.getConfig(); expect(config.aiIdleCheckModel).toBe('claude-sonnet-4-20250514'); expect(config.aiIdleCheckCooldownMs).toBe(60000); controller.stop(); }); it('should trigger AI check via completion message path (not requiring 3s working-absent)', async () => { // The pre-filter timer requires 3s without working patterns, // but the completion message path (startCompletionConfirmTimer) bypasses // the working-absent check and goes directly through tryStartAiCheck. const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 500, }); const states: string[] = []; controller.on('stateChanged', (state: string) => states.push(state)); controller.start(); // Completion message triggers the completion confirm timer // which routes through tryStartAiCheck after silence session.simulateCompletionMessage(); // Wait for completion confirm timer + AI check start await new Promise((resolve) => setTimeout(resolve, 150)); // Should have triggered ai_checking via completion path expect(states).toContain('ai_checking'); controller.stop(); }); it('should handle AI check timeout gracefully', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 100, // Very short timeout }); const states: string[] = []; controller.on('stateChanged', (state: string) => states.push(state)); controller.start(); session.simulateCompletionMessage(); // Wait for AI check to start and timeout await new Promise((resolve) => setTimeout(resolve, 300)); // Should return to watching after timeout (with cooldown) expect(controller.state).toBe('watching'); controller.stop(); }); }); describe('RespawnController AI Plan Mode Check', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should have AI plan check enabled by default', () => { const controller = new RespawnController(session as unknown as Session); const config = controller.getConfig(); expect(config.aiPlanCheckEnabled).toBe(true); expect(config.aiPlanCheckModel).toBe('claude-opus-4-5-20251101'); expect(config.aiPlanCheckMaxContext).toBe(8000); expect(config.aiPlanCheckTimeoutMs).toBe(60000); expect(config.aiPlanCheckCooldownMs).toBe(30000); controller.stop(); }); it('should block auto-accept when buffer has no plan mode patterns (pre-filter)', async () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: false, // Test pre-filter only }); let autoAcceptFired = false; controller.on('autoAcceptSent', () => { autoAcceptFired = true; }); controller.start(); // Output without plan mode patterns (no numbered list, no selector) session.simulateTerminalOutput('Claude is just thinking about something...\nSome regular output here.'); await new Promise((resolve) => setTimeout(resolve, 200)); // Pre-filter should block - no plan mode patterns found expect(autoAcceptFired).toBe(false); controller.stop(); }); it('should pass pre-filter when buffer contains numbered list + selector', async () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: false, // Test pre-filter only (no AI) }); let autoAcceptFired = false; controller.on('autoAcceptSent', () => { autoAcceptFired = true; }); controller.start(); // Output WITH plan mode patterns session.simulateTerminalOutput( 'Would you like to proceed with this plan?\n' + '❯ 1. Yes\n' + ' 2. No\n' + ' 3. Type your own\n' ); await new Promise((resolve) => setTimeout(resolve, 200)); // Pre-filter should pass and send Enter (AI disabled) expect(autoAcceptFired).toBe(true); controller.stop(); }); it('should block pre-filter when working patterns are in the tail', async () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: false, }); let autoAcceptFired = false; controller.on('autoAcceptSent', () => { autoAcceptFired = true; }); controller.start(); // Plan mode patterns BUT also has working patterns (spinner) in the tail session.simulateTerminalOutput('❯ 1. Yes\n' + ' 2. No\n' + 'Thinking ⠋\n'); // Wait for autoAcceptDelay - but working pattern resets the timer // so we need to wait longer and check after working pattern was consumed await new Promise((resolve) => setTimeout(resolve, 200)); // Should NOT fire because working patterns detected resets timer // (the working pattern in handleTerminalData clears timers) expect(autoAcceptFired).toBe(false); controller.stop(); }); it('should emit planCheckStarted when AI plan check is triggered', async () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: true, aiPlanCheckTimeoutMs: 500, // Short timeout for test }); let planCheckStarted = false; controller.on('planCheckStarted', () => { planCheckStarted = true; }); controller.start(); // Output with plan mode patterns to pass pre-filter session.simulateTerminalOutput('Would you like to proceed?\n' + '❯ 1. Yes\n' + ' 2. No\n'); await new Promise((resolve) => setTimeout(resolve, 200)); // Plan check should have been started (pre-filter passed, AI enabled) expect(planCheckStarted).toBe(true); controller.stop(); }); it('should cancel plan check when new output arrives', async () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: true, aiPlanCheckTimeoutMs: 5000, // Long timeout so we can interrupt }); let planCheckStarted = false; let autoAcceptFired = false; controller.on('planCheckStarted', () => { planCheckStarted = true; }); controller.on('autoAcceptSent', () => { autoAcceptFired = true; }); controller.start(); // Trigger plan check session.simulateTerminalOutput('❯ 1. Yes\n' + ' 2. No\n'); await new Promise((resolve) => setTimeout(resolve, 150)); expect(planCheckStarted).toBe(true); // New output arrives - should cancel plan check (stale) session.simulateTerminalOutput('New output from Claude...'); await new Promise((resolve) => setTimeout(resolve, 100)); // Auto-accept should NOT have fired (check was cancelled) expect(autoAcceptFired).toBe(false); controller.stop(); }); it('should discard stale plan check result (output during check)', async () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: true, aiPlanCheckTimeoutMs: 5000, }); let autoAcceptFired = false; controller.on('autoAcceptSent', () => { autoAcceptFired = true; }); controller.start(); // Plan mode patterns to trigger check session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); await new Promise((resolve) => setTimeout(resolve, 150)); // Output arrives during check - result should be discarded session.simulateTerminalOutput('Claude started working again'); // Wait for any pending check to complete await new Promise((resolve) => setTimeout(resolve, 200)); expect(autoAcceptFired).toBe(false); controller.stop(); }); it('should fall back to pre-filter-only when AI plan check is disabled', async () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: false, // Disabled - pre-filter only }); let autoAcceptFired = false; let planCheckStarted = false; controller.on('autoAcceptSent', () => { autoAcceptFired = true; }); controller.on('planCheckStarted', () => { planCheckStarted = true; }); controller.start(); // Plan mode patterns session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); await new Promise((resolve) => setTimeout(resolve, 200)); // Should send Enter directly (no AI check) expect(planCheckStarted).toBe(false); expect(autoAcceptFired).toBe(true); controller.stop(); }); it('should update plan checker config on updateConfig', () => { const controller = new RespawnController(session as unknown as Session, { aiPlanCheckEnabled: true, }); controller.updateConfig({ aiPlanCheckModel: 'claude-sonnet-4-20250514', aiPlanCheckCooldownMs: 60000, }); const config = controller.getConfig(); expect(config.aiPlanCheckModel).toBe('claude-sonnet-4-20250514'); expect(config.aiPlanCheckCooldownMs).toBe(60000); controller.stop(); }); it('should not auto-accept if pre-filter passes but no output received yet', async () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, aiPlanCheckEnabled: false, }); let autoAcceptFired = false; controller.on('autoAcceptSent', () => { autoAcceptFired = true; }); controller.start(); // Don't send any output - hasReceivedOutput should guard await new Promise((resolve) => setTimeout(resolve, 200)); expect(autoAcceptFired).toBe(false); controller.stop(); }); }); // ========== NEW COMPREHENSIVE TESTS ========== describe('RespawnController Configuration Validation', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); describe('Negative and invalid values', () => { it('should use defaults for negative idleTimeoutMs', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: -1000, }); const config = controller.getConfig(); // Default is 10000ms according to DEFAULT_CONFIG expect(config.idleTimeoutMs).toBe(10000); controller.stop(); }); it('should use defaults for zero idleTimeoutMs (validation converts <= 0 to default)', () => { // Note: the existing test shows 0 is accepted, but validation should // convert <= 0 to default. Let's verify the actual behavior. const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 0, }); const config = controller.getConfig(); // According to validateConfig: if (c.idleTimeoutMs <= 0) c.idleTimeoutMs = DEFAULT_CONFIG.idleTimeoutMs; expect(config.idleTimeoutMs).toBe(10000); controller.stop(); }); it('should use defaults for negative completionConfirmMs', () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: -5000, }); const config = controller.getConfig(); expect(config.completionConfirmMs).toBe(10000); controller.stop(); }); it('should use defaults for negative noOutputTimeoutMs', () => { const controller = new RespawnController(session as unknown as Session, { noOutputTimeoutMs: -30000, }); const config = controller.getConfig(); expect(config.noOutputTimeoutMs).toBe(30000); controller.stop(); }); it('should use defaults for negative interStepDelayMs', () => { const controller = new RespawnController(session as unknown as Session, { interStepDelayMs: -500, }); const config = controller.getConfig(); expect(config.interStepDelayMs).toBe(1000); controller.stop(); }); it('should use defaults for negative autoAcceptDelayMs', () => { const controller = new RespawnController(session as unknown as Session, { autoAcceptDelayMs: -100, }); const config = controller.getConfig(); expect(config.autoAcceptDelayMs).toBe(8000); controller.stop(); }); }); describe('completionConfirmMs capping to noOutputTimeoutMs', () => { it('should cap completionConfirmMs to noOutputTimeoutMs when larger', () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 60000, noOutputTimeoutMs: 30000, }); const config = controller.getConfig(); expect(config.completionConfirmMs).toBeLessThanOrEqual(config.noOutputTimeoutMs); expect(config.completionConfirmMs).toBe(30000); controller.stop(); }); it('should not cap completionConfirmMs when smaller than noOutputTimeoutMs', () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, noOutputTimeoutMs: 30000, }); const config = controller.getConfig(); expect(config.completionConfirmMs).toBe(5000); controller.stop(); }); it('should allow equal completionConfirmMs and noOutputTimeoutMs', () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 20000, noOutputTimeoutMs: 20000, }); const config = controller.getConfig(); expect(config.completionConfirmMs).toBe(20000); expect(config.noOutputTimeoutMs).toBe(20000); controller.stop(); }); }); describe('Valid configuration acceptance', () => { it('should accept valid configuration without modification', () => { const validConfig = { idleTimeoutMs: 5000, completionConfirmMs: 8000, noOutputTimeoutMs: 25000, interStepDelayMs: 500, autoAcceptDelayMs: 3000, }; const controller = new RespawnController(session as unknown as Session, validConfig); const config = controller.getConfig(); expect(config.idleTimeoutMs).toBe(5000); expect(config.completionConfirmMs).toBe(8000); expect(config.noOutputTimeoutMs).toBe(25000); expect(config.interStepDelayMs).toBe(500); expect(config.autoAcceptDelayMs).toBe(3000); controller.stop(); }); it('should accept very large timeout values', () => { const controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 86400000, // 24 hours noOutputTimeoutMs: 3600000, // 1 hour completionConfirmMs: 1800000, // 30 minutes }); const config = controller.getConfig(); expect(config.idleTimeoutMs).toBe(86400000); expect(config.noOutputTimeoutMs).toBe(3600000); expect(config.completionConfirmMs).toBe(1800000); controller.stop(); }); }); describe('AI check configuration validation', () => { it('should use defaults for negative aiIdleCheckTimeoutMs', () => { const controller = new RespawnController(session as unknown as Session, { aiIdleCheckTimeoutMs: -1000, }); const config = controller.getConfig(); expect(config.aiIdleCheckTimeoutMs).toBe(90000); controller.stop(); }); it('should use defaults for zero aiIdleCheckMaxContext', () => { const controller = new RespawnController(session as unknown as Session, { aiIdleCheckMaxContext: 0, }); const config = controller.getConfig(); expect(config.aiIdleCheckMaxContext).toBe(16000); controller.stop(); }); it('should use defaults for negative aiPlanCheckTimeoutMs', () => { const controller = new RespawnController(session as unknown as Session, { aiPlanCheckTimeoutMs: -5000, }); const config = controller.getConfig(); expect(config.aiPlanCheckTimeoutMs).toBe(60000); controller.stop(); }); }); }); describe('RespawnController Resume Behavior', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should work with completion message detection (not requiring promptDetected)', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, }); let cycleStarted = false; controller.on('respawnCycleStarted', () => { cycleStarted = true; }); controller.start(); // Only send completion message, never a prompt character session.simulateCompletionMessage(); // Wait for completion confirm timer to fire await new Promise((resolve) => setTimeout(resolve, 200)); // Should start cycle based on completion message alone expect(cycleStarted).toBe(true); controller.stop(); }); it('should detect idle via noOutput fallback when no completion message', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 100, noOutputTimeoutMs: 150, aiIdleCheckEnabled: false, }); let cycleStarted = false; controller.on('respawnCycleStarted', () => { cycleStarted = true; }); controller.start(); // Send some output but no completion message session.simulateTerminalOutput('Some text output'); // Wait for noOutput fallback await new Promise((resolve) => setTimeout(resolve, 300)); // Should eventually trigger via fallback expect(cycleStarted).toBe(true); controller.stop(); }); it('should resume watching state after pause', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, }); const states: string[] = []; controller.on('stateChanged', (state: string) => states.push(state)); controller.start(); expect(controller.state).toBe('watching'); controller.pause(); // State should still be watching but paused expect(controller.state).toBe('watching'); controller.resume(); expect(controller.state).toBe('watching'); // Verify cycle can still start after resume session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 200)); expect(states).toContain('confirming_idle'); controller.stop(); }); it('should handle resume after pause during cycle', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 30, interStepDelayMs: 50, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, }); controller.start(); session.simulateCompletionMessage(); // Wait for cycle to start await new Promise((resolve) => setTimeout(resolve, 100)); // Pause during cycle controller.pause(); const stateAtPause = controller.state; // Resume - controller stays in current state (does not reset to watching) // resume() only acts specially if in 'watching' state controller.resume(); // Should stay in the same state it was paused in (mid-cycle) // Note: resume() when not in 'watching' state does nothing special expect(controller.state).toBe(stateAtPause); expect(controller.isRunning).toBe(true); controller.stop(); }); }); describe('RespawnController Step Confirmation', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should transition through step states correctly', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 30, interStepDelayMs: 20, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, sendClear: false, // Skip clear for simpler test sendInit: false, // Skip init for simpler test }); const states: string[] = []; controller.on('stateChanged', (state: string) => states.push(state)); controller.start(); session.simulateCompletionMessage(); // Wait for full cycle await new Promise((resolve) => setTimeout(resolve, 300)); // Should have gone through: watching -> confirming_idle -> sending_update -> waiting_update -> watching expect(states).toContain('watching'); expect(states).toContain('confirming_idle'); expect(states).toContain('sending_update'); controller.stop(); }); it('should handle continuous output during step waiting', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, interStepDelayMs: 30, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, }); controller.start(); session.simulateCompletionMessage(); // Wait for update to be sent await new Promise((resolve) => setTimeout(resolve, 150)); // Simulate continuous output while waiting for update completion for (let i = 0; i < 5; i++) { session.simulateTerminalOutput(`Processing step ${i}...`); await new Promise((resolve) => setTimeout(resolve, 20)); } // Controller should still be functional expect(controller.isRunning).toBe(true); controller.stop(); }); it('should respect step timeout without infinite retry', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 30, interStepDelayMs: 20, noOutputTimeoutMs: 200, // Short fallback aiIdleCheckEnabled: false, }); controller.start(); session.simulateCompletionMessage(); // Wait for cycle to start await new Promise((resolve) => setTimeout(resolve, 100)); // Continuously emit output to prevent completion detection const outputInterval = setInterval(() => { session.simulateTerminalOutput('Still working...'); }, 50); // Wait a reasonable time await new Promise((resolve) => setTimeout(resolve, 500)); clearInterval(outputInterval); // Controller should still be running and not stuck expect(controller.isRunning).toBe(true); controller.stop(); }); it('should emit stepCompleted event when step finishes', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 30, interStepDelayMs: 20, noOutputTimeoutMs: 300, aiIdleCheckEnabled: false, sendClear: false, sendInit: false, }); let stepCompleted = false; controller.on('stepCompleted', () => { stepCompleted = true; }); controller.start(); session.simulateCompletionMessage(); // Wait for step to complete await new Promise((resolve) => setTimeout(resolve, 200)); // After update step completes (via timeout), should emit stepCompleted // Note: with sendClear/sendInit false, cycle completes after update expect(controller.isRunning).toBe(true); controller.stop(); }); }); describe('RespawnController AI Check Cooldown Behavior', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should enter cooldown after WORKING verdict', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 100, aiIdleCheckCooldownMs: 200, // Short cooldown for testing }); controller.start(); session.simulateCompletionMessage(); // Wait for AI check to start await new Promise((resolve) => setTimeout(resolve, 100)); const detection = controller.getDetectionStatus(); // AI check should have started or be in progress expect(detection.aiCheck).not.toBeNull(); controller.stop(); }); it('should track cooldown state in detection status', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 200, aiIdleCheckCooldownMs: 500, }); controller.start(); const detection = controller.getDetectionStatus(); expect(detection.aiCheck).not.toBeNull(); if (detection.aiCheck) { expect(['ready', 'checking', 'cooldown', 'disabled']).toContain(detection.aiCheck.status); } controller.stop(); }); it('should return to watching after AI check timeout', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 30, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 50, // Very short for test aiIdleCheckCooldownMs: 100, }); const states: string[] = []; controller.on('stateChanged', (state: string) => states.push(state)); controller.start(); session.simulateCompletionMessage(); // Wait for AI check to timeout await new Promise((resolve) => setTimeout(resolve, 200)); // Should have gone through ai_checking and back expect(states).toContain('ai_checking'); expect(controller.state).toBe('watching'); controller.stop(); }); it('should not start AI check when on cooldown', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 30, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: true, aiIdleCheckTimeoutMs: 50, aiIdleCheckCooldownMs: 1000, // Long cooldown }); controller.start(); // First cycle - should start AI check session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 100)); const detection1 = controller.getDetectionStatus(); // AI check was attempted // Reset by simulating working session.simulateWorking(); await new Promise((resolve) => setTimeout(resolve, 50)); // Second cycle - might be on cooldown session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 100)); // Controller should still be functional expect(controller.isRunning).toBe(true); controller.stop(); }); it('should respect AI check disabled state', () => { const controller = new RespawnController(session as unknown as Session, { aiIdleCheckEnabled: false, }); controller.start(); const detection = controller.getDetectionStatus(); expect(detection.aiCheck).toBeNull(); controller.stop(); }); }); describe('RespawnController Working Pattern Detection', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should detect thinking patterns', () => { const controller = new RespawnController(session as unknown as Session); controller.start(); session.simulateTerminalOutput('Thinking...'); const status = controller.getStatus(); expect(status.workingDetected).toBe(true); controller.stop(); }); it('should detect writing patterns', () => { const controller = new RespawnController(session as unknown as Session); controller.start(); session.simulateTerminalOutput('Writing file...'); const status = controller.getStatus(); expect(status.workingDetected).toBe(true); controller.stop(); }); it('should detect reading patterns', () => { const controller = new RespawnController(session as unknown as Session); controller.start(); session.simulateTerminalOutput('Reading src/index.ts'); const status = controller.getStatus(); expect(status.workingDetected).toBe(true); controller.stop(); }); it('should detect editing patterns', () => { const controller = new RespawnController(session as unknown as Session); controller.start(); session.simulateTerminalOutput('Editing src/file.ts'); const status = controller.getStatus(); expect(status.workingDetected).toBe(true); controller.stop(); }); it('should detect spinner characters as working', () => { const controller = new RespawnController(session as unknown as Session); controller.start(); // All spinner characters should indicate working const spinnerChars = [ '\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f', ]; for (const char of spinnerChars) { session.simulateTerminalOutput(char); } const status = controller.getStatus(); expect(status.workingDetected).toBe(true); controller.stop(); }); it('should clear working state after completion message', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 100, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, }); controller.start(); // First working session.simulateWorking(); let status = controller.getStatus(); expect(status.workingDetected).toBe(true); // Then completion session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 50)); // Working should be cleared after a bit of silence status = controller.getStatus(); // Note: working state may or may not be immediately cleared depending on timing // The important thing is the completion message was detected expect(status.promptDetected || !status.workingDetected).toBe(true); controller.stop(); }); }); describe('RespawnController Cycle Count Tracking', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should start with zero cycles', () => { const controller = new RespawnController(session as unknown as Session); expect(controller.currentCycle).toBe(0); controller.stop(); }); it('should increment cycle count on respawnCycleStarted', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 30, noOutputTimeoutMs: 300, aiIdleCheckEnabled: false, }); controller.start(); expect(controller.currentCycle).toBe(0); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 150)); expect(controller.currentCycle).toBeGreaterThan(0); controller.stop(); }); it('should emit respawnCycleStarted with cycle number', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 30, noOutputTimeoutMs: 300, aiIdleCheckEnabled: false, }); let cycleNumber = -1; controller.on('respawnCycleStarted', (cycle: number) => { cycleNumber = cycle; }); controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 150)); expect(cycleNumber).toBe(1); controller.stop(); }); it('should not reset cycle count on pause/resume', () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 30, noOutputTimeoutMs: 300, aiIdleCheckEnabled: false, }); controller.start(); // Manually set cycle count for testing (via private access would be needed) // Instead, verify it doesn't go negative controller.pause(); controller.resume(); expect(controller.currentCycle).toBeGreaterThanOrEqual(0); controller.stop(); }); }); describe('RespawnController Buffer Management', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should handle very large terminal buffers', () => { const controller = new RespawnController(session as unknown as Session); controller.start(); // Send 500KB of data const largeData = 'x'.repeat(500 * 1024); session.simulateTerminalOutput(largeData); expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle rapid small writes', () => { const controller = new RespawnController(session as unknown as Session); controller.start(); // Send many small writes rapidly for (let i = 0; i < 1000; i++) { session.simulateTerminalOutput(`Line ${i}\n`); } expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle interleaved ANSI codes and text', () => { const controller = new RespawnController(session as unknown as Session); controller.start(); // Mix of ANSI codes and text const mixed = '\x1b[32mGreen\x1b[0m Normal \x1b[1;34mBold Blue\x1b[0m End'; for (let i = 0; i < 100; i++) { session.simulateTerminalOutput(mixed); } expect(controller.isRunning).toBe(true); controller.stop(); }); it('should handle null bytes in output', () => { const controller = new RespawnController(session as unknown as Session); controller.start(); // Output with null bytes const withNulls = 'Hello\x00World\x00Test'; session.simulateTerminalOutput(withNulls); expect(controller.isRunning).toBe(true); controller.stop(); }); }); describe('RespawnController Timer Cleanup', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); it('should clean up timers on stop', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 1000, noOutputTimeoutMs: 2000, }); controller.start(); session.simulateCompletionMessage(); // Start a timer-based operation await new Promise((resolve) => setTimeout(resolve, 50)); // Stop should clean up all timers controller.stop(); // Wait to ensure no timer fires after stop await new Promise((resolve) => setTimeout(resolve, 200)); expect(controller.state).toBe('stopped'); expect(controller.isRunning).toBe(false); }); it('should clean up timers on state transitions', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 100, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, }); controller.start(); session.simulateCompletionMessage(); // Wait for confirming_idle await new Promise((resolve) => setTimeout(resolve, 30)); // Interrupt with working pattern session.simulateWorking(); // Should cancel completion confirm timer and return to watching await new Promise((resolve) => setTimeout(resolve, 50)); expect(controller.state).toBe('watching'); controller.stop(); }); it('should handle multiple rapid stop/start cycles', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 200, }); for (let i = 0; i < 10; i++) { controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 10)); controller.stop(); } // Should end in stopped state without errors expect(controller.state).toBe('stopped'); }); }); // ========== Hook-Based Detection Tests (Phase 1) ========== describe('RespawnController Hook-Based Idle Detection', () => { let session: MockSession; let controller: RespawnController; beforeEach(() => { session = new MockSession(); controller = new RespawnController(session as unknown as Session, { idleTimeoutMs: 100, interStepDelayMs: 50, completionConfirmMs: 50, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, }); }); afterEach(() => { controller.stop(); }); it('should expose signalStopHook method', () => { expect(typeof controller.signalStopHook).toBe('function'); }); it('should expose signalIdlePrompt method', () => { expect(typeof controller.signalIdlePrompt).toBe('function'); }); it('should set stopHookReceived in detection status when Stop hook signaled', () => { controller.start(); expect(controller.state).toBe('watching'); controller.signalStopHook(); const status = controller.getDetectionStatus(); expect(status.stopHookReceived).toBe(true); expect(status.stopHookTime).not.toBeNull(); expect(status.confidenceLevel).toBe(100); // Hook signals are definitive }); it('should include hook status in statusText when Stop hook received', () => { controller.start(); controller.signalStopHook(); const status = controller.getDetectionStatus(); expect(status.statusText).toContain('Stop hook received'); }); it('should trigger respawn cycle after Stop hook confirmation', async () => { const testController = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, }); const cycleStarted = vi.fn(); testController.on('respawnCycleStarted', cycleStarted); testController.start(); testController.signalStopHook(); // Wait for hook confirmation timer (3s default) await new Promise((resolve) => setTimeout(resolve, 3100)); expect(cycleStarted).toHaveBeenCalled(); testController.stop(); }); it('should immediately confirm idle when idle_prompt signaled (skip confirmation)', async () => { const testController = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, }); const cycleStarted = vi.fn(); testController.on('respawnCycleStarted', cycleStarted); testController.start(); testController.signalIdlePrompt(); // idle_prompt skips confirmation and goes directly to idle await new Promise((resolve) => setTimeout(resolve, 100)); expect(cycleStarted).toHaveBeenCalled(); testController.stop(); }); it('should cancel Stop hook confirmation if working patterns detected', async () => { const testController = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, // Long enough to not interfere with test timing noOutputTimeoutMs: 10000, aiIdleCheckEnabled: false, }); const cycleStarted = vi.fn(); testController.on('respawnCycleStarted', cycleStarted); testController.start(); testController.signalStopHook(); // Simulate working patterns IMMEDIATELY after hook (before confirmation) await new Promise((resolve) => setTimeout(resolve, 100)); session.simulateWorking(); // Wait longer than hook confirmation delay (3s) await new Promise((resolve) => setTimeout(resolve, 3500)); // Cycle should NOT have started because working was detected expect(cycleStarted).not.toHaveBeenCalled(); const status = testController.getDetectionStatus(); expect(status.stopHookReceived).toBe(false); // Reset by working detection testController.stop(); }); it('should ignore Stop hook when not in watching state', async () => { const testController = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 500, aiIdleCheckEnabled: false, sendClear: false, sendInit: false, }); testController.start(); testController.signalIdlePrompt(); // Start a cycle await new Promise((resolve) => setTimeout(resolve, 100)); // Now in sending_update state - Stop hook should be ignored testController.signalStopHook(); const status = testController.getDetectionStatus(); expect(status.stopHookReceived).toBe(false); testController.stop(); }); it('should have 100% confidence when hook signal is received', () => { controller.start(); // Before hook - confidence should be low let status = controller.getDetectionStatus(); expect(status.confidenceLevel).toBeLessThan(100); // After Stop hook - confidence should be 100% controller.signalStopHook(); status = controller.getDetectionStatus(); expect(status.confidenceLevel).toBe(100); }); }); // ========== CleanupManager Timer Tracking Tests ========== describe('RespawnController CleanupManager Timer Tracking', () => { let session: MockSession; beforeEach(() => { session = new MockSession(); }); describe('Timer lifecycle events', () => { it('should emit timerStarted when completion confirm timer begins', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 200, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, }); const timerEvents: Array<{ name: string; durationMs: number; endsAt: number; reason?: string }> = []; controller.on('timerStarted', (timer) => { timerEvents.push(timer); }); controller.start(); session.simulateCompletionMessage(); // Wait for completion detection to trigger timer await new Promise((resolve) => setTimeout(resolve, 100)); // Should have started at least one timer (completion-confirm or no-output-fallback) expect(timerEvents.length).toBeGreaterThan(0); // Each timer event should have valid fields for (const event of timerEvents) { expect(event.name).toBeTruthy(); expect(event.durationMs).toBeGreaterThan(0); expect(event.endsAt).toBeGreaterThan(0); } controller.stop(); }); it('should emit timerStarted with correct duration for no-output-fallback', async () => { const noOutputTimeoutMs = 300; const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, // Long so it doesn't fire first noOutputTimeoutMs, aiIdleCheckEnabled: false, }); const timerEvents: Array<{ name: string; durationMs: number }> = []; controller.on('timerStarted', (timer) => { timerEvents.push({ name: timer.name, durationMs: timer.durationMs }); }); controller.start(); // Send output to trigger no-output timer reset session.simulateTerminalOutput('some output'); await new Promise((resolve) => setTimeout(resolve, 100)); const noOutputTimer = timerEvents.find((e) => e.name === 'no-output-fallback'); if (noOutputTimer) { expect(noOutputTimer.durationMs).toBe(noOutputTimeoutMs); } controller.stop(); }); it('should emit timerCompleted when a timer fires', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, }); const completedTimers: string[] = []; controller.on('timerCompleted', (name: string) => { completedTimers.push(name); }); controller.start(); session.simulateCompletionMessage(); // Wait for completion confirm timer to fire (50ms + processing) await new Promise((resolve) => setTimeout(resolve, 200)); // At least one timer should have completed (completion-confirm) expect(completedTimers.length).toBeGreaterThan(0); controller.stop(); }); }); describe('Timer cancellation', () => { it('should emit timerCancelled when working patterns interrupt idle detection', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, // Long so we can cancel it noOutputTimeoutMs: 10000, aiIdleCheckEnabled: false, }); const cancelledTimers: Array<{ name: string; reason?: string }> = []; controller.on('timerCancelled', (name: string, reason?: string) => { cancelledTimers.push({ name, reason }); }); controller.start(); session.simulateCompletionMessage(); // Wait for timer to start await new Promise((resolve) => setTimeout(resolve, 100)); // Simulate working to cancel the completion confirm timer session.simulateWorking(); await new Promise((resolve) => setTimeout(resolve, 100)); // Working patterns should have cancelled at least one timer const hasCancel = cancelledTimers.length > 0; expect(hasCancel).toBe(true); controller.stop(); }); it('should include reason in timerCancelled event', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, noOutputTimeoutMs: 10000, aiIdleCheckEnabled: false, }); const cancelledTimers: Array<{ name: string; reason?: string }> = []; controller.on('timerCancelled', (name: string, reason?: string) => { cancelledTimers.push({ name, reason }); }); controller.start(); // Trigger Stop hook to start hook-confirm timer controller.signalStopHook(); await new Promise((resolve) => setTimeout(resolve, 100)); // Then working patterns should cancel it with a reason session.simulateWorking(); await new Promise((resolve) => setTimeout(resolve, 100)); const hookCancel = cancelledTimers.find((e) => e.name === 'hook-confirm'); if (hookCancel) { expect(hookCancel.reason).toBeTruthy(); } controller.stop(); }); }); describe('clearTimers on stop', () => { it('should clear all active timers when stopped', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, noOutputTimeoutMs: 10000, aiIdleCheckEnabled: false, }); controller.start(); session.simulateCompletionMessage(); // Wait for timers to start await new Promise((resolve) => setTimeout(resolve, 100)); // Should have active timers const timersBefore = controller.getActiveTimers(); expect(timersBefore.length).toBeGreaterThan(0); // Stop should clear all controller.stop(); const timersAfter = controller.getActiveTimers(); expect(timersAfter.length).toBe(0); }); it('should not fire timers after stop', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 100, noOutputTimeoutMs: 200, aiIdleCheckEnabled: false, }); let timerFiredAfterStop = false; controller.start(); session.simulateCompletionMessage(); // Wait for timers to start await new Promise((resolve) => setTimeout(resolve, 50)); controller.stop(); // Listen for any events after stop controller.on('timerCompleted', () => { timerFiredAfterStop = true; }); controller.on('respawnCycleStarted', () => { timerFiredAfterStop = true; }); // Wait longer than any timer duration await new Promise((resolve) => setTimeout(resolve, 400)); expect(timerFiredAfterStop).toBe(false); }); }); describe('Multiple start/stop cycles', () => { it('should recreate CleanupManager on restart (no stale timers)', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, noOutputTimeoutMs: 10000, aiIdleCheckEnabled: false, }); // First cycle: start, trigger completion-related timers, stop controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 100)); // Should have completion-confirm timer active const firstCycleTimers = controller.getActiveTimers(); const hasCompletionConfirm = firstCycleTimers.some((t) => t.name === 'completion-confirm'); expect(hasCompletionConfirm).toBe(true); controller.stop(); expect(controller.getActiveTimers().length).toBe(0); // Second cycle: start fresh — completion-confirm from first cycle should be gone controller.start(); expect(controller.state).toBe('watching'); const restartTimers = controller.getActiveTimers(); const staleCompletionConfirm = restartTimers.some((t) => t.name === 'completion-confirm'); expect(staleCompletionConfirm).toBe(false); // Can still trigger new timers session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 100)); const newTimers = controller.getActiveTimers(); const hasNewCompletionConfirm = newTimers.some((t) => t.name === 'completion-confirm'); expect(hasNewCompletionConfirm).toBe(true); controller.stop(); }); it('should handle rapid start/stop without timer leaks', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, noOutputTimeoutMs: 200, aiIdleCheckEnabled: false, }); const completedTimers: string[] = []; controller.on('timerCompleted', (name: string) => { completedTimers.push(name); }); // Rapid start/stop cycles for (let i = 0; i < 5; i++) { controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 20)); controller.stop(); } // Wait to check no stale timers fire const countBefore = completedTimers.length; await new Promise((resolve) => setTimeout(resolve, 300)); const countAfter = completedTimers.length; // No new timer completions should happen after final stop expect(countAfter).toBe(countBefore); expect(controller.state).toBe('stopped'); }); }); describe('activeTimers tracking', () => { it('should return correct timer metadata from getActiveTimers()', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, noOutputTimeoutMs: 10000, aiIdleCheckEnabled: false, }); controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 100)); const activeTimers = controller.getActiveTimers(); expect(activeTimers.length).toBeGreaterThan(0); for (const timer of activeTimers) { expect(timer.name).toBeTruthy(); expect(typeof timer.name).toBe('string'); expect(timer.remainingMs).toBeGreaterThanOrEqual(0); expect(timer.totalMs).toBeGreaterThan(0); // remainingMs should not exceed totalMs expect(timer.remainingMs).toBeLessThanOrEqual(timer.totalMs); } controller.stop(); }); it('should include activeTimers in detection status', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, noOutputTimeoutMs: 10000, aiIdleCheckEnabled: false, }); controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 100)); const detectionStatus = controller.getDetectionStatus(); expect(Array.isArray(detectionStatus.activeTimers)).toBe(true); expect(detectionStatus.activeTimers.length).toBeGreaterThan(0); controller.stop(); }); it('should remove timers from activeTimers after they complete', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 50, // Short timer noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, }); controller.start(); session.simulateCompletionMessage(); // Wait for completion confirm timer to be set up await new Promise((resolve) => setTimeout(resolve, 20)); const timersBefore = controller.getActiveTimers(); const hasCompletionConfirm = timersBefore.some((t) => t.name === 'completion-confirm'); // Wait for the timer to fire await new Promise((resolve) => setTimeout(resolve, 200)); const timersAfter = controller.getActiveTimers(); const stillHasCompletionConfirm = timersAfter.some((t) => t.name === 'completion-confirm'); // If we caught the timer before it fired, it should be gone now if (hasCompletionConfirm) { expect(stillHasCompletionConfirm).toBe(false); } controller.stop(); }); it('should remove timers from activeTimers after they are cancelled', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, noOutputTimeoutMs: 10000, aiIdleCheckEnabled: false, }); controller.start(); session.simulateCompletionMessage(); await new Promise((resolve) => setTimeout(resolve, 100)); // Should have timers const timersBefore = controller.getActiveTimers(); expect(timersBefore.length).toBeGreaterThan(0); // Cancel via working session.simulateWorking(); await new Promise((resolve) => setTimeout(resolve, 100)); // completion-confirm should be removed const timersAfter = controller.getActiveTimers(); const hasCompletionConfirm = timersAfter.some((t) => t.name === 'completion-confirm'); expect(hasCompletionConfirm).toBe(false); controller.stop(); }); it('should track hook-confirm timer when Stop hook is signaled', async () => { const controller = new RespawnController(session as unknown as Session, { completionConfirmMs: 5000, noOutputTimeoutMs: 10000, aiIdleCheckEnabled: false, }); controller.start(); controller.signalStopHook(); await new Promise((resolve) => setTimeout(resolve, 100)); const activeTimers = controller.getActiveTimers(); const hookTimer = activeTimers.find((t) => t.name === 'hook-confirm'); expect(hookTimer).toBeDefined(); if (hookTimer) { expect(hookTimer.totalMs).toBeGreaterThan(0); expect(hookTimer.remainingMs).toBeGreaterThan(0); } controller.stop(); }); }); });