mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: add AI-powered idle check for respawn controller
Replace the "Worked for Xm Xs" pattern as the sole primary idle detection signal with a final AI-powered check. When pre-filter conditions are met (output silence, no working patterns, tokens stable), a fresh Claude CLI session is spawned in a screen to analyze terminal output and provide a definitive IDLE/WORKING verdict before proceeding with the respawn cycle. New state in state machine: `ai_checking` (between pre-filter confirmation and `sending_update`). WORKING verdict triggers a 3-minute cooldown. Errors auto-disable after 3 consecutive failures, falling back to the existing noOutputTimeoutMs safety net. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { AiIdleChecker, AiCheckVerdict } from '../src/ai-idle-checker.js';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('node:child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
spawn: vi.fn(() => ({
|
||||
unref: vi.fn(),
|
||||
pid: 12345,
|
||||
on: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
readFileSync: vi.fn(() => ''),
|
||||
unlinkSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedSpawn = vi.mocked(spawn);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedReadFileSync = vi.mocked(readFileSync);
|
||||
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
||||
|
||||
describe('AiIdleChecker', () => {
|
||||
let checker: AiIdleChecker;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
checker = new AiIdleChecker('test-session-1234', {
|
||||
checkTimeoutMs: 5000,
|
||||
cooldownMs: 3000,
|
||||
errorCooldownMs: 1000,
|
||||
maxConsecutiveErrors: 3,
|
||||
maxContextChars: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
checker.cancel();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should start in ready status', () => {
|
||||
expect(checker.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('should not be on cooldown initially', () => {
|
||||
expect(checker.isOnCooldown()).toBe(false);
|
||||
});
|
||||
|
||||
it('should have clean initial state', () => {
|
||||
const state = checker.getState();
|
||||
expect(state.status).toBe('ready');
|
||||
expect(state.lastVerdict).toBeNull();
|
||||
expect(state.lastReasoning).toBeNull();
|
||||
expect(state.consecutiveErrors).toBe(0);
|
||||
expect(state.totalChecks).toBe(0);
|
||||
expect(state.disabledReason).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Parsing', () => {
|
||||
it('should parse IDLE verdict', async () => {
|
||||
// Set up mock to return IDLE result after polling
|
||||
mockedReadFileSync.mockReturnValueOnce('') // writeFileSync creates empty file
|
||||
.mockReturnValueOnce('IDLE\nSession shows completion message and prompt.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('some terminal output');
|
||||
|
||||
// First poll - empty
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
// Second poll - has result
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('IDLE');
|
||||
expect(result.reasoning).toContain('completion message');
|
||||
});
|
||||
|
||||
it('should parse WORKING verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nSpinner characters detected, still processing.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('some terminal output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('WORKING');
|
||||
expect(result.reasoning).toContain('Spinner');
|
||||
});
|
||||
|
||||
it('should handle lowercase verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('idle\nDone.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('IDLE');
|
||||
});
|
||||
|
||||
it('should return ERROR for unparseable output', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('Something unexpected happened.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('Could not parse');
|
||||
});
|
||||
|
||||
it('should return ERROR for empty output', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('Empty output');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Spawn', () => {
|
||||
it('should spawn a screen session for the check', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('terminal output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
// Verify screen was spawned with correct args
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'screen',
|
||||
expect.arrayContaining(['-dmS', expect.stringContaining('claudeman-aicheck-')]),
|
||||
expect.objectContaining({ detached: true, stdio: 'ignore' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should kill existing screen with same name before spawning', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
// First call should try to kill existing screen
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('screen -X -S claudeman-aicheck-'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should create temp file for output capture', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('claudeman-aicheck-'),
|
||||
''
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timeout', () => {
|
||||
it('should timeout after checkTimeoutMs', async () => {
|
||||
// Never return a result
|
||||
mockedReadFileSync.mockReturnValue('');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
|
||||
// Advance past timeout
|
||||
await vi.advanceTimersByTimeAsync(5100);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cancellation', () => {
|
||||
it('should cancel an in-progress check', async () => {
|
||||
mockedReadFileSync.mockReturnValue(''); // Never complete
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
|
||||
// Cancel immediately - the resolve callback will be called synchronously
|
||||
checker.cancel();
|
||||
expect(checker.status).toBe('ready');
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toBe('Cancelled');
|
||||
});
|
||||
|
||||
it('should clean up screen on cancel', async () => {
|
||||
mockedReadFileSync.mockReturnValue('');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
|
||||
checker.cancel();
|
||||
await checkPromise;
|
||||
|
||||
// Should have tried to kill the screen (initial kill + cleanup kill)
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
call => typeof call[0] === 'string' && call[0].includes('quit')
|
||||
);
|
||||
expect(killCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should be a no-op if not checking', () => {
|
||||
checker.cancel(); // Should not throw
|
||||
expect(checker.status).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cooldown', () => {
|
||||
it('should start cooldown after WORKING verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nStill processing.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
expect(checker.status).toBe('cooldown');
|
||||
expect(checker.isOnCooldown()).toBe(true);
|
||||
expect(checker.getCooldownRemainingMs()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return to ready after cooldown expires', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(checker.status).toBe('cooldown');
|
||||
|
||||
// Advance past cooldown
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
|
||||
expect(checker.status).toBe('ready');
|
||||
expect(checker.isOnCooldown()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start new check during cooldown', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const firstCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await firstCheck;
|
||||
|
||||
// Try to check during cooldown
|
||||
const result = await checker.check('output');
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toBe('On cooldown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should start error cooldown after parse error', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage output\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(checker.status).toBe('cooldown');
|
||||
expect(checker.getState().consecutiveErrors).toBe(1);
|
||||
});
|
||||
|
||||
it('should disable after maxConsecutiveErrors', async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
// Clear cooldown for next check (except after the last one which disables)
|
||||
if (i < 2) {
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
}
|
||||
}
|
||||
|
||||
expect(checker.status).toBe('disabled');
|
||||
expect(checker.getState().disabledReason).toContain('3 consecutive errors');
|
||||
});
|
||||
|
||||
it('should reset error counter on successful check', async () => {
|
||||
// First check: error
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
const firstCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await firstCheck;
|
||||
expect(checker.getState().consecutiveErrors).toBe(1);
|
||||
|
||||
// Wait for cooldown
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
|
||||
// Second check: success
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\nDone.\n__AICHECK_DONE__');
|
||||
const secondCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await secondCheck;
|
||||
|
||||
expect(checker.getState().consecutiveErrors).toBe(0);
|
||||
});
|
||||
|
||||
it('should return ERROR if disabled', async () => {
|
||||
checker.updateConfig({ enabled: false });
|
||||
|
||||
const result = await checker.check('output');
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('Disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Buffer Handling', () => {
|
||||
it('should strip ANSI codes from terminal buffer', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const ansiBuffer = '\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m text';
|
||||
const checkPromise = checker.check(ansiBuffer);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
// Verify the spawn command was called (which means the prompt was built)
|
||||
expect(mockedSpawn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trim buffer to maxContextChars', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
// Create buffer longer than maxContextChars (1000)
|
||||
const longBuffer = 'x'.repeat(2000);
|
||||
const checkPromise = checker.check(longBuffer);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
// The check should complete successfully (trimming happened internally)
|
||||
expect(mockedSpawn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Updates', () => {
|
||||
it('should disable when config sets enabled=false', () => {
|
||||
checker.updateConfig({ enabled: false });
|
||||
expect(checker.status).toBe('disabled');
|
||||
});
|
||||
|
||||
it('should re-enable when config sets enabled=true', () => {
|
||||
checker.updateConfig({ enabled: false });
|
||||
expect(checker.status).toBe('disabled');
|
||||
|
||||
checker.updateConfig({ enabled: true });
|
||||
expect(checker.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('should update model in config', () => {
|
||||
checker.updateConfig({ model: 'claude-sonnet-4-20250514' });
|
||||
expect(checker.getConfig().model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset', () => {
|
||||
it('should clear all state on reset', async () => {
|
||||
// Trigger a WORKING verdict to set state
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(checker.status).toBe('cooldown');
|
||||
|
||||
// Reset
|
||||
checker.reset();
|
||||
|
||||
expect(checker.status).toBe('ready');
|
||||
expect(checker.isOnCooldown()).toBe(false);
|
||||
const state = checker.getState();
|
||||
expect(state.lastVerdict).toBeNull();
|
||||
expect(state.consecutiveErrors).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events', () => {
|
||||
it('should emit checkStarted event', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('checkStarted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit checkCompleted event with result', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('checkCompleted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\nAll done.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
|
||||
verdict: 'IDLE',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should emit cooldownStarted event after WORKING', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('cooldownStarted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.any(Number));
|
||||
});
|
||||
|
||||
it('should emit disabled event after max errors', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('disabled', handler);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
if (i < 2) await vi.advanceTimersByTimeAsync(1100);
|
||||
}
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.stringContaining('consecutive errors'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent Check Prevention', () => {
|
||||
it('should reject if already checking', async () => {
|
||||
mockedReadFileSync.mockReturnValue(''); // Never complete
|
||||
|
||||
const firstCheck = checker.check('output');
|
||||
|
||||
const secondResult = await checker.check('output');
|
||||
expect(secondResult.verdict).toBe('ERROR');
|
||||
expect(secondResult.reasoning).toBe('Already checking');
|
||||
|
||||
// Clean up first check
|
||||
checker.cancel();
|
||||
await firstCheck;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,7 @@ describe('RespawnController', () => {
|
||||
interStepDelayMs: 50,
|
||||
completionConfirmMs: 50, // Short confirmation delay for testing
|
||||
noOutputTimeoutMs: 500, // Short fallback timeout for testing
|
||||
aiIdleCheckEnabled: false, // Disable AI check for legacy tests
|
||||
});
|
||||
});
|
||||
|
||||
@@ -614,6 +615,7 @@ describe('RespawnController State Transitions', () => {
|
||||
interStepDelayMs: 20,
|
||||
completionConfirmMs: 50, // Short confirmation for testing
|
||||
noOutputTimeoutMs: 300, // Short fallback for testing
|
||||
aiIdleCheckEnabled: false, // Disable AI check for legacy tests
|
||||
});
|
||||
});
|
||||
|
||||
@@ -814,6 +816,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
interStepDelayMs: 10,
|
||||
completionConfirmMs: 30, // Short confirmation for testing
|
||||
noOutputTimeoutMs: 200, // Short fallback for testing
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
expect(controller.currentCycle).toBe(0);
|
||||
@@ -873,6 +876,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100, // Short delay for testing
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -899,6 +903,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 200, // Longer than autoAcceptDelay
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -924,6 +929,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -946,6 +952,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -968,6 +975,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 150,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -998,6 +1006,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptCount = 0;
|
||||
@@ -1031,6 +1040,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
completionConfirmMs: 50,
|
||||
interStepDelayMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -1062,6 +1072,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -1089,6 +1100,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -1116,3 +1128,231 @@ describe('RespawnController Edge Cases', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user