/** * Unit Tests for AI Enrichment Module * * Tests the enrichment logic, env toggle, fallback behavior, * parseAIResponse, buildExtractionPrompt, and mock CLI flow. * * @module @agentkits/memory/hooks/__tests__/ai-enrichment */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { enrichWithAI, isAIEnrichmentAvailable, isAIEnrichmentEnabled, resetAIEnrichmentCache, parseAIResponse, buildExtractionPrompt, parseSummaryResponse, buildSummaryPrompt, enrichSummaryWithAI, buildCompressionPrompt, parseCompressionResponse, compressObservationWithAI, buildSessionDigestPrompt, parseSessionDigestResponse, generateSessionDigestWithAI, _setRunClaudePrintMockForTesting, _setCliAvailableForTesting, setAIProviderConfig, } from '../ai-enrichment.js'; describe('AI Enrichment Module', () => { const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; beforeEach(() => { resetAIEnrichmentCache(); }); afterEach(() => { if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } resetAIEnrichmentCache(); }); describe('environment variable control', () => { it('should return null when AGENTKITS_AI_ENRICHMENT=false', async () => { process.env.AGENTKITS_AI_ENRICHMENT = 'false'; const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); expect(result).toBeNull(); }); it('should return null when AGENTKITS_AI_ENRICHMENT=0', async () => { process.env.AGENTKITS_AI_ENRICHMENT = '0'; const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); expect(result).toBeNull(); }); it('should attempt enrichment when AGENTKITS_AI_ENRICHMENT=true', async () => { process.env.AGENTKITS_AI_ENRICHMENT = 'true'; resetAIEnrichmentCache(); _setCliAvailableForTesting(true); _setRunClaudePrintMockForTesting(() => JSON.stringify({ subtitle: 'Mock subtitle', narrative: 'Mock narrative text.', facts: ['Fact 1'], concepts: ['concept1'], })); const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); // With CLI available and mock set, should return enriched data expect(result).not.toBeNull(); expect(typeof result!.subtitle).toBe('string'); expect(typeof result!.narrative).toBe('string'); expect(Array.isArray(result!.facts)).toBe(true); expect(Array.isArray(result!.concepts)).toBe(true); }); it('should auto-detect when env not set', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; resetAIEnrichmentCache(); _setCliAvailableForTesting(true); _setRunClaudePrintMockForTesting(() => JSON.stringify({ subtitle: 'Mock subtitle', narrative: 'Mock narrative text.', facts: ['Fact 1'], concepts: ['concept1'], })); const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); // With CLI available, mock set, and env not set, should still enrich expect(result).not.toBeNull(); expect(typeof result!.subtitle).toBe('string'); expect(typeof result!.narrative).toBe('string'); }); it('should handle AGENTKITS_AI_ENRICHMENT=1', async () => { process.env.AGENTKITS_AI_ENRICHMENT = '1'; resetAIEnrichmentCache(); _setCliAvailableForTesting(true); _setRunClaudePrintMockForTesting(() => JSON.stringify({ subtitle: 'Mock subtitle', narrative: 'Mock narrative text.', facts: ['Fact 1'], concepts: ['concept1'], })); const result = await enrichWithAI('Read', '{}', '{}'); // With CLI available and mock set, should return enriched data expect(result).not.toBeNull(); expect(typeof result!.subtitle).toBe('string'); }); }); describe('isAIEnrichmentEnabled (sync)', () => { it.each([ ['false', false], ['0', false], ['true', true], ['1', true], ])('should return %s when env=%s', (envValue, expected) => { process.env.AGENTKITS_AI_ENRICHMENT = envValue; expect(isAIEnrichmentEnabled()).toBe(expected); }); it('should return true when env not set (auto-detect optimistic)', () => { delete process.env.AGENTKITS_AI_ENRICHMENT; expect(isAIEnrichmentEnabled()).toBe(true); }); }); describe('isAIEnrichmentAvailable', () => { it('should return false when env disabled', async () => { process.env.AGENTKITS_AI_ENRICHMENT = 'false'; const available = await isAIEnrichmentAvailable(); expect(available).toBe(false); }); it('should return boolean when auto-detecting', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; const available = await isAIEnrichmentAvailable(); expect(typeof available).toBe('boolean'); }); it('should return true when CLI available mock is set', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setCliAvailableForTesting(true); const available = await isAIEnrichmentAvailable(); expect(available).toBe(true); }); }); describe('resetAIEnrichmentCache', () => { it('should reset cached state', async () => { // First call with env=false should return null process.env.AGENTKITS_AI_ENRICHMENT = 'false'; _setCliAvailableForTesting(false); resetAIEnrichmentCache(); const disabledResult = await enrichWithAI('Read', '{}', '{}'); expect(disabledResult).toBeNull(); // Reset cache and enable CLI with mock resetAIEnrichmentCache(); _setCliAvailableForTesting(true); _setRunClaudePrintMockForTesting(() => JSON.stringify({ subtitle: 'Mock subtitle', narrative: 'Mock narrative text.', facts: ['Fact 1'], concepts: ['concept1'], })); // Now with auto-detect, CLI available, and mock set, should return enriched data delete process.env.AGENTKITS_AI_ENRICHMENT; const result = await enrichWithAI('Read', '{}', '{}'); expect(result).not.toBeNull(); expect(typeof result!.subtitle).toBe('string'); }); }); describe('buildExtractionPrompt', () => { it('should include tool name, input, and response', () => { const prompt = buildExtractionPrompt('Read', '{"file_path":"src/index.ts"}', 'file content here'); expect(prompt).toContain('Tool: Read'); expect(prompt).toContain('Input: {"file_path":"src/index.ts"}'); expect(prompt).toContain('Response: file content here'); }); it('should truncate long input to 2000 chars', () => { const longInput = 'x'.repeat(5000); const prompt = buildExtractionPrompt('Read', longInput, 'short'); expect(prompt).toContain('Input: ' + 'x'.repeat(2000)); expect(prompt).not.toContain('x'.repeat(2001)); }); it('should truncate long response to 2000 chars', () => { const longResponse = 'y'.repeat(5000); const prompt = buildExtractionPrompt('Read', 'short', longResponse); expect(prompt).toContain('Response: ' + 'y'.repeat(2000)); expect(prompt).not.toContain('y'.repeat(2001)); }); it('should include JSON structure instructions', () => { const prompt = buildExtractionPrompt('Bash', 'ls', 'output'); expect(prompt).toContain('"subtitle"'); expect(prompt).toContain('"narrative"'); expect(prompt).toContain('"facts"'); expect(prompt).toContain('"concepts"'); }); }); describe('parseAIResponse', () => { it('should parse valid JSON', () => { const json = JSON.stringify({ subtitle: 'Test subtitle', narrative: 'Test narrative sentence.', facts: ['Fact 1', 'Fact 2'], concepts: ['concept1', 'concept2'], }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.subtitle).toBe('Test subtitle'); expect(result!.narrative).toBe('Test narrative sentence.'); expect(result!.facts).toEqual(['Fact 1', 'Fact 2']); expect(result!.concepts).toEqual(['concept1', 'concept2']); }); it('should strip ```json code fences', () => { const json = '```json\n{"subtitle":"Test","narrative":"Test.","facts":["f"],"concepts":["c"]}\n```'; const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.subtitle).toBe('Test'); }); it('should strip ``` code fences without json tag', () => { const json = '```\n{"subtitle":"Test","narrative":"Test.","facts":["f"],"concepts":["c"]}\n```'; const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.subtitle).toBe('Test'); }); it('should handle whitespace around JSON', () => { const json = ' \n {"subtitle":"Test","narrative":"Test.","facts":[],"concepts":[]} \n '; const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.subtitle).toBe('Test'); }); it('should return null for invalid JSON', () => { const result = parseAIResponse('not json at all'); expect(result).toBeNull(); }); it('should return null for empty string', () => { const result = parseAIResponse(''); expect(result).toBeNull(); }); it.each([ ['subtitle', { subtitle: 123, narrative: 'Test.', facts: [], concepts: [] }], ['narrative', { subtitle: 'Test', narrative: null, facts: [], concepts: [] }], ['facts', { subtitle: 'Test', narrative: 'Test.', facts: 'not array', concepts: [] }], ['concepts', { subtitle: 'Test', narrative: 'Test.', facts: [], concepts: 'not array' }], ])('should return null when %s has invalid type', (_field, obj) => { const json = JSON.stringify(obj); const result = parseAIResponse(json); expect(result).toBeNull(); }); it.each([ ['subtitle', 200, { subtitle: 'A'.repeat(300), narrative: 'Test.', facts: [], concepts: [] }], ['narrative', 500, { subtitle: 'Test', narrative: 'B'.repeat(600), facts: [], concepts: [] }], ])('should truncate %s to %d chars', (field, maxLen, obj) => { const json = JSON.stringify(obj); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect((result as any)[field].length).toBe(maxLen); }); it.each([ ['facts', 5, { subtitle: 'Test', narrative: 'Test.', facts: ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7'], concepts: [] }], ['concepts', 8, { subtitle: 'Test', narrative: 'Test.', facts: [], concepts: ['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9', 'c10'] }], ])('should limit %s to %d items', (field, maxItems, obj) => { const json = JSON.stringify(obj); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect((result as any)[field].length).toBe(maxItems); }); it('should compute confidence score from AI-reported value', () => { const json = JSON.stringify({ subtitle: 'Examining auth module', narrative: 'Read the auth module for login flow.', facts: ['File has 200 lines'], concepts: ['authentication'], confidence: 0.92, }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.confidence).toBeCloseTo(0.92, 1); }); it('should default confidence to 0.5 when not provided', () => { const json = JSON.stringify({ subtitle: 'Examining auth module', narrative: 'Read the auth module for login flow.', facts: ['Fact 1'], concepts: ['auth'], }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.confidence).toBeCloseTo(0.5, 1); }); it('should penalize confidence for very short subtitle', () => { const json = JSON.stringify({ subtitle: 'Hi', narrative: 'Read the auth module for login flow.', facts: ['Fact 1'], concepts: ['auth'], confidence: 0.9, }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.confidence).toBeLessThan(0.9); }); it('should penalize confidence for empty facts', () => { const json = JSON.stringify({ subtitle: 'Examining auth module', narrative: 'Read the auth module for login flow.', facts: [], concepts: ['auth'], confidence: 0.9, }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.confidence).toBeLessThan(0.9); }); it('should clamp confidence to 0-1 range', () => { const json = JSON.stringify({ subtitle: 'Examining auth module', narrative: 'Read the auth module for login flow.', facts: ['Fact'], concepts: ['auth'], confidence: 1.5, }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.confidence).toBeLessThanOrEqual(1); expect(result!.confidence).toBeGreaterThanOrEqual(0); }); it('should truncate individual fact strings to 200 chars', () => { const json = JSON.stringify({ subtitle: 'Test', narrative: 'Test.', facts: ['C'.repeat(300)], concepts: [], }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.facts[0].length).toBe(200); }); it('should truncate individual concept strings to 50 chars', () => { const json = JSON.stringify({ subtitle: 'Test', narrative: 'Test.', facts: [], concepts: ['D'.repeat(100)], }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.concepts[0].length).toBe(50); }); it('should convert non-string fact values to strings', () => { const json = JSON.stringify({ subtitle: 'Test', narrative: 'Test.', facts: [42, true, null], concepts: [], }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.facts).toEqual(['42', 'true', 'null']); }); it('should convert non-string concept values to strings', () => { const json = JSON.stringify({ subtitle: 'Test', narrative: 'Test.', facts: [], concepts: [42, false], }); const result = parseAIResponse(json); expect(result).not.toBeNull(); expect(result!.concepts).toEqual(['42', 'false']); }); }); describe('enrichWithAI with mock CLI', () => { afterEach(() => { _setRunClaudePrintMockForTesting(null); }); it('should return enriched observation on success', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; 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', 'typescript'], }); _setRunClaudePrintMockForTesting(() => validResponse); const result = await enrichWithAI('Read', '{"file_path":"auth.ts"}', 'export class Auth {}'); expect(result).not.toBeNull(); expect(result!.subtitle).toBe('Examining auth module'); expect(result!.facts).toHaveLength(2); expect(result!.concepts).toContain('jwt'); }); it('should return null when CLI returns empty result', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => null); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should return null when CLI returns invalid JSON', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => 'not valid json'); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should return null when CLI returns incomplete structure', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => '{"subtitle":"test"}'); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should return null when mock returns empty string', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => ''); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should return null when mock throws', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => { throw new Error('CLI error'); }); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should work with AGENTKITS_AI_ENRICHMENT=true and mock', async () => { process.env.AGENTKITS_AI_ENRICHMENT = 'true'; const validResponse = JSON.stringify({ subtitle: 'Running tests', narrative: 'Executed test suite.', facts: ['5 tests passed'], concepts: ['testing'], }); _setRunClaudePrintMockForTesting(() => validResponse); const result = await enrichWithAI('Bash', 'npm test', '5 passed'); expect(result).not.toBeNull(); expect(result!.subtitle).toBe('Running tests'); }); it('should still return null when env=false even with mock set', async () => { process.env.AGENTKITS_AI_ENRICHMENT = 'false'; _setRunClaudePrintMockForTesting(() => '{"subtitle":"Test","narrative":"Test.","facts":[],"concepts":[]}'); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should parse markdown-fenced response from mock CLI', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; const fencedResponse = '```json\n{"subtitle":"Fenced","narrative":"Fenced response.","facts":["f1"],"concepts":["c1"]}\n```'; _setRunClaudePrintMockForTesting(() => fencedResponse); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).not.toBeNull(); expect(result!.subtitle).toBe('Fenced'); }); it('should pass prompt to mock', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; let capturedPrompt = ''; _setRunClaudePrintMockForTesting((prompt) => { capturedPrompt = prompt; return JSON.stringify({ subtitle: 'Test', narrative: 'Test.', facts: [], concepts: [], }); }); await enrichWithAI('Read', '{"file_path":"test.ts"}', 'content'); expect(capturedPrompt).toContain('Tool: Read'); expect(capturedPrompt).toContain('test.ts'); }); }); describe('parseSummaryResponse', () => { it('should parse valid summary JSON', () => { const json = JSON.stringify({ completed: 'Fixed a bug in the parser.', nextSteps: 'Run integration tests.', }); const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.completed).toBe('Fixed a bug in the parser.'); expect(result!.nextSteps).toBe('Run integration tests.'); }); it('should accept nextSteps as array', () => { const json = JSON.stringify({ completed: 'Fixed a bug.', nextSteps: ['Run tests', 'Deploy to staging'], }); const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.nextSteps).toBe('Run tests; Deploy to staging'); }); it('should default nextSteps to None when missing', () => { const json = JSON.stringify({ completed: 'All done.', }); const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.nextSteps).toBe('None'); }); it('should return null when completed is not a string', () => { const json = JSON.stringify({ completed: 123, nextSteps: 'Test', }); const result = parseSummaryResponse(json); expect(result).toBeNull(); }); it('should truncate completed to 1000 chars', () => { const json = JSON.stringify({ completed: 'A'.repeat(1500), nextSteps: 'Test', }); const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.completed.length).toBe(1000); }); it('should truncate nextSteps to 500 chars', () => { const json = JSON.stringify({ completed: 'Done.', nextSteps: 'B'.repeat(600), }); const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.nextSteps.length).toBe(500); }); it('should strip markdown fences', () => { const json = '```json\n{"completed":"Done.","nextSteps":"None"}\n```'; const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.completed).toBe('Done.'); }); it('should strip plain ``` markdown fences (without json suffix)', () => { const json = '```\n{"completed":"Done.","nextSteps":"None"}\n```'; const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.completed).toBe('Done.'); }); it('should return null for invalid JSON', () => { expect(parseSummaryResponse('not json')).toBeNull(); }); it('should parse decisions array', () => { const json = JSON.stringify({ completed: 'Fixed the bug.', nextSteps: 'None', decisions: ['Used mutex for thread safety', 'Chose retry pattern over circuit breaker'], }); const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.decisions).toHaveLength(2); expect(result!.decisions[0]).toBe('Used mutex for thread safety'); }); it('should default to empty decisions when not provided', () => { const json = JSON.stringify({ completed: 'Done.', nextSteps: 'None', }); const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.decisions).toEqual([]); }); it('should cap decisions at 5 items', () => { const json = JSON.stringify({ completed: 'Done.', nextSteps: 'None', decisions: Array.from({ length: 10 }, (_, i) => `Decision ${i}`), }); const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.decisions).toHaveLength(5); }); it('should filter out non-string decisions', () => { const json = JSON.stringify({ completed: 'Done.', nextSteps: 'None', decisions: ['Valid', 123, null, '', 'Also valid'], }); const result = parseSummaryResponse(json); expect(result).not.toBeNull(); expect(result!.decisions).toEqual(['Valid', 'Also valid']); }); }); describe('buildSummaryPrompt', () => { it('should include template summary and assistant message', () => { const prompt = buildSummaryPrompt('Request: Fix bug', 'I fixed the bug.'); expect(prompt).toContain('Template Summary'); expect(prompt).toContain('Request: Fix bug'); expect(prompt).toContain('Last Assistant Message'); expect(prompt).toContain('I fixed the bug.'); }); it('should truncate long inputs', () => { const longTemplate = 'T'.repeat(5000); const longMessage = 'M'.repeat(5000); const prompt = buildSummaryPrompt(longTemplate, longMessage); // Should contain truncated versions (3000 chars each) expect(prompt.length).toBeLessThan(10000); }); it('should ask for decisions in prompt', () => { const prompt = buildSummaryPrompt('Request: Fix auth', 'Fixed the auth flow.'); expect(prompt).toContain('decisions'); expect(prompt).toContain('WHY'); }); }); describe('enrichSummaryWithAI with mock CLI', () => { afterEach(() => { _setRunClaudePrintMockForTesting(null); }); it('should return enriched summary on success', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; const validResponse = JSON.stringify({ completed: 'Fixed the parser bug and verified with tests.', nextSteps: 'None', }); _setRunClaudePrintMockForTesting(() => validResponse); const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed the parser.'); expect(result).not.toBeNull(); expect(result!.completed).toBe('Fixed the parser bug and verified with tests.'); expect(result!.nextSteps).toBe('None'); }); it('should return null when CLI unavailable', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setCliAvailableForTesting(false); const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); expect(result).toBeNull(); }); it('should return null when CLI returns invalid response', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => 'not json'); const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); expect(result).toBeNull(); }); it('should return null when runClaudePrint throws (catch block)', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => { throw new Error('Unexpected CLI error'); }); const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); expect(result).toBeNull(); }); it('should return null when runClaudePrint returns null', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => null as unknown as string); const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); expect(result).toBeNull(); }); }); describe('buildCompressionPrompt', () => { it('should include tool name and input/response', () => { const prompt = buildCompressionPrompt('Read', '{"file_path":"test.ts"}', 'file content'); expect(prompt).toContain('Tool: Read'); expect(prompt).toContain('Input: {"file_path":"test.ts"}'); expect(prompt).toContain('Response: file content'); expect(prompt).toContain('compressed_summary'); }); it('should include context hints when provided', () => { const prompt = buildCompressionPrompt('Read', '{}', '{}', 'Examining config', 'Read config file.'); expect(prompt).toContain('Context: Examining config | Read config file.'); }); it('should omit context line when no hints', () => { const prompt = buildCompressionPrompt('Read', '{}', '{}'); expect(prompt).not.toContain('Context:'); }); it('should truncate long input to 1000 chars', () => { const longInput = 'x'.repeat(3000); const prompt = buildCompressionPrompt('Read', longInput, 'short'); expect(prompt).toContain('x'.repeat(1000)); expect(prompt).not.toContain('x'.repeat(1001)); }); it('should truncate long response to 1000 chars', () => { const longResponse = 'y'.repeat(3000); const prompt = buildCompressionPrompt('Read', 'short', longResponse); expect(prompt).toContain('y'.repeat(1000)); expect(prompt).not.toContain('y'.repeat(1001)); }); }); describe('parseCompressionResponse', () => { it('should parse valid compression JSON', () => { const json = JSON.stringify({ compressed_summary: 'Read auth.ts to check login flow' }); const result = parseCompressionResponse(json); expect(result).not.toBeNull(); expect(result!.compressed_summary).toBe('Read auth.ts to check login flow'); }); it('should strip markdown fences', () => { const json = '```json\n{"compressed_summary":"Test summary"}\n```'; const result = parseCompressionResponse(json); expect(result).not.toBeNull(); expect(result!.compressed_summary).toBe('Test summary'); }); it('should strip plain ``` fences', () => { const json = '```\n{"compressed_summary":"Test summary"}\n```'; const result = parseCompressionResponse(json); expect(result).not.toBeNull(); expect(result!.compressed_summary).toBe('Test summary'); }); it('should return null for invalid JSON', () => { expect(parseCompressionResponse('not json')).toBeNull(); }); it('should return null when compressed_summary is missing', () => { const json = JSON.stringify({ other: 'field' }); expect(parseCompressionResponse(json)).toBeNull(); }); it('should return null when compressed_summary is empty', () => { const json = JSON.stringify({ compressed_summary: '' }); expect(parseCompressionResponse(json)).toBeNull(); }); it('should return null when compressed_summary is not a string', () => { const json = JSON.stringify({ compressed_summary: 123 }); expect(parseCompressionResponse(json)).toBeNull(); }); it('should truncate to 200 chars', () => { const json = JSON.stringify({ compressed_summary: 'A'.repeat(300) }); const result = parseCompressionResponse(json); expect(result).not.toBeNull(); expect(result!.compressed_summary.length).toBe(200); }); }); describe('compressObservationWithAI with mock CLI', () => { afterEach(() => { _setRunClaudePrintMockForTesting(null); }); it('should return compressed observation on success', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => JSON.stringify({ compressed_summary: 'Read auth module for login flow' }) ); const result = await compressObservationWithAI('Read', '{"file_path":"auth.ts"}', 'export class Auth {}', 'Examining auth', 'Read auth module.'); expect(result).not.toBeNull(); expect(result!.compressed_summary).toBe('Read auth module for login flow'); }); it('should return null when CLI unavailable', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setCliAvailableForTesting(false); const result = await compressObservationWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should return null when CLI returns invalid response', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => 'not json'); const result = await compressObservationWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should return null when CLI throws', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => { throw new Error('Error'); }); const result = await compressObservationWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should return null when env=false', async () => { process.env.AGENTKITS_AI_ENRICHMENT = 'false'; _setRunClaudePrintMockForTesting(() => JSON.stringify({ compressed_summary: 'Should not reach here' }) ); const result = await compressObservationWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); }); describe('buildSessionDigestPrompt', () => { it('should include request, observations, and completion', () => { const prompt = buildSessionDigestPrompt( 'Fix auth bug', ['Read auth.ts', 'Edited login handler', 'Ran tests'], 'Fixed authentication', ['src/auth.ts'] ); expect(prompt).toContain('Request: Fix auth bug'); expect(prompt).toContain('Read auth.ts'); expect(prompt).toContain('Edited login handler'); expect(prompt).toContain('Completed: Fixed authentication'); expect(prompt).toContain('Files modified: src/auth.ts'); expect(prompt).toContain('"digest"'); }); it('should omit files line when no files modified', () => { const prompt = buildSessionDigestPrompt('Test', ['obs1'], 'Done', []); expect(prompt).not.toContain('Files modified:'); }); it('should limit observation summaries to 30', () => { const obs = Array.from({ length: 50 }, (_, i) => `Obs ${i}`); const prompt = buildSessionDigestPrompt('Test', obs, 'Done', []); // Should contain obs 0-29 but not 30+ expect(prompt).toContain('Obs 29'); expect(prompt).not.toContain('Obs 30'); }); }); describe('parseSessionDigestResponse', () => { it('should parse valid digest JSON', () => { const json = JSON.stringify({ digest: 'Session fixed auth bug in 3 files.' }); const result = parseSessionDigestResponse(json); expect(result).not.toBeNull(); expect(result!.digest).toBe('Session fixed auth bug in 3 files.'); }); it('should strip markdown fences', () => { const json = '```json\n{"digest":"Test digest"}\n```'; const result = parseSessionDigestResponse(json); expect(result).not.toBeNull(); expect(result!.digest).toBe('Test digest'); }); it('should return null for invalid JSON', () => { expect(parseSessionDigestResponse('not json')).toBeNull(); }); it('should return null when digest is missing', () => { expect(parseSessionDigestResponse(JSON.stringify({ other: 'x' }))).toBeNull(); }); it('should return null when digest is empty', () => { expect(parseSessionDigestResponse(JSON.stringify({ digest: '' }))).toBeNull(); }); it('should return null when digest is not a string', () => { expect(parseSessionDigestResponse(JSON.stringify({ digest: 42 }))).toBeNull(); }); it('should truncate to 600 chars', () => { const json = JSON.stringify({ digest: 'D'.repeat(800) }); const result = parseSessionDigestResponse(json); expect(result).not.toBeNull(); expect(result!.digest.length).toBe(600); }); }); describe('generateSessionDigestWithAI with mock CLI', () => { afterEach(() => { _setRunClaudePrintMockForTesting(null); }); it('should return digest on success', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => JSON.stringify({ digest: 'Fixed auth bug by patching JWT validation.' }) ); const result = await generateSessionDigestWithAI( 'Fix auth', ['Read auth.ts', 'Edit auth.ts'], 'Fixed JWT', ['auth.ts'] ); expect(result).not.toBeNull(); expect(result!.digest).toBe('Fixed auth bug by patching JWT validation.'); }); it('should return null when CLI unavailable', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setCliAvailableForTesting(false); const result = await generateSessionDigestWithAI('Test', [], 'Done', []); expect(result).toBeNull(); }); it('should return null when CLI returns null', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setRunClaudePrintMockForTesting(() => null as unknown as string); const result = await generateSessionDigestWithAI('Test', [], 'Done', []); expect(result).toBeNull(); }); }); describe('error handling', () => { it('should return null when CLI is not available with invalid input', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setCliAvailableForTesting(false); resetAIEnrichmentCache(); // Should gracefully handle any input without throwing const result = await enrichWithAI('InvalidTool', 'not json', 'not json'); expect(result).toBeNull(); }); it('should not throw on enrichment failure when CLI is available', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; _setCliAvailableForTesting(true); resetAIEnrichmentCache(); // Should gracefully handle invalid input without throwing const result = await enrichWithAI('InvalidTool', 'not json', 'not json'); // Result must be either null (graceful failure) or a valid enriched object // This is a strict assertion - exactly one of these conditions must be true const isNull = result === null; const isValidObject = result !== null && typeof result.subtitle === 'string' && typeof result.narrative === 'string'; expect(isNull || isValidObject).toBe(true); }); it('should respect timeout (returns null on slow response)', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; const start = Date.now(); const result = await enrichWithAI('Read', '{}', '{}', 100); const elapsed = Date.now() - start; expect(result).toBeNull(); expect(elapsed).toBeLessThan(5000); }); }); // ===== Provider Config ===== describe('setAIProviderConfig', () => { it('should accept provider config without error', () => { expect(() => setAIProviderConfig({ provider: 'openai', apiKey: 'test' })).not.toThrow(); }); it('should accept undefined to reset config', () => { setAIProviderConfig({ provider: 'gemini', apiKey: 'key' }); expect(() => setAIProviderConfig(undefined)).not.toThrow(); }); it('should force re-resolution so next enrichment uses new provider', async () => { // Set to openai with no API key → provider unavailable → enrichment returns null setAIProviderConfig({ provider: 'openai' }); delete process.env.AGENTKITS_AI_ENRICHMENT; const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); it('should not affect mock-based testing', async () => { setAIProviderConfig({ provider: 'openai', apiKey: 'test' }); _setRunClaudePrintMockForTesting(() => JSON.stringify({ subtitle: 'Test subtitle', narrative: 'Test narrative about something.', facts: ['Fact 1'], concepts: ['concept1'], confidence: 0.9, })); delete process.env.AGENTKITS_AI_ENRICHMENT; const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', 'content'); expect(result).not.toBeNull(); expect(result!.subtitle).toBe('Test subtitle'); }); it('should be cleared by resetAIEnrichmentCache', () => { setAIProviderConfig({ provider: 'gemini', apiKey: 'key' }); resetAIEnrichmentCache(); // After reset, should use default provider (claude-cli) // No error should occur expect(() => enrichWithAI('Read', '{}', '{}')).not.toThrow(); }); }); });