/** * Unit Tests for Hook Handlers * * @module @agentkits/memory/hooks/__tests__/handlers */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync, rmSync, mkdirSync } from 'node:fs'; import * as path from 'node:path'; import { NormalizedHookInput } from '../types.js'; import { MemoryHookService } from '../service.js'; import { ContextHook, createContextHook } from '../context.js'; import { SessionInitHook, createSessionInitHook } from '../session-init.js'; import { ObservationHook, createObservationHook } from '../observation.js'; import { SummarizeHook, createSummarizeHook } from '../summarize.js'; import { UserMessageHook, createUserMessageHook } from '../user-message.js'; const TEST_DIR = path.join(process.cwd(), '.test-hook-handlers'); // Track hooks for cleanup let activeHooks: Array<{ shutdown: () => Promise }> = []; function createTestInput(overrides: Partial = {}): NormalizedHookInput { return { sessionId: 'test-session-123', cwd: TEST_DIR, project: 'test-project', timestamp: Date.now(), ...overrides, }; } // Helper to track hooks for cleanup function trackHook Promise }>(hook: T): T { activeHooks.push(hook); return hook; } describe('Hook Handlers', () => { beforeEach(() => { activeHooks = []; // Clean up test directory if (existsSync(TEST_DIR)) { try { rmSync(TEST_DIR, { recursive: true }); } catch { // Ignore errors on Windows } } mkdirSync(TEST_DIR, { recursive: true }); }); afterEach(async () => { // Shutdown all hooks first (releases database locks) for (const hook of activeHooks) { try { await hook.shutdown(); } catch { // Ignore shutdown errors } } activeHooks = []; // Small delay for Windows file system await new Promise((r) => setTimeout(r, 100)); // Clean up test directory if (existsSync(TEST_DIR)) { try { rmSync(TEST_DIR, { recursive: true }); } catch { // Ignore errors on Windows - files may still be locked } } }); describe('ContextHook', () => { it('should return empty-state guidance for new project', async () => { const hook = trackHook(createContextHook(TEST_DIR)); const input = createTestInput(); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(false); // Should inject guidance even on empty state expect(result.additionalContext).toBeDefined(); expect(result.additionalContext).toContain('Memory tools available'); expect(result.additionalContext).toContain('memory_save'); expect(result.additionalContext).toContain('Do NOT call'); }); it('should return context with prompts and summaries', async () => { // Set up session with prompts, observations, and structured summary const service = new MemoryHookService(TEST_DIR); await service.initSession('old-session', 'test-project', 'Implement auth'); await service.saveUserPrompt('old-session', 'test-project', 'Implement auth'); await service.saveUserPrompt('old-session', 'test-project', 'Add tests'); await service.storeObservation('old-session', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); await service.storeObservation('old-session', 'test-project', 'Write', { file_path: 'auth.ts' }, {}, TEST_DIR); // Save structured summary await service.saveSessionSummary({ sessionId: 'old-session', project: 'test-project', request: '[#1] Implement auth → [#2] Add tests', completed: '2 file(s) modified', filesRead: ['file.ts'], filesModified: ['auth.ts'], nextSteps: 'Deploy to staging', notes: '', promptNumber: 2, }); await service.completeSession('old-session', 'Done'); await service.shutdown(); // Run context hook const hook = trackHook(createContextHook(TEST_DIR)); const input = createTestInput({ sessionId: 'new-session' }); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(false); expect(result.additionalContext).toBeDefined(); expect(result.additionalContext).toContain('# Memory Context'); expect(result.additionalContext).toContain('Previous Session Summaries'); expect(result.additionalContext).toContain('Implement auth'); expect(result.additionalContext).toContain('Recent User Prompts'); expect(result.additionalContext).toContain('Add tests'); expect(result.additionalContext).toContain('auth.ts'); // Should include tool-usage instructions expect(result.additionalContext).toContain('Memory tools available'); expect(result.additionalContext).toContain('memory_search'); }); it('should handle errors gracefully', async () => { // Create hook with invalid directory const hook = new ContextHook({ initialize: async () => { throw new Error('Test error'); }, } as unknown as MemoryHookService); const input = createTestInput(); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); expect(typeof result.error).toBe('string'); expect(result.error!.length).toBeGreaterThan(0); }); }); describe('SessionInitHook', () => { it('should initialize a new session', async () => { const hook = trackHook(createSessionInitHook(TEST_DIR)); const input = createTestInput({ prompt: 'Hello Claude' }); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); // Shutdown hook before verifying await hook.shutdown(); // Verify session was created const service = new MemoryHookService(TEST_DIR); await service.initialize(); const session = service.getSession('test-session-123'); await service.shutdown(); expect(session).not.toBeNull(); expect(session?.prompt).toBe('Hello Claude'); }); it('should save all user prompts (not just first)', async () => { // First prompt const hook1 = trackHook(createSessionInitHook(TEST_DIR)); await hook1.execute(createTestInput({ prompt: 'First prompt' })); await hook1.shutdown(); // Second prompt (same session) const hook2 = trackHook(createSessionInitHook(TEST_DIR)); await hook2.execute(createTestInput({ prompt: 'Second prompt' })); await hook2.shutdown(); // Third prompt const hook3 = trackHook(createSessionInitHook(TEST_DIR)); await hook3.execute(createTestInput({ prompt: 'Third prompt' })); await hook3.shutdown(); // Verify session prompt still has first prompt const service = new MemoryHookService(TEST_DIR); await service.initialize(); const session = service.getSession('test-session-123'); expect(session?.prompt).toBe('First prompt'); // Verify ALL prompts are saved in user_prompts table const prompts = await service.getSessionPrompts('test-session-123'); await service.shutdown(); expect(prompts.length).toBe(3); expect(prompts[0].promptNumber).toBe(1); expect(prompts[0].promptText).toBe('First prompt'); expect(prompts[1].promptNumber).toBe(2); expect(prompts[1].promptText).toBe('Second prompt'); expect(prompts[2].promptNumber).toBe(3); expect(prompts[2].promptText).toBe('Third prompt'); }); it('should not save prompt when prompt is empty', async () => { const hook = trackHook(createSessionInitHook(TEST_DIR)); await hook.execute(createTestInput({ prompt: undefined })); await hook.shutdown(); const service = new MemoryHookService(TEST_DIR); await service.initialize(); const prompts = await service.getSessionPrompts('test-session-123'); await service.shutdown(); expect(prompts.length).toBe(0); }); it('should handle errors gracefully', async () => { const hook = new SessionInitHook({ initialize: async () => { throw new Error('Test error'); }, } as unknown as MemoryHookService); const input = createTestInput(); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); expect(typeof result.error).toBe('string'); expect(result.error!.length).toBeGreaterThan(0); }); }); describe('ObservationHook', () => { it('should store observation with prompt number', async () => { // Initialize session and save a prompt const initHook = trackHook(createSessionInitHook(TEST_DIR)); await initHook.execute(createTestInput({ prompt: 'Fix the bug' })); await initHook.shutdown(); // Store observation const hook = trackHook(createObservationHook(TEST_DIR)); const input = createTestInput({ toolName: 'Read', toolInput: { file_path: '/path/to/file.ts' }, toolResponse: { content: 'file contents' }, }); const result = await hook.execute(input); await hook.shutdown(); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); // Verify observation was stored with prompt number const service = new MemoryHookService(TEST_DIR); await service.initialize(); const observations = await service.getSessionObservations('test-session-123'); await service.shutdown(); expect(observations.length).toBe(1); expect(observations[0].toolName).toBe('Read'); expect(observations[0].promptNumber).toBe(1); }); it('should skip if no tool name', async () => { const hook = trackHook(createObservationHook(TEST_DIR)); const input = createTestInput({ toolName: undefined }); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); }); it('should skip internal tools', async () => { // Initialize session first const initHook = trackHook(createSessionInitHook(TEST_DIR)); await initHook.execute(createTestInput()); await initHook.shutdown(); const hook = trackHook(createObservationHook(TEST_DIR)); // Test skipped tools for (const tool of ['TodoWrite', 'TodoRead', 'AskFollowupQuestion', 'AttemptCompletion']) { const input = createTestInput({ toolName: tool }); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); } await hook.shutdown(); // Verify no observations stored const service = new MemoryHookService(TEST_DIR); await service.initialize(); const observations = await service.getSessionObservations('test-session-123'); await service.shutdown(); expect(observations.length).toBe(0); }); it('should create session if not exists', async () => { const hook = trackHook(createObservationHook(TEST_DIR)); const input = createTestInput({ sessionId: 'new-session', toolName: 'Read', toolInput: { file_path: '/test/file.ts' }, toolResponse: { content: 'test' }, }); const result = await hook.execute(input); await hook.shutdown(); expect(result.continue).toBe(true); // Verify session was created const service = new MemoryHookService(TEST_DIR); await service.initialize(); const session = service.getSession('new-session'); await service.shutdown(); expect(session).not.toBeNull(); }); it('should return fast with template data (no AI blocking)', async () => { // Initialize session const initHook = trackHook(createSessionInitHook(TEST_DIR)); await initHook.execute(createTestInput({ prompt: 'Test task' })); await initHook.shutdown(); const hook = trackHook(createObservationHook(TEST_DIR)); const input = createTestInput({ toolName: 'Read', toolInput: { file_path: '/path/to/auth.ts' }, toolResponse: { content: 'export class Auth {}' }, }); // Measure execution time — should be fast (<500ms) since AI is fire-and-forget const start = Date.now(); const result = await hook.execute(input); const elapsed = Date.now() - start; await hook.shutdown(); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); // Should complete quickly (template-only, no AI blocking) expect(elapsed).toBeLessThan(2000); // Verify template data was stored immediately const service = new MemoryHookService(TEST_DIR); await service.initialize(); const observations = await service.getSessionObservations('test-session-123'); await service.shutdown(); expect(observations.length).toBe(1); expect(typeof observations[0].subtitle).toBe('string'); expect(observations[0].subtitle.length).toBeGreaterThan(0); expect(typeof observations[0].narrative).toBe('string'); expect(observations[0].narrative.length).toBeGreaterThan(0); }); it('should not spawn enrichment when AGENTKITS_AI_ENRICHMENT=false', async () => { const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; process.env.AGENTKITS_AI_ENRICHMENT = 'false'; try { const initHook = trackHook(createSessionInitHook(TEST_DIR)); await initHook.execute(createTestInput({ prompt: 'Test' })); await initHook.shutdown(); const hook = trackHook(createObservationHook(TEST_DIR)); const input = createTestInput({ toolName: 'Write', toolInput: { file_path: 'test.ts' }, toolResponse: {}, }); const result = await hook.execute(input); await hook.shutdown(); // Should still store observation successfully expect(result.continue).toBe(true); const service = new MemoryHookService(TEST_DIR); await service.initialize(); const observations = await service.getSessionObservations('test-session-123'); await service.shutdown(); expect(observations.length).toBe(1); expect(observations[0].toolName).toBe('Write'); } finally { if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } } }); it('should skip empty/no-op tool calls (both input and response are {})', async () => { const hook = trackHook(createObservationHook(TEST_DIR)); const input = createTestInput({ toolName: 'Read', toolInput: {}, toolResponse: {}, }); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); // Should NOT initialize service or store anything // Verify by checking no observations exist const service = new MemoryHookService(TEST_DIR); await service.initialize(); const observations = await service.getSessionObservations('test-session-123'); await service.shutdown(); expect(observations.length).toBe(0); }); it('should skip when toolInput is undefined and toolResponse is undefined', async () => { const hook = trackHook(createObservationHook(TEST_DIR)); const input = createTestInput({ toolName: 'Bash', toolInput: undefined, toolResponse: undefined, }); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); }); it('should handle errors gracefully', async () => { const hook = new ObservationHook({ initialize: async () => { throw new Error('Test error'); }, } as unknown as MemoryHookService); const input = createTestInput({ toolName: 'Read', toolInput: { file_path: '/test/file.ts' }, toolResponse: { content: 'test' }, }); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); expect(typeof result.error).toBe('string'); expect(result.error!.length).toBeGreaterThan(0); }); }); describe('SummarizeHook', () => { it('should complete session with structured summary', async () => { // Set up session with prompts and observations const service = new MemoryHookService(TEST_DIR); await service.initSession('test-session-123', 'test-project', 'Fix authentication bug'); await service.saveUserPrompt('test-session-123', 'test-project', 'Fix authentication bug'); await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'auth.ts' }, {}, TEST_DIR); await service.storeObservation('test-session-123', 'test-project', 'Write', { file_path: 'auth.ts' }, {}, TEST_DIR); await service.storeObservation('test-session-123', 'test-project', 'Bash', { command: 'npm test' }, {}, TEST_DIR); await service.shutdown(); // Run summarize hook const hook = trackHook(createSummarizeHook(TEST_DIR)); const input = createTestInput(); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); // Verify session was completed with text summary const service2 = new MemoryHookService(TEST_DIR); await service2.initialize(); const session = service2.getSession('test-session-123'); expect(session?.status).toBe('completed'); expect(typeof session?.summary).toBe('string'); expect(session?.summary).toContain('Request:'); expect(session?.summary).toContain('Fix authentication bug'); // Verify structured summary was saved const summaries = await service2.getRecentSummaries('test-project'); await service2.shutdown(); expect(summaries.length).toBe(1); expect(summaries[0].request).toContain('Fix authentication bug'); expect(summaries[0].filesRead).toContain('auth.ts'); expect(summaries[0].filesModified).toContain('auth.ts'); expect(summaries[0].completed).toContain('file(s) modified'); expect(summaries[0].notes).toContain('npm test'); }); it('should handle non-existent session', async () => { const hook = trackHook(createSummarizeHook(TEST_DIR)); const input = createTestInput({ sessionId: 'non-existent' }); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); }); it('should spawn enrich worker when AI enrichment is enabled', async () => { const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; process.env.AGENTKITS_AI_ENRICHMENT = 'true'; try { // Set up session with observations const service = new MemoryHookService(TEST_DIR); await service.initSession('test-session-123', 'test-project', 'Test task'); await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); await service.shutdown(); // Run summarize hook const hook = trackHook(createSummarizeHook(TEST_DIR)); const input = createTestInput(); const result = await hook.execute(input); // Hook should still succeed (worker spawn is fire-and-forget) expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); } finally { if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } } }); it('should spawn enrich-summary process when AI enrichment enabled and transcriptPath provided', async () => { const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; process.env.AGENTKITS_AI_ENRICHMENT = 'true'; try { // Set up session const service = new MemoryHookService(TEST_DIR); await service.initSession('test-session-123', 'test-project', 'Test task'); await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); await service.shutdown(); // Run summarize hook with transcriptPath const hook = trackHook(createSummarizeHook(TEST_DIR)); const input = createTestInput({ transcriptPath: '/tmp/test-transcript.jsonl', }); const result = await hook.execute(input); // Hook should still succeed (spawn is fire-and-forget, spawn failure is caught) expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); } finally { if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } } }); it('should handle errors gracefully', async () => { const hook = new SummarizeHook({ initialize: async () => { throw new Error('Test error'); }, shutdown: async () => {}, } as unknown as MemoryHookService); const input = createTestInput(); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); expect(typeof result.error).toBe('string'); expect(result.error!.length).toBeGreaterThan(0); }); }); describe('UserMessageHook', () => { it('should display status for new project (no context)', async () => { const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const hook = trackHook(createUserMessageHook(TEST_DIR)); const input = createTestInput(); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); // Should write to stderr expect(stderrSpy).toHaveBeenCalled(); const output = stderrSpy.mock.calls.map(c => c[0]).join('\n'); expect(output).toContain('AgentKits Memory Loaded'); expect(output).toContain('Fresh memory'); expect(output).toContain('memory_save'); stderrSpy.mockRestore(); }); it('should display stats when context exists', async () => { // Set up session with observations and prompts const service = new MemoryHookService(TEST_DIR); await service.initSession('old-session', 'test-project', 'Test task'); await service.saveUserPrompt('old-session', 'test-project', 'Test task'); await service.storeObservation('old-session', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); await service.completeSession('old-session', 'Done'); await service.shutdown(); const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const hook = trackHook(createUserMessageHook(TEST_DIR)); const input = createTestInput({ sessionId: 'new-session' }); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); expect(stderrSpy).toHaveBeenCalled(); const output = stderrSpy.mock.calls.map(c => c[0]).join('\n'); expect(output).toContain('AgentKits Memory Loaded'); expect(output).toContain('observation'); expect(output).toContain('memory_search'); stderrSpy.mockRestore(); }); it('should handle errors gracefully', async () => { const hook = new UserMessageHook({ initialize: async () => { throw new Error('Test error'); }, } as unknown as MemoryHookService); const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const input = createTestInput(); const result = await hook.execute(input); expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); expect(typeof result.error).toBe('string'); expect(result.error!.length).toBeGreaterThan(0); stderrSpy.mockRestore(); }); }); });