import { describe, expect, mock, test } from 'bun:test'; import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils'; import { createTodoContinuationHook } from './index'; describe('createTodoContinuationHook', () => { function createMockContext(overrides?: { todoResult?: { data?: Array<{ id: string; content: string; status: string; priority: string; }>; }; messagesResult?: { data?: Array<{ info?: { role?: string }; parts?: Array<{ type?: string; text?: string }>; }>; }; }) { return { client: { session: { todo: mock(async () => overrides?.todoResult ?? { data: [] }), messages: mock(async () => overrides?.messagesResult ?? { data: [] }), prompt: mock(async () => ({})), }, }, } as any; } async function delay(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } // Notification prompts (noReply:true, no marker) fire immediately when // scheduling a continuation. These helpers check only for actual // continuation prompts (with SLIM_INTERNAL_INITIATOR_MARKER). function hasContinuation(m: ReturnType): boolean { return m.mock.calls.some((c: any[]) => (c[0]?.body?.parts as any[])?.some((p: any) => p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER), ), ); } function contCount(m: ReturnType): number { return m.mock.calls.filter((c: any[]) => (c[0]?.body?.parts as any[])?.some((p: any) => p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER), ), ).length; } function contCall(m: ReturnType): any[] { const call = m.mock.calls.find((c: any[]) => (c[0]?.body?.parts as any[])?.some((p: any) => p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER), ), ); if (!call) { throw new Error('No continuation call found'); } return call; } describe('tool toggle', () => { test('calling auto_continue execute with { enabled: true } sets state', async () => { const ctx = createMockContext(); const hook = createTodoContinuationHook(ctx); const result = await hook.tool.auto_continue.execute({ enabled: true }); expect(result).toContain('Auto-continue enabled'); expect(result).toContain('up to 5'); }); test('calling auto_continue execute with { enabled: false } disables', async () => { const ctx = createMockContext(); const hook = createTodoContinuationHook(ctx); const result = await hook.tool.auto_continue.execute({ enabled: false }); expect(result).toBe('Auto-continue disabled.'); }); }); describe('continuation scheduling', () => { test('session idle + enabled + incomplete todos → schedules continuation', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, { id: '2', content: 'todo2', status: 'completed', priority: 'low' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Here is the result' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, }); // Enable auto-continue await hook.tool.auto_continue.execute({ enabled: true }); // Fire session.idle event await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Wait for cooldown await delay(60); // Verify session.prompt was called with continuation prompt expect(hasContinuation(ctx.client.session.prompt)).toBe(true); const promptCall = contCall(ctx.client.session.prompt); expect(promptCall[0].path.id).toBe('session-123'); expect(promptCall[0].body.parts[0].text).toContain( '[Auto-continue: enabled - there are incomplete todos remaining.', ); expect(promptCall[0].body.parts[0].text).toContain( SLIM_INTERNAL_INITIATOR_MARKER, ); }); test('disabled → no continuation', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Done' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); // Do NOT enable auto-continue // Fire session.idle event await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Wait for cooldown await delay(60); // Verify session.prompt was NOT called expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('last message is a question → skip', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [ { type: 'text', text: 'Should I proceed with the next step?' }, ], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); // Enable auto-continue await hook.tool.auto_continue.execute({ enabled: true }); // Fire session.idle event await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Wait for cooldown await delay(60); // Verify continuation NOT scheduled expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('question detection with question mark → skip', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Ready to continue?' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await delay(60); expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('question detection with "would you like" phrase → skip', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [ { type: 'text', text: 'Would you like me to proceed?', }, ], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await delay(60); expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('max continuations reached → skip', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 2, cooldownMs: 50, }); await hook.tool.auto_continue.execute({ enabled: true }); // Fire idle events up to maxContinuations for (let i = 0; i < 2; i++) { await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await delay(60); } // Reset mock for the 3rd attempt ctx.client.session.prompt.mockClear(); // On the N+1th idle, verify no continuation scheduled await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await delay(60); expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('abort suppress window → skip', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); // Seed orchestrator session await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await hook.tool.auto_continue.execute({ enabled: true }); // Fire session.error with MessageAbortedError await hook.handleEvent({ event: { type: 'session.error', properties: { sessionID: 'session-123', error: { name: 'MessageAbortedError' }, }, }, }); // Immediately fire session.idle await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Wait less than suppress window (5s) - just enough to verify it's working await delay(100); // Verify no continuation within suppress window expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('session busy → cancel pending timer', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 100, }); await hook.tool.auto_continue.execute({ enabled: true }); // Schedule a continuation await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Before cooldown expires, fire session.status with busy await delay(50); await hook.handleEvent({ event: { type: 'session.status', properties: { sessionID: 'session-123', status: { type: 'busy' }, }, }, }); // Advance past original cooldown await delay(60); // Verify timer was cancelled and prompt NOT called expect(hasContinuation(ctx.client.session.prompt)).toBe(false); }); test('sub-agent session.busy does NOT cancel orchestrator timer', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 100, }); await hook.tool.auto_continue.execute({ enabled: true }); // Schedule a continuation for orchestrator session await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // A sub-agent (different session) goes busy await delay(50); await hook.handleEvent({ event: { type: 'session.status', properties: { sessionID: 'sub-agent-456', status: { type: 'busy' }, }, }, }); // Advance past original cooldown await delay(60); // Orchestrator timer should still fire — prompt was called expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); test('all todos complete → skip', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'completed', priority: 'high', }, { id: '2', content: 'todo2', status: 'cancelled', priority: 'low' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'All done' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await delay(60); expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('non-orchestrator session → skip', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); // First idle from session A (becomes orchestrator) await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-A' }, }, }); await delay(60); // Verify prompt was called for session A expect(hasContinuation(ctx.client.session.prompt)).toBe(true); // Reset mock ctx.client.session.prompt.mockClear(); // Second idle from session B (different sessionID) await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-B' }, }, }); await delay(60); // Verify no continuation for session B expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('cooldownMs from config', async () => { const customCooldownMs = 100; const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: customCooldownMs, }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Advance timer by less than custom cooldown await delay(customCooldownMs - 1); // Verify prompt not called yet expect(hasContinuation(ctx.client.session.prompt)).toBe(false); // Advance timer by remaining 1ms await delay(1); // Now prompt should be called expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); }); describe('event handling - session.error', () => { test('MessageAbortedError sets suppress window', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); // Seed orchestrator session await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await hook.tool.auto_continue.execute({ enabled: true }); // Fire session.error with MessageAbortedError await hook.handleEvent({ event: { type: 'session.error', properties: { sessionID: 'session-123', error: { name: 'MessageAbortedError' }, }, }, }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Wait less than suppress window await delay(100); // Verify no continuation within suppress window expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('AbortError sets suppress window', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); // Seed orchestrator session (disabled, so no continuation fires) await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.error', properties: { sessionID: 'session-123', error: { name: 'AbortError' }, }, }, }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Wait less than suppress window await delay(100); // Verify no continuation within suppress window expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('other errors do not set suppress window', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.error', properties: { sessionID: 'session-123', error: { name: 'NetworkError' }, }, }, }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await delay(60); // Prompt should be called immediately (no suppress window) expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); }); describe('event handling - session.deleted', () => { test('clears pending timer on session delete', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 100, }); await hook.tool.auto_continue.execute({ enabled: true }); // Schedule continuation await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Delete session before timer fires await delay(50); await hook.handleEvent({ event: { type: 'session.deleted', properties: { sessionID: 'session-123', }, }, }); // Advance past original cooldown await delay(60); // Verify timer was cancelled and prompt NOT called expect(hasContinuation(ctx.client.session.prompt)).toBe(false); }); test('sub-agent session.deleted does NOT cancel orchestrator timer', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 100, }); await hook.tool.auto_continue.execute({ enabled: true }); // Schedule continuation for orchestrator session await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // A sub-agent (different session) gets deleted await delay(50); await hook.handleEvent({ event: { type: 'session.deleted', properties: { sessionID: 'sub-agent-456', }, }, }); // Advance past original cooldown await delay(60); // Orchestrator timer should still fire — prompt was called expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); test('resets orchestrator session when deleted session matches', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); // First idle sets orchestrator await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-A' }, }, }); await delay(60); // Delete orchestrator session await hook.handleEvent({ event: { type: 'session.deleted', properties: { sessionID: 'session-A', }, }, }); // Second idle from new session should become orchestrator ctx.client.session.prompt.mockClear(); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-B' }, }, }); await delay(60); // Prompt should be called for session-B (new orchestrator) expect(hasContinuation(ctx.client.session.prompt)).toBe(true); const promptCall = contCall(ctx.client.session.prompt); expect(promptCall[0].path.id).toBe('session-B'); }); }); describe('error handling', () => { test('fetch todos failure → skips continuation', async () => { const ctx = createMockContext({ todoResult: undefined as any, }); ctx.client.session.todo = mock(async () => { throw new Error('Failed to fetch todos'); }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await delay(60); expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('fetch messages failure → skips continuation', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, }); ctx.client.session.messages = mock(async () => { throw new Error('Failed to fetch messages'); }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await delay(60); expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); }); describe('command.execute.before interception', () => { test('unrelated command → no interception', async () => { const ctx = createMockContext(); const hook = createTodoContinuationHook(ctx); const output = { parts: [] as Array<{ type: string; text?: string }> }; await hook.handleCommandExecuteBefore( { command: 'help', sessionID: 'session-123', arguments: '' }, output, ); expect(output.parts).toHaveLength(0); }); test('/auto-continue enables and injects continuation when incomplete todos', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high', }, ], }, }); const hook = createTodoContinuationHook(ctx); const output = { parts: [] as Array<{ type: string; text?: string }> }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 'session-123', arguments: '' }, output, ); expect(output.parts).toHaveLength(1); expect(output.parts[0].text).toContain( '[Auto-continue: enabled - there are incomplete todos remaining.', ); expect(output.parts[0].text).toContain(SLIM_INTERNAL_INITIATOR_MARKER); }); test('/auto-continue enables but no continuation when all todos complete', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'completed', priority: 'high', }, ], }, }); const hook = createTodoContinuationHook(ctx); const output = { parts: [] as Array<{ type: string; text?: string }> }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 'session-123', arguments: '' }, output, ); expect(output.parts).toHaveLength(1); expect(output.parts[0].text).toContain('No incomplete todos right now'); }); test('/auto-continue toggles off when already enabled', async () => { const ctx = createMockContext(); const hook = createTodoContinuationHook(ctx); const output = { parts: [] as Array<{ type: string; text?: string }> }; // Enable via tool await hook.tool.auto_continue.execute({ enabled: true }); // Toggle off via command await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 'session-123', arguments: '' }, output, ); expect(output.parts).toHaveLength(1); expect(output.parts[0].text).toContain('disabled by user command'); }); test('/auto-continue resets consecutive continuations on toggle', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 2, cooldownMs: 50, }); // Enable and run up to max await hook.tool.auto_continue.execute({ enabled: true }); for (let i = 0; i < 2; i++) { await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); await delay(60); } // Toggle off then on via command (resets count) const outputOff = { parts: [] as Array<{ type: string; text?: string }>, }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 'session-123', arguments: '' }, outputOff, ); expect(outputOff.parts[0].text).toContain('disabled'); const outputOn = { parts: [] as Array<{ type: string; text?: string }>, }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 'session-123', arguments: '' }, outputOn, ); // Should have continuation prompt again (count was reset) expect(outputOn.parts[0].text).toContain( '[Auto-continue: enabled - there are incomplete todos remaining.', ); }); test('/auto-continue with todo fetch failure → enables without continuation', async () => { const ctx = createMockContext(); ctx.client.session.todo = mock(async () => { throw new Error('Network error'); }); const hook = createTodoContinuationHook(ctx); const output = { parts: [] as Array<{ type: string; text?: string }> }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 'session-123', arguments: '' }, output, ); // Should still enable but skip continuation (no todos fetched) expect(output.parts).toHaveLength(1); expect(output.parts[0].text).toContain('No incomplete todos right now'); }); }); describe('config defaults', () => { test('default config: maxContinuations = 5, cooldownMs = 3000', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 'todo1', status: 'pending', priority: 'high' }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); const hook = createTodoContinuationHook(ctx); // No config passed const result = await hook.tool.auto_continue.execute({ enabled: true }); expect(result).toContain('up to 5'); // Test default cooldown - we'll just verify it waits before calling await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-123' }, }, }); // Wait less than default cooldown await delay(100); expect(hasContinuation(ctx.client.session.prompt)).toBe(false); // Wait past default cooldown await delay(2900); expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); }); describe('council review findings', () => { describe('CRITICAL-1: counter bypass via session.status→busy', () => { test('counter persists when busy fires during auto-injection', async () => { let promptResolve!: () => void; const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); // Make prompt hang so isAutoInjecting stays true ctx.client.session.prompt = mock(async () => { await new Promise((r) => { promptResolve = r; }); }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 2, cooldownMs: 50, }); await hook.tool.auto_continue.execute({ enabled: true }); // Cycle 1: idle → timer → prompt hangs await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Session goes busy from prompt — isAutoInjecting is true, // so counter should NOT be reset await hook.handleEvent({ event: { type: 'session.status', properties: { sessionID: 's1', status: { type: 'busy' }, }, }, }); // Resolve prompt → counter = 1 promptResolve(); await delay(10); // Cycle 2: idle → timer → prompt hangs await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Session goes busy again — counter still not reset await hook.handleEvent({ event: { type: 'session.status', properties: { sessionID: 's1', status: { type: 'busy' }, }, }, }); // Resolve prompt → counter = 2 promptResolve(); await delay(10); // Cycle 3: counter = 2 >= maxContinuations = 2 → BLOCKED ctx.client.session.prompt = mock(async () => ({})); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); expect(hasContinuation(ctx.client.session.prompt)).toBe(false); }); }); describe('CRITICAL-2: disable cancels pending timer', () => { test('tool disable during cooldown prevents injection', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 }); await hook.tool.auto_continue.execute({ enabled: true }); // Fire idle → timer scheduled (100ms cooldown) await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); // Disable before timer fires await delay(50); await hook.tool.auto_continue.execute({ enabled: false }); // Wait past original cooldown await delay(60); expect(hasContinuation(ctx.client.session.prompt)).toBe(false); }); test('command disable during cooldown prevents injection', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 }); // Enable via command const outputOn = { parts: [] as Array<{ type: string; text?: string }>, }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 's1', arguments: 'on', }, outputOn, ); // Fire idle → timer scheduled await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); // Disable via command before timer fires await delay(50); const outputOff = { parts: [] as Array<{ type: string; text?: string }>, }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 's1', arguments: 'off', }, outputOff, ); // Wait past original cooldown await delay(60); expect(hasContinuation(ctx.client.session.prompt)).toBe(false); }); }); describe('MAJOR-1: session.deleted resets counter', () => { test('deleted orchestrator session resets counter for next session', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 2, cooldownMs: 50, }); await hook.tool.auto_continue.execute({ enabled: true }); // Cycle 1: idle → inject → counter = 1 await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Delete orchestrator session → counter should reset await hook.handleEvent({ event: { type: 'session.deleted', properties: { sessionID: 's1' }, }, }); // New session becomes orchestrator — counter starts from 0 ctx.client.session.prompt.mockClear(); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's2' }, }, }); await delay(60); // counter = 1 // One more cycle → counter = 2 (reaches max) ctx.client.session.prompt.mockClear(); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's2' }, }, }); await delay(60); // Third cycle blocked (counter = 2 >= max = 2) ctx.client.session.prompt.mockClear(); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's2' }, }, }); await delay(60); expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); }); describe('MAJOR-2: suppressUntil cleared on re-enable', () => { test('tool re-enable clears suppress window', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); // Fire abort → sets suppress window await hook.handleEvent({ event: { type: 'session.error', properties: { sessionID: 's1', error: { name: 'AbortError' }, }, }, }); // Re-enable within suppress window → clears suppressUntil await hook.tool.auto_continue.execute({ enabled: true }); // Fire idle → should NOT be suppressed await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); test('command re-enable clears suppress window', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); // Fire abort → sets suppress window await hook.handleEvent({ event: { type: 'session.error', properties: { sessionID: 's1', error: { name: 'AbortError' }, }, }, }); // Re-enable via command → clears suppressUntil const output = { parts: [] as Array<{ type: string; text?: string }>, }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 's1', arguments: 'on', }, output, ); // Fire idle → should NOT be suppressed await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); }); describe('error paths', () => { test('prompt failure in timer callback is handled gracefully', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); ctx.client.session.prompt = mock(async () => { throw new Error('API error'); }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); // Seed orchestrator session await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Error caught; isAutoInjecting should be cleared via finally. // Verify by checking a second idle still works. ctx.client.session.prompt = mock(async () => ({})); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); }); describe('edge cases', () => { test('session.idle with missing sessionID returns early', async () => { const ctx = createMockContext(); const hook = createTodoContinuationHook(ctx); await hook.tool.auto_continue.execute({ enabled: true }); // Fire idle without sessionID — should not throw await hook.handleEvent({ event: { type: 'session.idle', properties: {} }, }); expect(ctx.client.session.todo).not.toHaveBeenCalled(); }); test('session.deleted with properties.info.id path', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); // Set orchestrator via idle await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); expect(hasContinuation(ctx.client.session.prompt)).toBe(true); // Delete via info.id path (alternative shape from session store) await hook.handleEvent({ event: { type: 'session.deleted', properties: { info: { id: 's1' } }, }, }); // New session should become orchestrator ctx.client.session.prompt.mockClear(); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's2' }, }, }); await delay(60); expect(hasContinuation(ctx.client.session.prompt)).toBe(true); expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('s2'); }); test('cooldownMs = 0 fires on next tick', async () => { const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 0, maxContinuations: 5, }); await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(10); expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); }); describe('MAJOR-3: double-fire prevention', () => { test('rapid idle events during prompt delivery — single continuation', async () => { let promptResolve!: () => void; const ctx = createMockContext({ todoResult: { data: [ { id: '1', content: 't1', status: 'pending', priority: 'high', }, ], }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Work' }], }, ], }, }); ctx.client.session.prompt = mock(async () => { await new Promise((r) => { promptResolve = r; }); }); const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 }); await hook.tool.auto_continue.execute({ enabled: true }); // Fire idle → timer → prompt hangs (isAutoInjecting = true) await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Fire another idle while prompt is in flight await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); // Only one prompt call (blocked by isAutoInjecting gate) expect(contCount(ctx.client.session.prompt)).toBe(1); // Resolve prompt promptResolve(); await delay(10); // Now idle should schedule a new timer ctx.client.session.prompt = mock(async () => ({})); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); expect(contCount(ctx.client.session.prompt)).toBe(1); }); }); describe('MAJOR-4: command explicit on|off arguments', () => { test('command "on" keeps enabled state when already enabled', async () => { const ctx = createMockContext(); const hook = createTodoContinuationHook(ctx); // Enable via tool await hook.tool.auto_continue.execute({ enabled: true }); // /auto-continue on → should KEEP enabled (not toggle to off) const output = { parts: [] as Array<{ type: string; text?: string }>, }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 's1', arguments: 'on', }, output, ); expect(output.parts[0].text).not.toContain('disabled'); }); test('command "off" keeps disabled state when already disabled', async () => { const ctx = createMockContext(); const hook = createTodoContinuationHook(ctx); // Start disabled (default) const output = { parts: [] as Array<{ type: string; text?: string }>, }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 's1', arguments: 'off', }, output, ); expect(output.parts[0].text).toContain('disabled'); }); test('command with no argument toggles state', async () => { const ctx = createMockContext(); const hook = createTodoContinuationHook(ctx); // First toggle: disabled → enabled const output1 = { parts: [] as Array<{ type: string; text?: string }>, }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 's1', arguments: '', }, output1, ); expect(output1.parts[0].text).not.toContain('disabled'); // Second toggle: enabled → disabled const output2 = { parts: [] as Array<{ type: string; text?: string }>, }; await hook.handleCommandExecuteBefore( { command: 'auto-continue', sessionID: 's1', arguments: '', }, output2, ); expect(output2.parts[0].text).toContain('disabled'); }); }); }); describe('auto-enable on todo count', () => { function createAutoEnableCtx( todos: Array<{ id: string; content: string; status: string; priority: string; }>, ) { return createMockContext({ todoResult: { data: todos }, messagesResult: { data: [ { info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Working...' }], }, ], }, }); } test('autoEnable=true, todos >= threshold → auto-enables and continues', async () => { const ctx = createAutoEnableCtx([ { id: '1', content: 't1', status: 'pending', priority: 'high' }, { id: '2', content: 't2', status: 'pending', priority: 'high' }, { id: '3', content: 't3', status: 'pending', priority: 'high' }, { id: '4', content: 't4', status: 'pending', priority: 'high' }, ]); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, autoEnable: true, autoEnableThreshold: 4, }); // Do NOT manually enable — auto-enable should trigger await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Should have scheduled continuation (auto-enabled) expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); test('autoEnable=true, todos < threshold → does NOT auto-enable', async () => { const ctx = createAutoEnableCtx([ { id: '1', content: 't1', status: 'pending', priority: 'high' }, { id: '2', content: 't2', status: 'pending', priority: 'high' }, { id: '3', content: 't3', status: 'pending', priority: 'high' }, ]); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, autoEnable: true, autoEnableThreshold: 4, }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Should NOT auto-enable or continue expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('autoEnable=false (default) → never auto-enables regardless of todo count', async () => { const ctx = createAutoEnableCtx( Array.from({ length: 10 }, (_, i) => ({ id: String(i), content: `t${i}`, status: 'pending', priority: 'high', })), ); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, // autoEnable defaults to false }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('auto-enable does not re-enable if already manually enabled', async () => { const ctx = createAutoEnableCtx([ { id: '1', content: 't1', status: 'pending', priority: 'high' }, { id: '2', content: 't2', status: 'pending', priority: 'high' }, ]); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, autoEnable: true, autoEnableThreshold: 4, }); // Manually enable first await hook.tool.auto_continue.execute({ enabled: true }); // Only 2 todos (< threshold) — but already enabled, so should continue await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Continues because already manually enabled (auto-enable check skipped) expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); test('auto-enable respects custom threshold', async () => { const ctx = createAutoEnableCtx([ { id: '1', content: 't1', status: 'pending', priority: 'high' }, { id: '2', content: 't2', status: 'pending', priority: 'high' }, ]); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, autoEnable: true, autoEnableThreshold: 2, }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // 2 todos >= threshold 2 → auto-enables expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); test('auto-enable skipped for non-orchestrator session', async () => { const ctx = createAutoEnableCtx([ { id: '1', content: 't1', status: 'pending', priority: 'high' }, { id: '2', content: 't2', status: 'pending', priority: 'high' }, { id: '3', content: 't3', status: 'pending', priority: 'high' }, { id: '4', content: 't4', status: 'pending', priority: 'high' }, ]); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, autoEnable: true, autoEnableThreshold: 4, }); // First idle sets orchestrator to session-A await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-A' }, }, }); await delay(60); // Reset mock ctx.client.session.prompt.mockClear(); // Second idle from session-B — not orchestrator, should skip await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 'session-B' }, }, }); await delay(60); expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('auto-enable with todo fetch failure → no auto-enable, no crash', async () => { const ctx = createMockContext(); ctx.client.session.todo = mock(async () => { throw new Error('Network error'); }); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, autoEnable: true, autoEnableThreshold: 4, }); // Should not throw await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // No auto-enable, no continuation expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); test('auto-enable resets consecutive counter and suppress window', async () => { const ctx = createAutoEnableCtx([ { id: '1', content: 't1', status: 'pending', priority: 'high' }, { id: '2', content: 't2', status: 'pending', priority: 'high' }, { id: '3', content: 't3', status: 'pending', priority: 'high' }, { id: '4', content: 't4', status: 'pending', priority: 'high' }, ]); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, autoEnable: true, autoEnableThreshold: 4, }); // Manually enable, run a continuation, disable await hook.tool.auto_continue.execute({ enabled: true }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Fire abort to set suppress window await hook.handleEvent({ event: { type: 'session.error', properties: { sessionID: 's1', error: { name: 'AbortError' }, }, }, }); // Disable await hook.tool.auto_continue.execute({ enabled: false }); // Reset mock ctx.client.session.prompt.mockClear(); // Fire idle again — auto-enable should trigger (4 todos >= 4), // resetting counter and suppress window await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Should continue (suppressed window was cleared by auto-enable) expect(hasContinuation(ctx.client.session.prompt)).toBe(true); }); test('auto-enable counts incomplete todos only, not completed', async () => { const ctx = createAutoEnableCtx([ { id: '1', content: 't1', status: 'completed', priority: 'high' }, { id: '2', content: 't2', status: 'completed', priority: 'high' }, { id: '3', content: 't3', status: 'pending', priority: 'high' }, { id: '4', content: 't4', status: 'pending', priority: 'high' }, ]); const hook = createTodoContinuationHook(ctx, { maxContinuations: 5, cooldownMs: 50, autoEnable: true, autoEnableThreshold: 4, }); await hook.handleEvent({ event: { type: 'session.idle', properties: { sessionID: 's1' }, }, }); await delay(60); // Only 2 incomplete todos < threshold 4 → does NOT auto-enable expect(ctx.client.session.prompt).not.toHaveBeenCalled(); }); }); });