ui: improve respawn controller layout and filter action log noise

- Stack timers row vertically (timers on top, action log below)
- Reduce action log height to 60px since it's now full width
- Filter action log to show only important entries:
  - Commands sent to console
  - Plan-check with action taken
  - Step completions
- Skip timer starts/cancels, detection updates, ai-check status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-25 19:59:42 +01:00
co-authored by Claude Opus 4.5
parent c6b16bf815
commit 40df0748c5
15 changed files with 2931 additions and 16 deletions
+860 -5
View File
@@ -572,11 +572,12 @@ describe('RespawnController Configuration', () => {
controller.stop();
});
it('should handle zero idleTimeoutMs', () => {
it('should handle zero idleTimeoutMs by resetting to default', () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 0,
});
expect(controller.getConfig().idleTimeoutMs).toBe(0);
// Zero is invalid (could cause infinite loops), so it's reset to default
expect(controller.getConfig().idleTimeoutMs).toBe(10000);
controller.stop();
});
@@ -588,11 +589,56 @@ describe('RespawnController Configuration', () => {
controller.stop();
});
it('should handle zero interStepDelayMs', () => {
it('should handle zero interStepDelayMs by resetting to default', () => {
const controller = new RespawnController(session as unknown as Session, {
interStepDelayMs: 0,
});
expect(controller.getConfig().interStepDelayMs).toBe(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();
});
@@ -865,7 +911,8 @@ describe('RespawnController Edge Cases', () => {
await new Promise(resolve => setTimeout(resolve, 100));
const status = controller.getStatus();
expect(status.timeSinceActivity).toBeGreaterThanOrEqual(100);
// Allow for slight timing variance (timers may fire 1-2ms early)
expect(status.timeSinceActivity).toBeGreaterThanOrEqual(95);
controller.stop();
});
@@ -1677,3 +1724,811 @@ describe('RespawnController AI Plan Mode Check', () => {
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');
});
});