/** * Integration Tests for Hook System * * Tests the full hook flow from session start to end. * * @module @agentkits/memory/hooks/__tests__/integration */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync, rmSync, mkdirSync } from 'node:fs'; import * as path from 'node:path'; import { NormalizedHookInput, parseHookInput } from '../types.js'; import { MemoryHookService } from '../service.js'; import { createContextHook } from '../context.js'; import { createSessionInitHook } from '../session-init.js'; import { createObservationHook } from '../observation.js'; import { createSummarizeHook } from '../summarize.js'; const TEST_DIR = path.join(process.cwd(), '.test-integration-hooks'); // Track hooks for cleanup (needed for Windows file locking) let activeHooks: Array<{ shutdown: () => Promise }> = []; // Helper to track hooks for cleanup function trackHook Promise }>(hook: T): T { activeHooks.push(hook); return hook; } function createTestInput(overrides: Partial = {}): NormalizedHookInput { return { sessionId: 'integration-session', cwd: TEST_DIR, project: 'test-project', timestamp: Date.now(), ...overrides, }; } describe('Hook System Integration', () => { 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('Full Session Flow', () => { it('should complete a full session lifecycle', async () => { const sessionId = 'full-flow-session'; const project = 'test-project'; // 1. Session Start - Context Hook (no previous context) const contextHook = trackHook(createContextHook(TEST_DIR)); const contextResult = await contextHook.execute( createTestInput({ sessionId, project }) ); expect(contextResult.continue).toBe(true); // Empty state now injects guidance (save-first workflow) expect(contextResult.additionalContext).toBeDefined(); expect(contextResult.additionalContext).toContain('memory_save'); expect(contextResult.additionalContext).toContain('Do NOT call'); await contextHook.shutdown(); // 2. User Prompt Submit - Session Init Hook const sessionInitHook = trackHook(createSessionInitHook(TEST_DIR)); const sessionInitResult = await sessionInitHook.execute( createTestInput({ sessionId, project, prompt: 'Help me implement a feature' }) ); expect(sessionInitResult.continue).toBe(true); await sessionInitHook.shutdown(); // 3. Tool Uses - Observation Hooks const observationHook = trackHook(createObservationHook(TEST_DIR)); // Simulate reading files await observationHook.execute( createTestInput({ sessionId, project, toolName: 'Read', toolInput: { file_path: 'src/index.ts' }, toolResponse: { content: 'export function main() {}' }, }) ); // Simulate grep search await observationHook.execute( createTestInput({ sessionId, project, toolName: 'Grep', toolInput: { pattern: 'function', path: 'src' }, toolResponse: { matches: ['src/index.ts:1'] }, }) ); // Simulate writing file await observationHook.execute( createTestInput({ sessionId, project, toolName: 'Write', toolInput: { file_path: 'src/feature.ts' }, toolResponse: { success: true }, }) ); // Simulate running tests await observationHook.execute( createTestInput({ sessionId, project, toolName: 'Bash', toolInput: { command: 'npm test' }, toolResponse: { stdout: 'All tests passed' }, }) ); await observationHook.shutdown(); // 4. Session End - Summarize Hook const summarizeHook = trackHook(createSummarizeHook(TEST_DIR)); const summarizeResult = await summarizeHook.execute( createTestInput({ sessionId, project, stopReason: 'user_exit' }) ); expect(summarizeResult.continue).toBe(true); // Verify final state const service = new MemoryHookService(TEST_DIR); await service.initialize(); const session = service.getSession(sessionId); expect(session).not.toBeNull(); expect(session?.status).toBe('completed'); expect(session?.observationCount).toBe(4); expect(session?.summary).toBeDefined(); expect(session?.summary).toContain('file(s) modified'); expect(session?.summary).toContain('file(s) read'); expect(session?.summary).toContain('command(s) executed'); const observations = await service.getSessionObservations(sessionId); expect(observations.length).toBe(4); await service.shutdown(); }); it('should provide context from previous sessions', async () => { // Session 1: Complete a full session const session1Id = 'previous-session'; const project = 'test-project'; // Init session 1 const initHook1 = trackHook(createSessionInitHook(TEST_DIR)); await initHook1.execute(createTestInput({ sessionId: session1Id, project, prompt: 'First task' })); await initHook1.shutdown(); // Add observations to session 1 const obsHook1 = trackHook(createObservationHook(TEST_DIR)); await obsHook1.execute(createTestInput({ sessionId: session1Id, project, toolName: 'Write', toolInput: { file_path: 'src/auth.ts' }, toolResponse: {}, })); await obsHook1.shutdown(); // Complete session 1 const sumHook1 = trackHook(createSummarizeHook(TEST_DIR)); await sumHook1.execute(createTestInput({ sessionId: session1Id, project })); // Session 2: Should see context from session 1 const session2Id = 'current-session'; const contextHook2 = trackHook(createContextHook(TEST_DIR)); const contextResult = await contextHook2.execute( createTestInput({ sessionId: session2Id, project }) ); expect(contextResult.continue).toBe(true); expect(contextResult.suppressOutput).toBe(false); expect(contextResult.additionalContext).toBeDefined(); expect(contextResult.additionalContext).toContain('Previous Session Summaries'); expect(contextResult.additionalContext).toContain('Recent Activity'); expect(contextResult.additionalContext).toContain('auth.ts'); }); it('should handle multiple projects independently', async () => { // Session for project A const initHookA = trackHook(createSessionInitHook(TEST_DIR)); await initHookA.execute(createTestInput({ sessionId: 'session-a', project: 'project-a', prompt: 'Task for A', })); await initHookA.shutdown(); const obsHookA = trackHook(createObservationHook(TEST_DIR)); await obsHookA.execute(createTestInput({ sessionId: 'session-a', project: 'project-a', toolName: 'Write', toolInput: { file_path: 'a.ts' }, toolResponse: {}, })); await obsHookA.shutdown(); // Session for project B const initHookB = trackHook(createSessionInitHook(TEST_DIR)); await initHookB.execute(createTestInput({ sessionId: 'session-b', project: 'project-b', prompt: 'Task for B', })); await initHookB.shutdown(); const obsHookB = trackHook(createObservationHook(TEST_DIR)); await obsHookB.execute(createTestInput({ sessionId: 'session-b', project: 'project-b', toolName: 'Read', toolInput: { file_path: 'b.ts' }, toolResponse: {}, })); await obsHookB.shutdown(); // Verify isolation const service = new MemoryHookService(TEST_DIR); await service.initialize(); const sessionsA = await service.getRecentSessions('project-a', 10); const sessionsB = await service.getRecentSessions('project-b', 10); expect(sessionsA.length).toBe(1); expect(sessionsB.length).toBe(1); expect(sessionsA[0].prompt).toBe('Task for A'); expect(sessionsB[0].prompt).toBe('Task for B'); const obsA = await service.getRecentObservations('project-a', 10); const obsB = await service.getRecentObservations('project-b', 10); expect(obsA.length).toBe(1); expect(obsB.length).toBe(1); expect(obsA[0].toolName).toBe('Write'); expect(obsB[0].toolName).toBe('Read'); await service.shutdown(); }); }); describe('CLI Input Parsing Integration', () => { it('should parse and process real Claude Code input', async () => { // Simulate real Claude Code hook input const claudeInput = JSON.stringify({ session_id: 'abc123', cwd: TEST_DIR, prompt: 'Help me fix the bug', tool_name: 'Read', tool_input: { file_path: '/path/to/file.ts' }, tool_result: { content: 'file contents here' }, }); const parsed = parseHookInput(claudeInput); expect(parsed.sessionId).toBe('abc123'); expect(parsed.cwd).toBe(TEST_DIR); expect(parsed.prompt).toBe('Help me fix the bug'); expect(parsed.toolName).toBe('Read'); expect(parsed.toolInput).toEqual({ file_path: '/path/to/file.ts' }); expect(parsed.toolResponse).toEqual({ content: 'file contents here' }); // Process through observation hook const observationHook = trackHook(createObservationHook(TEST_DIR)); const result = await observationHook.execute(parsed); expect(result.continue).toBe(true); await observationHook.shutdown(); // Verify stored const service = new MemoryHookService(TEST_DIR); await service.initialize(); const obs = await service.getSessionObservations('abc123'); await service.shutdown(); expect(obs.length).toBe(1); expect(obs[0].title).toBe('Read /path/to/file.ts'); }); }); describe('Error Recovery', () => { it('should continue working after errors', async () => { const sessionId = 'error-recovery-session'; const project = 'test-project'; // Init session const initHook = trackHook(createSessionInitHook(TEST_DIR)); await initHook.execute(createTestInput({ sessionId, project })); await initHook.shutdown(); // Successful observation const obsHook = trackHook(createObservationHook(TEST_DIR)); await obsHook.execute(createTestInput({ sessionId, project, toolName: 'Read', toolInput: { file_path: '/test/file.ts' }, toolResponse: { content: 'test content' }, })); // Another successful observation await obsHook.execute(createTestInput({ sessionId, project, toolName: 'Write', toolInput: { file_path: '/test/output.ts' }, toolResponse: { success: true }, })); await obsHook.shutdown(); // Verify both observations stored const service = new MemoryHookService(TEST_DIR); await service.initialize(); const obs = await service.getSessionObservations(sessionId); await service.shutdown(); expect(obs.length).toBe(2); }); }); describe('Multiple Sessions', () => { it('should handle multiple sessions sequentially', async () => { const project = 'test-project'; // Start two sessions sequentially (SQLite doesn't handle concurrent writes well) const initHook1 = trackHook(createSessionInitHook(TEST_DIR)); await initHook1.execute(createTestInput({ sessionId: 'multi-1', project })); await initHook1.shutdown(); const initHook2 = trackHook(createSessionInitHook(TEST_DIR)); await initHook2.execute(createTestInput({ sessionId: 'multi-2', project })); await initHook2.shutdown(); // Add observations sequentially const obsHook1 = trackHook(createObservationHook(TEST_DIR)); await obsHook1.execute(createTestInput({ sessionId: 'multi-1', project, toolName: 'Read', toolInput: { file_path: '/test/file1.ts' }, toolResponse: { content: 'content1' }, })); await obsHook1.shutdown(); const obsHook2 = trackHook(createObservationHook(TEST_DIR)); await obsHook2.execute(createTestInput({ sessionId: 'multi-2', project, toolName: 'Write', toolInput: { file_path: '/test/file2.ts' }, toolResponse: { success: true }, })); await obsHook2.shutdown(); // Verify both sessions have their observations const service = new MemoryHookService(TEST_DIR); await service.initialize(); const obs1 = await service.getSessionObservations('multi-1'); const obs2 = await service.getSessionObservations('multi-2'); expect(obs1.length).toBe(1); expect(obs2.length).toBe(1); expect(obs1[0].toolName).toBe('Read'); expect(obs2[0].toolName).toBe('Write'); await service.shutdown(); }); }); describe('Large Data Handling', () => { it('should handle many observations efficiently', async () => { const sessionId = 'large-data-session'; const project = 'test-project'; // Init session const initHook = trackHook(createSessionInitHook(TEST_DIR)); await initHook.execute(createTestInput({ sessionId, project })); await initHook.shutdown(); // Add many observations const obsHook = trackHook(createObservationHook(TEST_DIR)); const observationCount = 50; for (let i = 0; i < observationCount; i++) { await obsHook.execute(createTestInput({ sessionId, project, toolName: i % 2 === 0 ? 'Read' : 'Write', toolInput: { file_path: `file${i}.ts` }, toolResponse: { content: `content ${i}` }, })); } await obsHook.shutdown(); // Verify all observations stored const service = new MemoryHookService(TEST_DIR); await service.initialize(); const session = service.getSession(sessionId); expect(session?.observationCount).toBe(observationCount); const obs = await service.getSessionObservations(sessionId); expect(obs.length).toBe(observationCount); await service.shutdown(); }); it('should truncate large tool responses', async () => { const sessionId = 'large-response-session'; const project = 'test-project'; // Init session const initHook = trackHook(createSessionInitHook(TEST_DIR)); await initHook.execute(createTestInput({ sessionId, project })); await initHook.shutdown(); // Add observation with large response const obsHook = trackHook(createObservationHook(TEST_DIR)); const largeContent = 'A'.repeat(100000); // 100KB await obsHook.execute(createTestInput({ sessionId, project, toolName: 'Read', toolInput: { file_path: 'large.ts' }, toolResponse: { content: largeContent }, })); await obsHook.shutdown(); // Verify response was truncated const service = new MemoryHookService(TEST_DIR); await service.initialize(); const obs = await service.getSessionObservations(sessionId); expect(obs.length).toBe(1); expect(obs[0].toolResponse.length).toBeLessThan(100000); expect(obs[0].toolResponse).toContain('[truncated]'); await service.shutdown(); }); }); });