/** * Unit Tests for MemoryHookService * * @module @agentkits/memory/hooks/__tests__/service */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import * as path from 'node:path'; import { MemoryHookService, createHookService } from '../service.js'; import { _setRunClaudePrintMockForTesting, resetAIEnrichmentCache } from '../ai-enrichment.js'; import { computeContentHash } from '../types.js'; const TEST_DIR = path.join(process.cwd(), '.test-memory-hooks'); describe('MemoryHookService', () => { let service: MemoryHookService; beforeEach(() => { // Clean up test directory if (existsSync(TEST_DIR)) { rmSync(TEST_DIR, { recursive: true }); } mkdirSync(TEST_DIR, { recursive: true }); service = new MemoryHookService(TEST_DIR); }); afterEach(async () => { // Shutdown service try { await service.shutdown(); } catch { // Ignore shutdown errors } // Clean up test directory if (existsSync(TEST_DIR)) { rmSync(TEST_DIR, { recursive: true }); } }); describe('initialization', () => { it('should initialize successfully', async () => { await service.initialize(); // Database file is created after persist() call // Initialize just creates the in-memory database // Let's verify by adding some data and persisting await service.initSession('test', 'test-project'); const dbPath = path.join(TEST_DIR, '.claude/memory', 'memory.db'); expect(existsSync(dbPath)).toBe(true); }); it('should be idempotent', async () => { await service.initialize(); await service.initialize(); // Should not throw }); it('should create memory directory if not exists', async () => { const memDir = path.join(TEST_DIR, '.claude/memory'); expect(existsSync(memDir)).toBe(false); await service.initialize(); expect(existsSync(memDir)).toBe(true); }); }); describe('session management', () => { it('should initialize a new session', async () => { const session = await service.initSession('session-1', 'test-project', 'Hello Claude'); expect(session.sessionId).toBe('session-1'); expect(session.project).toBe('test-project'); expect(session.prompt).toBe('Hello Claude'); expect(session.status).toBe('active'); expect(session.observationCount).toBe(0); }); it('should return existing session on re-init', async () => { const session1 = await service.initSession('session-1', 'test-project', 'First prompt'); const session2 = await service.initSession('session-1', 'test-project', 'Second prompt'); expect(session1.sessionId).toBe(session2.sessionId); expect(session1.prompt).toBe('First prompt'); // Original prompt preserved }); it('should get session by ID', async () => { await service.initSession('session-1', 'test-project', 'Test prompt'); const session = service.getSession('session-1'); expect(session).not.toBeNull(); expect(session?.sessionId).toBe('session-1'); expect(session?.project).toBe('test-project'); }); it('should return null for non-existent session', async () => { await service.initialize(); const session = service.getSession('non-existent'); expect(session).toBeNull(); }); it('should complete a session with summary', async () => { await service.initSession('session-1', 'test-project'); await service.completeSession('session-1', 'Task completed successfully'); const session = service.getSession('session-1'); expect(session?.status).toBe('completed'); expect(session?.summary).toBe('Task completed successfully'); expect(session?.endedAt).toBeGreaterThan(0); }); it('should get recent sessions', async () => { await service.initSession('session-1', 'test-project', 'First'); // Small delay to ensure different timestamps await new Promise(resolve => setTimeout(resolve, 10)); await service.initSession('session-2', 'test-project', 'Second'); await service.initSession('session-3', 'other-project', 'Third'); const sessions = await service.getRecentSessions('test-project', 10); expect(sessions.length).toBe(2); expect(sessions[0].sessionId).toBe('session-2'); // Most recent first expect(sessions[1].sessionId).toBe('session-1'); }); it('should limit recent sessions', async () => { await service.initSession('session-1', 'test-project'); await service.initSession('session-2', 'test-project'); await service.initSession('session-3', 'test-project'); const sessions = await service.getRecentSessions('test-project', 2); expect(sessions.length).toBe(2); }); }); describe('observation management', () => { it('should store an observation', async () => { await service.initSession('session-1', 'test-project'); const observation = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: '/path/to/file.ts' }, { content: 'file contents' }, TEST_DIR ); expect(observation.id).toMatch(/^obs_/); expect(observation.sessionId).toBe('session-1'); expect(observation.project).toBe('test-project'); expect(observation.toolName).toBe('Read'); expect(observation.type).toBe('read'); expect(observation.title).toBe('Read /path/to/file.ts'); }); it('should increment session observation count', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); await service.storeObservation('session-1', 'test-project', 'Write', {}, {}, TEST_DIR); const session = service.getSession('session-1'); expect(session?.observationCount).toBe(2); }); it('should get session observations', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); // Small delay to ensure different timestamps await new Promise(resolve => setTimeout(resolve, 10)); await service.storeObservation('session-1', 'test-project', 'Write', { file_path: 'b.ts' }, {}, TEST_DIR); const observations = await service.getSessionObservations('session-1'); expect(observations.length).toBe(2); // Most recent first expect(observations[0].toolName).toBe('Write'); expect(observations[1].toolName).toBe('Read'); }); it('should get recent observations for project', async () => { await service.initSession('session-1', 'test-project'); await service.initSession('session-2', 'test-project'); await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); await service.storeObservation('session-2', 'test-project', 'Write', {}, {}, TEST_DIR); const observations = await service.getRecentObservations('test-project', 10); expect(observations.length).toBe(2); }); it('should truncate large responses', async () => { await service.initSession('session-1', 'test-project'); const largeResponse = { content: 'A'.repeat(10000) }; const observation = await service.storeObservation( 'session-1', 'test-project', 'Read', {}, largeResponse, TEST_DIR ); expect(observation.toolResponse.length).toBeLessThan(10000); expect(observation.toolResponse).toContain('[truncated]'); }); it('should handle null/undefined tool input and response', async () => { await service.initSession('session-1', 'test-project'); // Pass null values - should use empty object fallback const observation = await service.storeObservation( 'session-1', 'test-project', 'Read', null, undefined, TEST_DIR ); expect(observation.toolInput).toBe('{}'); expect(observation.toolResponse).toBe('{}'); }); }); describe('context generation', () => { it('should get context for project', async () => { await service.initSession('session-1', 'test-project', 'First task'); await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); await service.completeSession('session-1', 'Completed first task'); const context = await service.getContext('test-project'); expect(context.recentObservations.length).toBe(1); expect(context.previousSessions.length).toBe(1); expect(context.markdown).toContain('# Memory Context'); expect(context.markdown).toContain('test-project'); }); it('should include tool-usage instructions in context header', async () => { await service.initSession('session-1', 'test-project', 'Test'); await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); const context = await service.getContext('test-project'); expect(context.markdown).toContain('Memory tools available'); expect(context.markdown).toContain('memory_search'); expect(context.markdown).toContain('memory_timeline'); expect(context.markdown).toContain('memory_details'); expect(context.markdown).toContain('memory_save'); expect(context.markdown).toContain('memory_recall'); expect(context.markdown).toContain('memory_delete'); expect(context.markdown).toContain('memory_update'); }); it('should include observation IDs in context', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); const context = await service.getContext('test-project'); const obs = context.recentObservations[0]; // Observation ID should appear in markdown (format: [obs_xxxx_yyyy]) expect(context.markdown).toContain(`[${obs.id}]`); }); it('should include token economics footer when context exists', async () => { await service.initSession('session-1', 'test-project', 'Test'); await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); await service.completeSession('session-1', 'Done'); const context = await service.getContext('test-project'); expect(context.markdown).toContain('tokens shown'); expect(context.markdown).toContain('tokens available'); expect(context.markdown).toContain('memory_search'); expect(context.markdown).toContain('memory_details'); }); it('should include all observation type icons in context', async () => { await service.initSession('session-1', 'test-project'); // Store observations of different types to test icon coverage await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); // read icon await service.storeObservation('session-1', 'test-project', 'Write', {}, {}, TEST_DIR); // write icon await service.storeObservation('session-1', 'test-project', 'Bash', {}, {}, TEST_DIR); // execute icon await service.storeObservation('session-1', 'test-project', 'WebSearch', {}, {}, TEST_DIR); // search icon await service.storeObservation('session-1', 'test-project', 'Unknown', {}, {}, TEST_DIR); // default icon const context = await service.getContext('test-project'); // Verify icons are in the markdown expect(context.markdown).toContain('📖'); // read expect(context.markdown).toContain('✏️'); // write expect(context.markdown).toContain('⚡'); // execute expect(context.markdown).toContain('🔍'); // search expect(context.markdown).toContain('•'); // default/other }); it('should return empty context for new project', async () => { await service.initialize(); const context = await service.getContext('new-project'); expect(context.recentObservations.length).toBe(0); expect(context.previousSessions.length).toBe(0); expect(context.markdown).toContain('No previous session context'); }); it('should format relative times correctly in context', async () => { const baseTime = Date.now(); // Create session with observations at different times await service.initSession('session-time', 'test-project'); // Store an observation await service.storeObservation('session-time', 'test-project', 'Read', {}, {}, TEST_DIR); // Mock Date.now to simulate time passing const originalDateNow = Date.now; // Test "just now" (less than 1 minute) vi.spyOn(Date, 'now').mockReturnValue(baseTime + 30000); // 30 seconds later let context = await service.getContext('test-project'); expect(context.markdown).toContain('just now'); // Test "Xm ago" (minutes) vi.spyOn(Date, 'now').mockReturnValue(baseTime + 5 * 60000); // 5 minutes later context = await service.getContext('test-project'); expect(context.markdown).toMatch(/\dm ago/); // Test "Xh ago" (hours) vi.spyOn(Date, 'now').mockReturnValue(baseTime + 3 * 3600000); // 3 hours later context = await service.getContext('test-project'); expect(context.markdown).toMatch(/\dh ago/); // Test "Xd ago" (days) vi.spyOn(Date, 'now').mockReturnValue(baseTime + 3 * 86400000); // 3 days later context = await service.getContext('test-project'); expect(context.markdown).toMatch(/\dd ago/); // Test date format (more than 7 days) vi.spyOn(Date, 'now').mockReturnValue(baseTime + 10 * 86400000); // 10 days later context = await service.getContext('test-project'); // Should contain a date format like "1/20/2026" or similar expect(context.markdown).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/); // Restore vi.restoreAllMocks(); }); it('should format context as markdown', async () => { await service.initSession('session-1', 'test-project', 'Test prompt'); await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); await service.completeSession('session-1', 'Done'); const context = await service.getContext('test-project'); expect(context.markdown).toContain('## Recent Activity'); expect(context.markdown).toContain('## Previous Sessions'); expect(context.markdown).toContain('Read'); }); it('should truncate long prompts in session context', async () => { const longPrompt = 'A'.repeat(150); // More than 100 characters await service.initSession('session-1', 'test-project', longPrompt); await service.completeSession('session-1', 'Done'); const context = await service.getContext('test-project'); // Should contain truncated prompt with ellipsis expect(context.markdown).toContain('A'.repeat(100)); expect(context.markdown).toContain('...'); }); it('should show active session status', async () => { // Create an active session (not completed) await service.initSession('session-active', 'test-project', 'Active task'); const context = await service.getContext('test-project'); // Active sessions should show → instead of ✓ expect(context.markdown).toContain('→'); }); it('should handle observations without title', async () => { await service.initSession('session-1', 'test-project'); // Store an observation - the service will generate a title await service.storeObservation('session-1', 'test-project', 'CustomTool', {}, {}, TEST_DIR); const context = await service.getContext('test-project'); // Should not error and should include the tool name expect(context.markdown).toContain('CustomTool'); }); }); describe('summary generation', () => { it('should generate summary from observations', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'b.ts' }, {}, TEST_DIR); await service.storeObservation('session-1', 'test-project', 'Write', { file_path: 'c.ts' }, {}, TEST_DIR); await service.storeObservation('session-1', 'test-project', 'Bash', { command: 'npm test' }, {}, TEST_DIR); const summary = await service.generateSummary('session-1'); expect(summary).toContain('file(s) modified'); expect(summary).toContain('file(s) read'); expect(summary).toContain('command(s) executed'); }); it('should return default summary for empty session', async () => { await service.initSession('session-1', 'test-project'); const summary = await service.generateSummary('session-1'); expect(summary).toContain('No activity recorded'); }); it('should list files in summary', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation('session-1', 'test-project', 'Write', { file_path: 'src/index.ts' }, {}, TEST_DIR); await service.storeObservation('session-1', 'test-project', 'Write', { file_path: 'src/utils.ts' }, {}, TEST_DIR); const summary = await service.generateSummary('session-1'); expect(summary).toContain('src/index.ts'); expect(summary).toContain('src/utils.ts'); }); it('should include search count in summary', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation('session-1', 'test-project', 'WebSearch', { query: 'test' }, {}, TEST_DIR); await service.storeObservation('session-1', 'test-project', 'WebFetch', { url: 'http://test.com' }, {}, TEST_DIR); const summary = await service.generateSummary('session-1'); expect(summary).toContain('search(es)'); }); it('should show file count when more than 5 files touched', async () => { await service.initSession('session-1', 'test-project'); // Touch more than 5 files for (let i = 0; i < 7; i++) { await service.storeObservation('session-1', 'test-project', 'Write', { file_path: `file${i}.ts` }, {}, TEST_DIR); } const summary = await service.generateSummary('session-1'); expect(summary).toContain('7 file(s) modified'); }); }); describe('enrichObservation', () => { const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; beforeEach(() => { delete process.env.AGENTKITS_AI_ENRICHMENT; resetAIEnrichmentCache(); }); afterEach(() => { _setRunClaudePrintMockForTesting(null); resetAIEnrichmentCache(); if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } }); it('should enrich an existing observation with AI data', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'auth.ts' }, { content: 'export class Auth {}' }, TEST_DIR ); // Reset cache and set up mock AI enrichment resetAIEnrichmentCache(); const validResponse = JSON.stringify({ subtitle: 'Examining auth module', narrative: 'Read the auth module to understand login flow.', facts: ['File has 200 lines', 'Uses JWT tokens'], concepts: ['authentication', 'jwt'], }); _setRunClaudePrintMockForTesting(() => validResponse); const result = await service.enrichObservation(obs.id); expect(result).toBe(true); // Verify the observation was updated in DB const observations = await service.getSessionObservations('session-1'); expect(observations[0].subtitle).toBe('Examining auth module'); expect(observations[0].narrative).toBe('Read the auth module to understand login flow.'); expect(observations[0].facts).toContain('File has 200 lines'); expect(observations[0].concepts).toContain('jwt'); }); it('should return false for non-existent observation', async () => { await service.initialize(); const result = await service.enrichObservation('obs_nonexistent_0000'); expect(result).toBe(false); }); it('should return false when AI enrichment returns null', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR ); // Mock CLI that returns invalid response _setRunClaudePrintMockForTesting(() => 'not valid json'); const result = await service.enrichObservation(obs.id); expect(result).toBe(false); // Original template data should still be intact const observations = await service.getSessionObservations('session-1'); expect(observations[0].subtitle).toBeDefined(); expect(observations[0].subtitle.length).toBeGreaterThan(0); }); it('should return false when AI enrichment throws', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR ); // Mock CLI that throws _setRunClaudePrintMockForTesting(() => { throw new Error('CLI error'); }); const result = await service.enrichObservation(obs.id); expect(result).toBe(false); }); it('should preserve template data when enrichment is disabled', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'src/index.ts' }, { content: 'hello' }, TEST_DIR ); // Template data should be present immediately expect(obs.subtitle).toBeDefined(); expect(obs.subtitle.length).toBeGreaterThan(0); expect(obs.narrative).toBeDefined(); expect(obs.narrative.length).toBeGreaterThan(0); }); }); describe('persistence', () => { it('should auto-recreate database if deleted', async () => { // Create and populate first instance await service.initSession('session-1', 'test-project', 'Test prompt'); await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); await service.shutdown(); // Delete the database file const dbPath = path.join(TEST_DIR, '.claude/memory', 'memory.db'); expect(existsSync(dbPath)).toBe(true); rmSync(dbPath); expect(existsSync(dbPath)).toBe(false); // Create new instance - should auto-create new database const service2 = new MemoryHookService(TEST_DIR); await service2.initialize(); // Old data should be gone const session = service2.getSession('session-1'); expect(session).toBeNull(); // But we can create new data await service2.initSession('session-2', 'test-project', 'New prompt'); const newSession = service2.getSession('session-2'); expect(newSession).not.toBeNull(); expect(newSession?.prompt).toBe('New prompt'); // Database file should exist again await service2.shutdown(); expect(existsSync(dbPath)).toBe(true); }); it('should persist data across service restarts', async () => { // Create and populate first instance await service.initSession('session-1', 'test-project', 'Test prompt'); await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); await service.shutdown(); // Create second instance const service2 = new MemoryHookService(TEST_DIR); await service2.initialize(); const session = service2.getSession('session-1'); const observations = await service2.getSessionObservations('session-1'); expect(session).not.toBeNull(); expect(session?.prompt).toBe('Test prompt'); expect(observations.length).toBe(1); await service2.shutdown(); }); }); describe('content hash deduplication', () => { it('should deduplicate identical prompts within 5-minute window', async () => { await service.initSession('session-1', 'test-project'); const prompt1 = await service.saveUserPrompt('session-1', 'test-project', 'Hello Claude'); const prompt2 = await service.saveUserPrompt('session-1', 'test-project', 'Hello Claude'); // Should return the same prompt (dedup) expect(prompt1.id).toBe(prompt2.id); expect(prompt1.contentHash).toBeDefined(); expect(prompt2.contentHash).toBe(prompt1.contentHash); }); it('should allow different prompts in same session', async () => { await service.initSession('session-1', 'test-project'); const prompt1 = await service.saveUserPrompt('session-1', 'test-project', 'First prompt'); const prompt2 = await service.saveUserPrompt('session-1', 'test-project', 'Second prompt'); expect(prompt1.id).not.toBe(prompt2.id); expect(prompt1.promptNumber).toBe(1); expect(prompt2.promptNumber).toBe(2); }); it('should deduplicate identical observations within 60-second window', async () => { await service.initSession('session-1', 'test-project'); const obs1 = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR ); const obs2 = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR ); // Should return the same observation (dedup) expect(obs1.id).toBe(obs2.id); // Session count should only increment once const session = service.getSession('session-1'); expect(session?.observationCount).toBe(1); }); it('should allow same tool on different files', async () => { await service.initSession('session-1', 'test-project'); const obs1 = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR ); const obs2 = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'b.ts' }, {}, TEST_DIR ); expect(obs1.id).not.toBe(obs2.id); }); }); describe('session resume detection', () => { it('should link new session to recent parent in same project', async () => { // Create first session await service.initSession('session-old', 'test-project'); // Create second session shortly after const session2 = await service.initSession('session-new', 'test-project'); expect(session2.parentSessionId).toBe('session-old'); }); it('should not link sessions from different projects', async () => { await service.initSession('session-1', 'project-a'); const session2 = await service.initSession('session-2', 'project-b'); expect(session2.parentSessionId).toBeUndefined(); }); it('should return existing session on re-init (no duplicate parent)', async () => { await service.initSession('session-1', 'test-project'); const first = await service.initSession('session-2', 'test-project'); const second = await service.initSession('session-2', 'test-project'); // Re-init returns the same session expect(first.sessionId).toBe(second.sessionId); expect(first.parentSessionId).toBe(second.parentSessionId); }); }); describe('context XML wrapper', () => { it('should wrap context in agentkits-memory-context tags', async () => { await service.initSession('session-1', 'test-project', 'Test'); await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); const context = await service.getContext('test-project'); expect(context.markdown).toContain(''); expect(context.markdown).toContain(''); expect(context.markdown).toContain("Use these naturally when relevant. Don't force them into every response."); }); }); describe('context grouping by prompt', () => { it('should group observations by prompt number when prompts exist', async () => { await service.initSession('session-1', 'test-project'); // Save prompt first await service.saveUserPrompt('session-1', 'test-project', 'Fix the bug'); // Store observation linked to prompt 1 await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'bug.ts' }, {}, TEST_DIR ); const context = await service.getContext('test-project'); // Should have prompt-based grouping expect(context.markdown).toContain('Prompt #1'); expect(context.markdown).toContain('Fix the bug'); }); }); describe('compressObservation', () => { const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; beforeEach(() => { delete process.env.AGENTKITS_AI_ENRICHMENT; resetAIEnrichmentCache(); }); afterEach(() => { _setRunClaudePrintMockForTesting(null); resetAIEnrichmentCache(); if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } }); it('should compress an observation and clear raw data', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'auth.ts' }, { content: 'big file content' }, TEST_DIR ); _setRunClaudePrintMockForTesting(() => JSON.stringify({ compressed_summary: 'Read auth.ts for login flow analysis' }) ); const result = await service.compressObservation(obs.id); expect(result).toBe(true); // Verify compressed data in DB const observations = await service.getSessionObservations('session-1'); expect(observations[0].compressedSummary).toBe('Read auth.ts for login flow analysis'); expect(observations[0].isCompressed).toBe(true); expect(observations[0].toolInput).toBe('{}'); // raw data cleared expect(observations[0].toolResponse).toBe('{}'); // raw data cleared }); it('should skip already-compressed observations', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR ); _setRunClaudePrintMockForTesting(() => JSON.stringify({ compressed_summary: 'First compression' }) ); // First compress await service.compressObservation(obs.id); // Second compress should skip (already compressed) const result = await service.compressObservation(obs.id); expect(result).toBe(false); }); it('should return false for non-existent observation', async () => { await service.initialize(); const result = await service.compressObservation('obs_nonexistent_0000'); expect(result).toBe(false); }); it('should return false when AI returns null', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR ); _setRunClaudePrintMockForTesting(() => 'not json'); const result = await service.compressObservation(obs.id); expect(result).toBe(false); }); }); describe('compressSessionObservations', () => { const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; beforeEach(() => { delete process.env.AGENTKITS_AI_ENRICHMENT; resetAIEnrichmentCache(); }); afterEach(() => { _setRunClaudePrintMockForTesting(null); resetAIEnrichmentCache(); if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } }); it('should compress all session observations and create digest', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); await service.storeObservation('session-1', 'test-project', 'Write', { file_path: 'b.ts' }, {}, TEST_DIR); // Save structured summary (needed for digest generation) const structured = await service.generateStructuredSummary('session-1'); await service.saveSessionSummary(structured); let callCount = 0; _setRunClaudePrintMockForTesting(() => { callCount++; // First two calls: observation compression, third: session digest if (callCount <= 2) { return JSON.stringify({ compressed_summary: `Compressed obs ${callCount}` }); } return JSON.stringify({ digest: 'Session compressed all observations successfully.' }); }); const result = await service.compressSessionObservations('session-1'); expect(result.compressed).toBe(2); expect(result.digestCreated).toBe(true); }); it('should handle session with no summary gracefully', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); _setRunClaudePrintMockForTesting(() => JSON.stringify({ compressed_summary: 'Compressed' }) ); const result = await service.compressSessionObservations('session-1'); expect(result.compressed).toBe(1); expect(result.digestCreated).toBe(false); // No summary = no digest }); }); describe('processCompressionQueue', () => { const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; beforeEach(() => { delete process.env.AGENTKITS_AI_ENRICHMENT; resetAIEnrichmentCache(); }); afterEach(() => { _setRunClaudePrintMockForTesting(null); resetAIEnrichmentCache(); if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } }); it('should process compress tasks from queue', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR ); // Queue a compress task manually service.queueTask('compress', 'observations', obs.id); _setRunClaudePrintMockForTesting(() => JSON.stringify({ compressed_summary: 'Read test.ts' }) ); const count = await service.processCompressionQueue(); expect(count).toBe(1); // Verify observation is compressed const observations = await service.getSessionObservations('session-1'); expect(observations[0].compressedSummary).toBe('Read test.ts'); expect(observations[0].isCompressed).toBe(true); }); it('should return 0 when queue is empty', async () => { await service.initialize(); const count = await service.processCompressionQueue(); expect(count).toBe(0); }); }); describe('task queue retry limits', () => { const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; beforeEach(() => { delete process.env.AGENTKITS_AI_ENRICHMENT; resetAIEnrichmentCache(); }); afterEach(() => { _setRunClaudePrintMockForTesting(null); resetAIEnrichmentCache(); if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } }); it('should skip tasks that have reached max retries', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR ); // Queue a compress task and set retry_count to 3 (max) service.queueTask('compress', 'observations', obs.id); service.db.prepare("UPDATE task_queue SET retry_count = 3 WHERE task_type = 'compress'").run(); _setRunClaudePrintMockForTesting(() => JSON.stringify({ compressed_summary: 'Should not run' }) ); const count = await service.processCompressionQueue(); expect(count).toBe(0); // Skipped — retry limit reached // Task should still be in queue as pending with retry_count=3 const task = service.db.prepare("SELECT * FROM task_queue WHERE task_type = 'compress'").get() as Record; expect(task.status).toBe('pending'); expect(task.retry_count).toBe(3); }); it('should increment retry_count on failure and mark failed at max', async () => { await service.initSession('session-1', 'test-project'); // Queue a compress task for observations table service.db.prepare( "INSERT INTO task_queue (task_type, target_table, target_id, created_at) VALUES ('compress', 'observations', 'bad-id', ?)" ).run(Date.now()); // Monkey-patch compressObservation to throw (simulating unhandled error) const origCompress = service.compressObservation.bind(service); service.compressObservation = async () => { throw new Error('unexpected failure'); }; // Worker loop retries within a single call — all 3 attempts exhaust in one run const count = await service.processCompressionQueue(); expect(count).toBe(3); // 3 failed attempts processed // Task should now be marked 'failed' with retry_count=3 const task = service.db.prepare("SELECT * FROM task_queue WHERE task_type = 'compress'").get() as Record; expect(task.status).toBe('failed'); expect(task.retry_count).toBe(3); // Next call skips entirely — no pending tasks under retry limit const count2 = await service.processCompressionQueue(); expect(count2).toBe(0); // Restore service.compressObservation = origCompress; }); it('should include retry_count column in task_queue schema', async () => { await service.initialize(); const cols = service.db.prepare("PRAGMA table_info(task_queue)").all() as Array<{ name: string }>; expect(cols.some(c => c.name === 'retry_count')).toBe(true); }); it('should default retry_count to 0 for new tasks', async () => { await service.initSession('session-1', 'test-project'); service.queueTask('embed', 'observations', 'test-id'); const task = service.db.prepare("SELECT retry_count FROM task_queue WHERE task_type = 'embed'").get() as Record; expect(task.retry_count).toBe(0); }); }); describe('computeContentHash', () => { it('should produce consistent hashes', () => { const hash1 = computeContentHash('a', 'b', 'c'); const hash2 = computeContentHash('a', 'b', 'c'); expect(hash1).toBe(hash2); }); it('should produce different hashes for different inputs', () => { const hash1 = computeContentHash('a', 'b'); const hash2 = computeContentHash('a', 'c'); expect(hash1).not.toBe(hash2); }); it('should produce 16-char hex string', () => { const hash = computeContentHash('test'); expect(hash).toMatch(/^[0-9a-f]{16}$/); }); }); // ===== Embedding Reliability ===== describe('hasPendingEmbeddings', () => { it('should return false when no pending tasks or missing embeddings', async () => { await service.initialize(); expect(service.hasPendingEmbeddings()).toBe(false); }); it('should return true when pending embed tasks exist', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR ); // storeObservation auto-queues embed task expect(service.hasPendingEmbeddings()).toBe(true); }); it('should return true when observations have null embeddings', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR ); // Clear task queue but leave embedding null service.db.prepare("DELETE FROM task_queue").run(); expect(service.hasPendingEmbeddings()).toBe(true); }); it('should return false when db not initialized', async () => { // service.db is null before initialize expect(service.hasPendingEmbeddings()).toBe(false); }); it('should ignore failed tasks at max retries', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR ); // Set all tasks to failed with max retries service.db.prepare("UPDATE task_queue SET status = 'failed', retry_count = 3").run(); // But observations still have null embeddings — should detect via DB scan expect(service.hasPendingEmbeddings()).toBe(true); }); }); describe('hasPendingEnrichments', () => { it('should return false when no pending enrich tasks', async () => { await service.initialize(); expect(service.hasPendingEnrichments()).toBe(false); }); it('should return true when pending enrich tasks exist', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR ); // Manually queue an enrich task service.db.prepare( "INSERT INTO task_queue (task_type, target_id, target_table, status, created_at) VALUES ('enrich', ?, 'observations', 'pending', datetime('now'))" ).run(obs.id); expect(service.hasPendingEnrichments()).toBe(true); }); it('should ignore failed tasks at max retries', async () => { await service.initialize(); service.db.prepare( "INSERT INTO task_queue (task_type, target_id, target_table, status, retry_count, created_at) VALUES ('enrich', 'obs-1', 'observations', 'failed', 3, datetime('now'))" ).run(); expect(service.hasPendingEnrichments()).toBe(false); }); it('should return false when db not initialized', () => { expect(service.hasPendingEnrichments()).toBe(false); }); }); describe('hasPendingCompressions', () => { it('should return false when no pending compress tasks', async () => { await service.initialize(); expect(service.hasPendingCompressions()).toBe(false); }); it('should return true when pending compress tasks exist', async () => { await service.initSession('session-1', 'test-project'); const obs = await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR ); service.db.prepare( "INSERT INTO task_queue (task_type, target_id, target_table, status, created_at) VALUES ('compress', ?, 'observations', 'pending', datetime('now'))" ).run(obs.id); expect(service.hasPendingCompressions()).toBe(true); }); it('should return true when pending digest tasks exist', async () => { await service.initSession('session-1', 'test-project'); service.db.prepare( "INSERT INTO task_queue (task_type, target_id, target_table, status, created_at) VALUES ('digest', 'session-1', 'sessions', 'pending', datetime('now'))" ).run(); expect(service.hasPendingCompressions()).toBe(true); }); it('should ignore failed tasks at max retries', async () => { await service.initialize(); service.db.prepare( "INSERT INTO task_queue (task_type, target_id, target_table, status, retry_count, created_at) VALUES ('compress', 'obs-1', 'observations', 'failed', 3, datetime('now'))" ).run(); expect(service.hasPendingCompressions()).toBe(false); }); it('should return false when db not initialized', () => { expect(service.hasPendingCompressions()).toBe(false); }); }); // ===== Persistent Settings ===== describe('loadSettings / saveSettings', () => { it('should return defaults when no settings file exists', async () => { await service.initialize(); const settings = service.loadSettings(); expect(settings.context.showSummaries).toBe(true); expect(settings.context.showPrompts).toBe(true); expect(settings.context.showObservations).toBe(true); expect(settings.context.showToolGuidance).toBe(true); expect(settings.context.maxSummaries).toBe(3); expect(settings.context.maxPrompts).toBe(10); expect(settings.context.maxObservations).toBe(10); }); it('should read settings from .claude/memory/settings.json', async () => { await service.initialize(); // Write a custom settings file const settingsPath = path.join(TEST_DIR, '.claude', 'memory', 'settings.json'); mkdirSync(path.dirname(settingsPath), { recursive: true }); writeFileSync(settingsPath, JSON.stringify({ context: { showSummaries: false, maxObservations: 25 }, })); const settings = service.loadSettings(); expect(settings.context.showSummaries).toBe(false); expect(settings.context.maxObservations).toBe(25); // Missing keys get defaults expect(settings.context.showPrompts).toBe(true); expect(settings.context.maxSummaries).toBe(3); }); it('should return defaults on corrupted settings file', async () => { await service.initialize(); const settingsPath = path.join(TEST_DIR, '.claude', 'memory', 'settings.json'); mkdirSync(path.dirname(settingsPath), { recursive: true }); writeFileSync(settingsPath, '{ invalid json'); const settings = service.loadSettings(); expect(settings.context.showSummaries).toBe(true); expect(settings.context.maxObservations).toBe(10); }); it('should save settings to disk', async () => { await service.initialize(); const customSettings = { context: { showSummaries: false, showPrompts: true, showObservations: true, showToolGuidance: false, maxSummaries: 5, maxPrompts: 20, maxObservations: 30, }, }; service.saveSettings(customSettings); // Verify file contents const settingsPath = path.join(TEST_DIR, '.claude', 'memory', 'settings.json'); const saved = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(saved.context.showSummaries).toBe(false); expect(saved.context.maxObservations).toBe(30); }); it('should round-trip save and load', async () => { await service.initialize(); const original = { context: { showSummaries: false, showPrompts: false, showObservations: true, showToolGuidance: true, maxSummaries: 1, maxPrompts: 5, maxObservations: 15, }, }; service.saveSettings(original); const loaded = service.loadSettings(); expect(loaded).toEqual(original); }); it('should save and load aiProvider settings', async () => { await service.initialize(); const settings = { context: { showSummaries: true, showPrompts: true, showObservations: true, showToolGuidance: true, maxSummaries: 3, maxPrompts: 10, maxObservations: 10, }, aiProvider: { provider: 'openai' as const, apiKey: 'sk-test-key', baseUrl: 'https://openrouter.ai/api/v1', model: 'anthropic/claude-3.5-haiku', }, }; service.saveSettings(settings); const loaded = service.loadSettings(); expect(loaded.aiProvider).toBeDefined(); expect(loaded.aiProvider!.provider).toBe('openai'); expect(loaded.aiProvider!.apiKey).toBe('sk-test-key'); expect(loaded.aiProvider!.baseUrl).toBe('https://openrouter.ai/api/v1'); expect(loaded.aiProvider!.model).toBe('anthropic/claude-3.5-haiku'); }); it('should return undefined aiProvider when not set in settings', async () => { await service.initialize(); const loaded = service.loadSettings(); expect(loaded.aiProvider).toBeUndefined(); }); }); describe('getContext with settings', () => { it('should use settings from disk', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR ); // Save settings to disable summaries service.saveSettings({ context: { showSummaries: false, showPrompts: true, showObservations: true, showToolGuidance: false, maxSummaries: 3, maxPrompts: 10, maxObservations: 10, }, }); const context = await service.getContext('test-project'); // Tool guidance should be absent when showToolGuidance=false expect(context.markdown).not.toContain('Memory tools available'); }); it('should respect configOverride over disk settings', async () => { await service.initSession('session-1', 'test-project'); await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR ); // Disk settings: guidance enabled service.saveSettings({ context: { showSummaries: true, showPrompts: true, showObservations: true, showToolGuidance: true, maxSummaries: 3, maxPrompts: 10, maxObservations: 10, }, }); // Override: disable guidance const context = await service.getContext('test-project', { showSummaries: true, showPrompts: true, showObservations: true, showToolGuidance: false, maxSummaries: 3, maxPrompts: 10, maxObservations: 10, }); expect(context.markdown).not.toContain('Memory tools available'); }); it('should limit observations by maxObservations setting', async () => { await service.initSession('session-1', 'test-project'); // Store 5 observations for (let i = 0; i < 5; i++) { await service.storeObservation( 'session-1', 'test-project', 'Read', { file_path: `file${i}.ts` }, {}, TEST_DIR ); } // Set maxObservations=2 service.saveSettings({ context: { showSummaries: true, showPrompts: true, showObservations: true, showToolGuidance: true, maxSummaries: 3, maxPrompts: 10, maxObservations: 2, }, }); const context = await service.getContext('test-project'); expect(context.recentObservations.length).toBe(2); }); }); describe('createHookService factory', () => { it('should create service with default config', () => { const svc = createHookService(TEST_DIR); expect(svc).toBeInstanceOf(MemoryHookService); }); }); // ===== Feature #5: Intent Tags ===== describe('intent detection in storeObservation', () => { it('should add intent tags to concepts when prompt exists', async () => { await service.initialize(); await service.initSession('intent-session', 'test-project', 'Fix the login bug'); await service.saveUserPrompt('intent-session', 'test-project', 'Fix the login bug'); const obs = await service.storeObservation( 'intent-session', 'test-project', 'Edit', { file_path: 'src/auth.ts', old_string: 'foo', new_string: 'bar' }, { success: true }, TEST_DIR ); expect(obs.concepts).toBeDefined(); const intentConcepts = obs.concepts!.filter(c => c.startsWith('intent:')); expect(intentConcepts.length).toBeGreaterThan(0); expect(intentConcepts).toContain('intent:bugfix'); }); it('should default to investigation for read without prompt', async () => { await service.initialize(); await service.initSession('intent-session-2', 'test-project'); const obs = await service.storeObservation( 'intent-session-2', 'test-project', 'Read', { file_path: 'src/app.ts' }, { content: 'file contents' }, TEST_DIR ); const intentConcepts = obs.concepts!.filter(c => c.startsWith('intent:')); expect(intentConcepts).toContain('intent:investigation'); }); }); describe('getLatestPromptText', () => { it('should return latest prompt text', async () => { await service.initialize(); await service.initSession('prompt-text-session', 'test-project', 'Hello'); await service.saveUserPrompt('prompt-text-session', 'test-project', 'First prompt'); await service.saveUserPrompt('prompt-text-session', 'test-project', 'Second prompt'); const text = service.getLatestPromptText('prompt-text-session'); expect(text).toBe('Second prompt'); }); it('should return null when no prompts exist', async () => { await service.initialize(); const text = service.getLatestPromptText('nonexistent-session'); expect(text).toBeNull(); }); }); // ===== Feature #8: Lifecycle Management ===== describe('lifecycle management', () => { it('should archive old completed sessions', async () => { await service.initialize(); // Create an old completed session await service.initSession('old-session', 'test-project', 'old task'); await service.completeSession('old-session', 'Done'); // Manually backdate the session // @ts-expect-error accessing private db for testing service.db.prepare("UPDATE sessions SET ended_at = ? WHERE session_id = ?").run( Date.now() - 40 * 86400000, // 40 days ago 'old-session' ); const result = await service.runLifecycleTasks({ archiveAfterDays: 30 }); expect(result.archived).toBe(1); // Verify session is archived const session = service.getSession('old-session'); expect(session?.status).toBe('archived'); }); it('should not archive recent sessions', async () => { await service.initialize(); await service.initSession('recent-session', 'test-project', 'recent task'); await service.completeSession('recent-session', 'Done'); const result = await service.runLifecycleTasks({ archiveAfterDays: 30 }); expect(result.archived).toBe(0); const session = service.getSession('recent-session'); expect(session?.status).toBe('completed'); }); it('should delete archived sessions when autoDelete enabled', async () => { await service.initialize(); await service.initSession('delete-session', 'test-project', 'delete task'); await service.completeSession('delete-session', 'Done'); // @ts-expect-error accessing private db for testing service.db.prepare("UPDATE sessions SET status = 'archived', ended_at = ? WHERE session_id = ?").run( Date.now() - 100 * 86400000, // 100 days ago 'delete-session' ); const result = await service.runLifecycleTasks({ autoDelete: true, deleteAfterDays: 90, }); expect(result.deleted).toBe(1); expect(result.vacuumed).toBe(true); const session = service.getSession('delete-session'); expect(session).toBeNull(); }); it('should not delete when autoDelete is false (default)', async () => { await service.initialize(); await service.initSession('keep-session', 'test-project', 'keep task'); await service.completeSession('keep-session', 'Done'); // @ts-expect-error accessing private db for testing service.db.prepare("UPDATE sessions SET status = 'archived', ended_at = ? WHERE session_id = ?").run( Date.now() - 100 * 86400000, 'keep-session' ); const result = await service.runLifecycleTasks({ autoDelete: false }); expect(result.deleted).toBe(0); const session = service.getSession('keep-session'); expect(session).not.toBeNull(); }); it('should queue compression for old uncompressed observations', async () => { await service.initialize(); await service.initSession('compress-lc-session', 'test-project', 'task'); await service.storeObservation( 'compress-lc-session', 'test-project', 'Read', { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR ); // Backdate the observation // @ts-expect-error accessing private db for testing service.db.prepare("UPDATE observations SET timestamp = ? WHERE session_id = ?").run( Date.now() - 10 * 86400000, // 10 days ago 'compress-lc-session' ); const result = await service.runLifecycleTasks({ compressAfterDays: 7 }); expect(result.compressed).toBe(1); }); }); describe('lifecycle stats', () => { it('should return database statistics', async () => { await service.initialize(); await service.initSession('stats-session', 'test-project', 'stats'); await service.storeObservation( 'stats-session', 'test-project', 'Read', { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR ); await service.saveUserPrompt('stats-session', 'test-project', 'test prompt'); const stats = await service.getLifecycleStats(); expect(stats.totalSessions).toBeGreaterThanOrEqual(1); expect(stats.activeSessions).toBeGreaterThanOrEqual(1); expect(stats.totalObservations).toBeGreaterThanOrEqual(1); expect(stats.totalPrompts).toBeGreaterThanOrEqual(1); expect(stats.dbSizeBytes).toBeGreaterThan(0); }); }); // ===== Feature #9: Export/Import ===== describe('structured diff capture', () => { it('should include diff facts for Edit observations', async () => { await service.initialize(); await service.initSession('diff-session', 'test-project'); const obs = await service.storeObservation( 'diff-session', 'test-project', 'Edit', { file_path: 'src/auth.ts', old_string: 'function login(user) {', new_string: 'function login(user, opts) {' }, {}, TEST_DIR ); expect(obs.facts).toBeDefined(); expect(obs.facts!.some(f => f.includes('DIFF'))).toBe(true); expect(obs.facts!.some(f => f.includes('function login(user) {'))).toBe(true); expect(obs.facts!.some(f => f.includes('function login(user, opts) {'))).toBe(true); }); it('should include diff info in narrative for Edit', async () => { await service.initialize(); await service.initSession('diff-session-2', 'test-project'); const obs = await service.storeObservation( 'diff-session-2', 'test-project', 'Edit', { file_path: 'src/app.ts', old_string: 'const x = 1;', new_string: 'const x = 2;' }, {}, TEST_DIR ); expect(obs.narrative).toBeDefined(); expect(obs.narrative).toContain('const x = 1;'); expect(obs.narrative).toContain('const x = 2;'); }); it('should handle MultiEdit with multiple diffs', async () => { await service.initialize(); await service.initSession('multi-diff', 'test-project'); const obs = await service.storeObservation( 'multi-diff', 'test-project', 'MultiEdit', { file_path: 'src/index.ts', edits: [ { old_string: 'import { a } from "./a"', new_string: 'import { a, b } from "./a"' }, { old_string: 'export default a;', new_string: 'export default { a, b };' }, ], }, {}, TEST_DIR ); expect(obs.facts!.filter(f => f.includes('DIFF')).length).toBe(2); }); }); describe('decision rationale in summaries', () => { it('should extract decisions from Edit observations', async () => { await service.initialize(); await service.initSession('decision-session', 'test-project'); await service.saveUserPrompt('decision-session', 'test-project', 'Fix the auth bug'); await service.storeObservation( 'decision-session', 'test-project', 'Edit', { file_path: 'src/auth.ts', old_string: 'function login(user) {', new_string: 'function login(user, opts) {' }, {}, TEST_DIR ); const summary = await service.generateStructuredSummary('decision-session'); expect(summary.decisions).toBeDefined(); expect(summary.decisions.length).toBeGreaterThan(0); expect(summary.decisions[0]).toContain('auth.ts'); expect(summary.decisions[0]).toContain('function login'); }); it('should include intent tags in decisions', async () => { await service.initialize(); await service.initSession('intent-decision', 'test-project'); await service.saveUserPrompt('intent-decision', 'test-project', 'Refactor the handler'); await service.storeObservation( 'intent-decision', 'test-project', 'Edit', { file_path: 'src/handler.ts', old_string: 'async handle(req)', new_string: 'async handleRequest(req, res)' }, {}, TEST_DIR ); const summary = await service.generateStructuredSummary('intent-decision'); expect(summary.decisions.length).toBeGreaterThan(0); expect(summary.decisions[0]).toContain('refactor'); }); it('should include decisions in saved session summaries', async () => { await service.initialize(); await service.initSession('saved-decision', 'test-project'); await service.saveUserPrompt('saved-decision', 'test-project', 'Add feature'); await service.storeObservation( 'saved-decision', 'test-project', 'Edit', { file_path: 'src/feature.ts', old_string: 'const x = 1;', new_string: 'const x = getValue();' }, {}, TEST_DIR ); const structured = await service.generateStructuredSummary('saved-decision'); const saved = await service.saveSessionSummary(structured); expect(saved.decisions).toBeDefined(); expect(saved.decisions.length).toBeGreaterThan(0); // Verify it roundtrips through DB const summaries = await service.getRecentSummaries('test-project'); const found = summaries.find(s => s.sessionId === 'saved-decision'); expect(found).toBeDefined(); expect(found!.decisions.length).toBeGreaterThan(0); }); it('should return empty decisions when no Edit observations', async () => { await service.initialize(); await service.initSession('no-decision', 'test-project'); await service.storeObservation( 'no-decision', 'test-project', 'Read', { file_path: 'src/app.ts' }, {}, TEST_DIR ); const summary = await service.generateStructuredSummary('no-decision'); expect(summary.decisions).toEqual([]); }); it('should show decisions in context markdown', async () => { await service.initialize(); await service.initSession('ctx-decision', 'test-project'); await service.saveUserPrompt('ctx-decision', 'test-project', 'Fix bug'); await service.storeObservation( 'ctx-decision', 'test-project', 'Edit', { file_path: 'src/fix.ts', old_string: 'return null;', new_string: 'return defaultValue;' }, {}, TEST_DIR ); const structured = await service.generateStructuredSummary('ctx-decision'); await service.saveSessionSummary(structured); await service.completeSession('ctx-decision', 'Done'); const ctx = await service.getContext('test-project'); expect(ctx.markdown).toContain('Decisions'); }); }); describe('errors in structured summaries', () => { it('should extract errors from Bash stderr/stdout', async () => { await service.initialize(); await service.initSession('error-session', 'test-project'); await service.storeObservation( 'error-session', 'test-project', 'Bash', { command: 'npm run build' }, { stderr: 'Error: TS2304 Cannot find name "foo"', stdout: '' }, TEST_DIR ); const summary = await service.generateStructuredSummary('error-session'); expect(summary.errors).toBeDefined(); expect(summary.errors.length).toBeGreaterThan(0); expect(summary.errors[0]).toContain('TS2304'); }); it('should not include false positive errors like "0 errors"', async () => { await service.initialize(); await service.initSession('no-error-session', 'test-project'); await service.storeObservation( 'no-error-session', 'test-project', 'Bash', { command: 'tsc' }, { stdout: 'Build completed with 0 errors', stderr: '' }, TEST_DIR ); const summary = await service.generateStructuredSummary('no-error-session'); expect(summary.errors.length).toBe(0); }); it('should return empty errors when no Bash observations', async () => { await service.initialize(); await service.initSession('read-only-session', 'test-project'); await service.storeObservation( 'read-only-session', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR ); const summary = await service.generateStructuredSummary('read-only-session'); expect(summary.errors).toEqual([]); }); it('should cap errors at 10', async () => { await service.initialize(); await service.initSession('many-error-session', 'test-project'); // Create output with many error lines const errorLines = Array.from({ length: 20 }, (_, i) => `Error: issue ${i}`).join('\n'); await service.storeObservation( 'many-error-session', 'test-project', 'Bash', { command: 'build' }, { stdout: errorLines, stderr: '' }, TEST_DIR ); const summary = await service.generateStructuredSummary('many-error-session'); expect(summary.errors.length).toBeLessThanOrEqual(10); }); it('should include errors in saved session summary', async () => { await service.initialize(); await service.initSession('saved-error', 'test-project'); await service.storeObservation( 'saved-error', 'test-project', 'Bash', { command: 'tsc' }, { stderr: 'fatal error: out of memory' }, TEST_DIR ); const structured = await service.generateStructuredSummary('saved-error'); const saved = await service.saveSessionSummary(structured); expect(saved.errors.length).toBeGreaterThan(0); // Verify roundtrip through DB const summaries = await service.getRecentSummaries('test-project'); const found = summaries.find(s => s.sessionId === 'saved-error'); expect(found).toBeDefined(); expect(found!.errors.length).toBeGreaterThan(0); }); it('should show errors in context markdown', async () => { await service.initialize(); await service.initSession('ctx-error', 'test-project'); await service.storeObservation( 'ctx-error', 'test-project', 'Bash', { command: 'npm test' }, { stderr: 'Error: Test failed unexpectedly' }, TEST_DIR ); const structured = await service.generateStructuredSummary('ctx-error'); await service.saveSessionSummary(structured); await service.completeSession('ctx-error', 'Done'); const ctx = await service.getContext('test-project'); expect(ctx.markdown).toContain('Errors'); }); }); describe('cross-session pattern detection', () => { it('should detect recurring concepts across observations', async () => { await service.initialize(); // Create multiple sessions with overlapping concepts await service.initSession('pattern-s1', 'test-project'); await service.storeObservation( 'pattern-s1', 'test-project', 'Edit', { file_path: 'src/auth.ts', old_string: 'function login(user) {', new_string: 'function login(user, opts) {' }, {}, TEST_DIR ); await service.initSession('pattern-s2', 'test-project'); await service.storeObservation( 'pattern-s2', 'test-project', 'Edit', { file_path: 'src/auth.ts', old_string: 'function logout() {', new_string: 'function logout(session) {' }, {}, TEST_DIR ); const patterns = await service.detectCrossSessionPatterns('test-project'); // Both edits touched 'authentication' concept from auth.ts path expect(patterns.length).toBeGreaterThan(0); // At least some patterns should appear with count >= 2 expect(patterns.every(p => p.count >= 2)).toBe(true); expect(patterns.every(p => p.category)).toBe(true); }); it('should categorize patterns by prefix', async () => { await service.initialize(); // Create observations with fn: and intent: concepts await service.initSession('cat-s1', 'test-project'); await service.saveUserPrompt('cat-s1', 'test-project', 'Fix the login bug'); await service.storeObservation( 'cat-s1', 'test-project', 'Edit', { file_path: 'src/app.ts', old_string: 'function handleAuth() {', new_string: 'function handleAuth(opts) {' }, {}, TEST_DIR ); await service.initSession('cat-s2', 'test-project'); await service.saveUserPrompt('cat-s2', 'test-project', 'Fix the signup bug'); await service.storeObservation( 'cat-s2', 'test-project', 'Edit', { file_path: 'src/signup.ts', old_string: 'function handleAuth() {', new_string: 'function handleAuth(token) {' }, {}, TEST_DIR ); const patterns = await service.detectCrossSessionPatterns('test-project'); // Check category values const categories = new Set(patterns.map(p => p.category)); // Should have valid categories for (const cat of categories) { expect(['topic', 'intent', 'function', 'class', 'code-pattern']).toContain(cat); } }); it('should return empty for project with no observations', async () => { await service.initialize(); const patterns = await service.detectCrossSessionPatterns('empty-project'); expect(patterns).toEqual([]); }); it('should respect limit parameter', async () => { await service.initialize(); await service.initSession('limit-s1', 'test-project'); // Create many observations to generate many concepts for (let i = 0; i < 5; i++) { await service.storeObservation( 'limit-s1', 'test-project', 'Edit', { file_path: `src/file${i}.ts`, old_string: 'const a = 1;', new_string: 'const a = 2;' }, {}, TEST_DIR ); } const patterns = await service.detectCrossSessionPatterns('test-project', 3); expect(patterns.length).toBeLessThanOrEqual(3); }); }); describe('export/import', () => { it('should export sessions with observations and prompts', async () => { await service.initialize(); await service.initSession('export-session', 'test-project', 'export task'); await service.saveUserPrompt('export-session', 'test-project', 'export prompt'); await service.storeObservation( 'export-session', 'test-project', 'Read', { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR ); await service.completeSession('export-session', 'Done'); const data = await service.exportToJSON('test-project'); expect(data.version).toBe('1.0'); expect(data.project).toBe('test-project'); expect(data.sessions.length).toBeGreaterThanOrEqual(1); const session = data.sessions.find(s => s.sessionId === 'export-session'); expect(session).toBeDefined(); expect(session!.observations.length).toBeGreaterThanOrEqual(1); expect(session!.prompts.length).toBeGreaterThanOrEqual(1); }); it('should export specific sessions by ID', async () => { await service.initialize(); await service.initSession('export-a', 'test-project', 'task A'); await service.initSession('export-b', 'test-project', 'task B'); const data = await service.exportToJSON('test-project', ['export-a']); expect(data.sessions.length).toBe(1); expect(data.sessions[0].sessionId).toBe('export-a'); }); it('should import exported data with new session IDs', async () => { await service.initialize(); await service.initSession('import-src', 'test-project', 'import task'); await service.saveUserPrompt('import-src', 'test-project', 'import prompt'); await service.storeObservation( 'import-src', 'test-project', 'Read', { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR ); const exported = await service.exportToJSON('test-project', ['import-src']); // Importing into same DB: content_hash dedup will skip existing obs/prompts // but session is always created new const result = await service.importFromJSON(exported); expect(result.imported.sessions).toBe(1); // Observations and prompts are deduplicated by content_hash since they already exist expect(result.imported.observations + result.skipped.observations).toBeGreaterThanOrEqual(1); expect(result.imported.prompts + result.skipped.prompts).toBeGreaterThanOrEqual(1); }); it('should dedup observations by content_hash on reimport', async () => { await service.initialize(); await service.initSession('dedup-session', 'test-project', 'dedup task'); await service.storeObservation( 'dedup-session', 'test-project', 'Read', { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR ); const exported = await service.exportToJSON('test-project', ['dedup-session']); // First import: content_hash already exists in same DB → skipped const result1 = await service.importFromJSON(exported); // Second import: still skipped (same hash) const result2 = await service.importFromJSON(exported); // Both imports should have the session created but obs deduplicated expect(result1.imported.sessions).toBe(1); expect(result2.imported.sessions).toBe(1); // Both skip observations since content_hash already in DB expect(result1.skipped.observations + result2.skipped.observations).toBeGreaterThanOrEqual(1); }); it('should preserve errors in export/import roundtrip', async () => { await service.initialize(); await service.initSession('err-export', 'test-project'); await service.storeObservation( 'err-export', 'test-project', 'Bash', { command: 'build' }, { stderr: 'Error: compilation failed' }, TEST_DIR ); const structured = await service.generateStructuredSummary('err-export'); await service.saveSessionSummary(structured); await service.completeSession('err-export', 'Done'); const exported = await service.exportToJSON('test-project', ['err-export']); const session = exported.sessions.find(s => s.sessionId === 'err-export'); expect(session).toBeDefined(); expect(session!.summary).toBeDefined(); expect(session!.summary!.errors.length).toBeGreaterThan(0); }); }); });