mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59: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:
@@ -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