/**
* 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<void> }> = [];
// Helper to track hooks for cleanup
function trackHook<T extends { shutdown: () => Promise<void> }>(hook: T): T {
activeHooks.push(hook);
return hook;
}
function createTestInput(overrides: Partial<NormalizedHookInput> = {}): 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();
});
});
});