mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
feat: add hook-based idle detection for respawn controller
Implement multi-phase improvement to respawn controller's idle detection: Phase 1 - Stop Hook Detection: - Add signalStopHook() method - definitive signal when Claude finishes - 3s confirmation timer to handle race conditions - Skip AI check when hook received (100% confidence) Phase 2 - idle_prompt Detection: - Add signalIdlePrompt() method - fires after 60s+ of idle - Immediately confirms idle (no confirmation timer needed) Phase 3 - Transcript File Monitoring: - New TranscriptWatcher class watches session JSONL files - Detects: completion, tool execution, plan mode, errors - Signals respawn controller for supporting detection Web UI Updates: - Hook indicator with purple styling and pulse animation - Shows "Stop hook received" or "idle_prompt hook received" - 100% confidence displayed with hook-confirmed style This significantly improves idle detection reliability by using definitive signals from Claude Code rather than parsing terminal output. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2531,4 +2531,158 @@ describe('RespawnController Timer Cleanup', () => {
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { TranscriptWatcher, TranscriptState } from '../src/transcript-watcher.js';
|
||||
import { writeFileSync, unlinkSync, mkdirSync, existsSync, appendFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
describe('TranscriptWatcher', () => {
|
||||
let watcher: TranscriptWatcher;
|
||||
let testDir: string;
|
||||
let testFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
watcher = new TranscriptWatcher();
|
||||
testDir = join(tmpdir(), `transcript-test-${Date.now()}`);
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
}
|
||||
testFile = join(testDir, 'test-transcript.jsonl');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
watcher.stop();
|
||||
// Clean up test file
|
||||
try {
|
||||
if (existsSync(testFile)) {
|
||||
unlinkSync(testFile);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should start in stopped state', () => {
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should have initial state with defaults', () => {
|
||||
const state = watcher.getState();
|
||||
expect(state.isComplete).toBe(false);
|
||||
expect(state.toolExecuting).toBe(false);
|
||||
expect(state.currentTool).toBeNull();
|
||||
expect(state.hasError).toBe(false);
|
||||
expect(state.planModeDetected).toBe(false);
|
||||
expect(state.entryCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Watching', () => {
|
||||
it('should start watching an existing file', () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle non-existent file by polling', () => {
|
||||
const nonExistent = join(testDir, 'nonexistent.jsonl');
|
||||
watcher.start(nonExistent);
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('should stop watching on stop()', () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
watcher.stop();
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should update path with updatePath()', () => {
|
||||
const file1 = join(testDir, 'file1.jsonl');
|
||||
const file2 = join(testDir, 'file2.jsonl');
|
||||
writeFileSync(file1, '');
|
||||
writeFileSync(file2, '');
|
||||
|
||||
watcher.start(file1);
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
|
||||
watcher.updatePath(file2);
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entry Processing', () => {
|
||||
it('should process user entry and reset state', async () => {
|
||||
// Start with some state
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
|
||||
// Add user entry
|
||||
const userEntry = { type: 'user', timestamp: new Date().toISOString(), message: { role: 'user', content: 'test' } };
|
||||
appendFileSync(testFile, JSON.stringify(userEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const state = watcher.getState();
|
||||
expect(state.entryCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should emit transcript:complete on result entry', async () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
|
||||
const completeHandler = vi.fn();
|
||||
watcher.on('transcript:complete', completeHandler);
|
||||
|
||||
// Add result entry
|
||||
const resultEntry = { type: 'result', timestamp: new Date().toISOString() };
|
||||
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(completeHandler).toHaveBeenCalled();
|
||||
const state = watcher.getState();
|
||||
expect(state.isComplete).toBe(true);
|
||||
});
|
||||
|
||||
it('should track tool execution', async () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
|
||||
const toolStartHandler = vi.fn();
|
||||
watcher.on('transcript:tool_start', toolStartHandler);
|
||||
|
||||
// Add assistant entry with tool_use
|
||||
const assistantEntry = {
|
||||
type: 'assistant',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } }
|
||||
]
|
||||
}
|
||||
};
|
||||
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(toolStartHandler).toHaveBeenCalledWith('Read');
|
||||
const state = watcher.getState();
|
||||
expect(state.toolExecuting).toBe(true);
|
||||
expect(state.currentTool).toBe('Read');
|
||||
});
|
||||
|
||||
it('should detect plan mode from AskUserQuestion tool', async () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
|
||||
const planModeHandler = vi.fn();
|
||||
watcher.on('transcript:plan_mode', planModeHandler);
|
||||
|
||||
// Add assistant entry with AskUserQuestion
|
||||
const assistantEntry = {
|
||||
type: 'assistant',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } }
|
||||
]
|
||||
}
|
||||
};
|
||||
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(planModeHandler).toHaveBeenCalled();
|
||||
const state = watcher.getState();
|
||||
expect(state.planModeDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect errors in result entry', async () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
|
||||
// Add result entry with error
|
||||
const resultEntry = {
|
||||
type: 'result',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: { type: 'api_error', message: 'Rate limited' }
|
||||
};
|
||||
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
const state = watcher.getState();
|
||||
expect(state.hasError).toBe(true);
|
||||
expect(state.errorMessage).toContain('Rate limited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should return a copy of state', () => {
|
||||
const state1 = watcher.getState();
|
||||
const state2 = watcher.getState();
|
||||
expect(state1).not.toBe(state2); // Different objects
|
||||
expect(state1).toEqual(state2); // Same content
|
||||
});
|
||||
|
||||
it('should reset state on stop()', () => {
|
||||
writeFileSync(testFile, '');
|
||||
watcher.start(testFile);
|
||||
watcher.stop();
|
||||
const state = watcher.getState();
|
||||
expect(state.entryCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user