/** * Integration Test: Full Observation Lifecycle * * Tests the complete flow from tool usage to observation storage * and retrieval through search. This validates that all components * work together correctly. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { bashCommandScenario, sessionScenario, sampleObservation } from '../helpers/scenarios.js'; import { getWorkerPort } from '../../src/shared/worker-utils.js'; describe('Full Observation Lifecycle', () => { const WORKER_PORT = getWorkerPort(); let sessionId: string; beforeEach(() => { vi.clearAllMocks(); sessionId = sessionScenario.claudeSessionId; }); it('observation flows from hook to database to search', async () => { /** * This integration test simulates the complete happy path: * * 1. Session starts → Context injected * 2. User types prompt → First tool runs * 3. Tool result captured → Observation queued * 4. SDK processes → Observation saved * 5. Search finds observation * 6. Session ends → Cleanup */ // === Step 1: Context Injection (SessionStart) === global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, text: async () => '# [claude-mem] recent context\n\nNo observations yet.' }); const contextResponse = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/context/inject?project=claude-mem` ); expect(contextResponse.ok).toBe(true); const contextText = await contextResponse.text(); expect(contextText).toContain('recent context'); // === Step 2 & 3: Tool runs, Observation captured (PostToolUse) === global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ status: 'queued', observationId: 1 }) }); const observationResponse = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionId, tool_name: bashCommandScenario.tool_name, tool_input: bashCommandScenario.tool_input, tool_response: bashCommandScenario.tool_response, cwd: '/project/claude-mem' }) } ); expect(observationResponse.ok).toBe(true); const obsResult = await observationResponse.json(); expect(obsResult.status).toBe('queued'); // === Step 4: Simulate SDK processing and saving observation === // In a real flow, the SDK would process the tool data and generate an observation // For this test, we simulate the observation being saved to the database // === Step 5: Search finds the observation === global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ results: [ { id: 1, title: 'Git status check', content: 'Checked repository status, working tree clean', type: 'discovery', files: [], created_at: new Date().toISOString() } ], total: 1 }) }); const searchResponse = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/search?query=git+status&project=claude-mem` ); expect(searchResponse.ok).toBe(true); const searchResults = await searchResponse.json(); expect(searchResults.results).toHaveLength(1); expect(searchResults.results[0].title).toContain('Git'); // === Step 6: Session summary (Stop) === global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ status: 'queued' }) }); const summaryResponse = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/summarize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionId, last_user_message: 'Thanks!', last_assistant_message: 'Checked git status successfully.', cwd: '/project/claude-mem' }) } ); expect(summaryResponse.ok).toBe(true); // === Step 7: Session cleanup (SessionEnd) === global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ status: 'completed' }) }); const cleanupResponse = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionId, reason: 'user_exit' }) } ); expect(cleanupResponse.ok).toBe(true); // Verify: All steps completed successfully expect(global.fetch).toHaveBeenCalled(); }); it('handles multiple observations in a single session', async () => { /** * Tests a more realistic session with multiple tool uses * and observations being generated. */ // Track all observations in this session const observations: any[] = []; // Mock worker to accept multiple observations let obsCount = 0; global.fetch = vi.fn().mockImplementation(async (url: string, options?: any) => { if (url.includes('/api/sessions/observations') && options?.method === 'POST') { obsCount++; const body = JSON.parse(options.body); observations.push(body); return { ok: true, status: 200, json: async () => ({ status: 'queued', observationId: obsCount }) }; } if (url.includes('/api/search')) { return { ok: true, status: 200, json: async () => ({ results: observations.map((obs, i) => ({ id: i + 1, title: `Observation ${i + 1}`, content: `Tool: ${obs.tool_name}`, type: 'discovery', created_at: new Date().toISOString() })), total: observations.length }) }; } return { ok: true, status: 200, json: async () => ({}) }; }); // Simulate 5 different tool uses const tools = [ { name: 'Bash', input: { command: 'npm test' } }, { name: 'Read', input: { file_path: '/src/index.ts' } }, { name: 'Edit', input: { file_path: '/src/index.ts', old_string: 'old', new_string: 'new' } }, { name: 'Grep', input: { pattern: 'function', path: '/src' } }, { name: 'Write', input: { file_path: '/src/new.ts', content: 'code' } } ]; // Send observations for each tool for (const tool of tools) { const response = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionId, tool_name: tool.name, tool_input: tool.input, tool_response: { success: true }, cwd: '/project' }) } ); expect(response.ok).toBe(true); } // Verify: All observations were queued expect(observations).toHaveLength(5); expect(observations.map(o => o.tool_name)).toEqual(['Bash', 'Read', 'Edit', 'Grep', 'Write']); // Search finds all observations const searchResponse = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/search?query=observation&project=test-project` ); const searchResults = await searchResponse.json(); expect(searchResults.results).toHaveLength(5); }); it('preserves context across session lifecycle', async () => { /** * Tests that observations from one session can be found * when starting a new session in the same project. */ // Session 1: Create some observations global.fetch = vi.fn().mockImplementation(async (url: string, options?: any) => { if (url.includes('/api/sessions/observations')) { return { ok: true, status: 200, json: async () => ({ status: 'queued', observationId: 1 }) }; } if (url.includes('/api/context/inject')) { return { ok: true, status: 200, text: async () => `# [test-project] recent context ## Recent Work (1 observation) ### [bugfix] Fixed parser bug The XML parser now handles self-closing tags correctly. Files: /src/parser.ts` }; } return { ok: true, status: 200, json: async () => ({}) }; }); // Session 1: Add observation await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: 'session-1', tool_name: 'Edit', tool_input: { file_path: '/src/parser.ts' }, tool_response: { success: true }, cwd: '/project/test-project' }) } ); // Session 2: Start new session, should see context from session 1 const contextResponse = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/context/inject?project=test-project` ); const context = await contextResponse.text(); // Verify: Context includes previous session's work expect(context).toContain('Fixed parser bug'); expect(context).toContain('parser.ts'); }); it('handles error recovery gracefully', async () => { /** * Tests that the system continues to work even if some * operations fail along the way. */ let callCount = 0; global.fetch = vi.fn().mockImplementation(async () => { callCount++; // First call fails (simulating transient error) if (callCount === 1) { return { ok: false, status: 500, json: async () => ({ error: 'Temporary error' }) }; } // Subsequent calls succeed return { ok: true, status: 200, json: async () => ({ status: 'queued' }) }; }); // First attempt fails const firstAttempt = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionId, tool_name: 'Bash', tool_input: { command: 'test' }, tool_response: {}, cwd: '/project' }) } ); expect(firstAttempt.ok).toBe(false); // Retry succeeds const secondAttempt = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionId, tool_name: 'Bash', tool_input: { command: 'test' }, tool_response: {}, cwd: '/project' }) } ); expect(secondAttempt.ok).toBe(true); }); });