/** * Tests for Heartbeat Engine + Reflection Engine (Phase 3) */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { HeartbeatEngine, resetHeartbeatEngines, type HeartbeatAlert, } from '../../desktop/src/lib/heartbeat-engine'; import { ReflectionEngine, resetReflectionEngine, } from '../../desktop/src/lib/reflection-engine'; import { MemoryManager, resetMemoryManager } from '../../desktop/src/lib/agent-memory'; import { resetAgentIdentityManager } from '../../desktop/src/lib/agent-identity'; import { resetMemoryExtractor } from '../../desktop/src/lib/memory-extractor'; // === Mock localStorage === const localStorageMock = (() => { let store: Record = {}; return { getItem: (key: string) => store[key] ?? null, setItem: (key: string, value: string) => { store[key] = value; }, removeItem: (key: string) => { delete store[key]; }, clear: () => { store = {}; }, }; })(); vi.stubGlobal('localStorage', localStorageMock); // ============================================= // HeartbeatEngine Tests // ============================================= describe('HeartbeatEngine', () => { let engine: HeartbeatEngine; beforeEach(() => { localStorageMock.clear(); resetHeartbeatEngines(); resetMemoryManager(); resetAgentIdentityManager(); // Disable quiet hours to avoid test-time sensitivity engine = new HeartbeatEngine('agent-1', { quietHoursStart: undefined, quietHoursEnd: undefined }); }); afterEach(() => { engine.stop(); }); describe('lifecycle', () => { it('starts and stops cleanly', () => { const eng = new HeartbeatEngine('test', { enabled: true, intervalMinutes: 1 }); eng.start(); expect(eng.isRunning()).toBe(true); eng.stop(); expect(eng.isRunning()).toBe(false); }); it('does not start when disabled', () => { const eng = new HeartbeatEngine('test', { enabled: false }); eng.start(); expect(eng.isRunning()).toBe(false); }); }); describe('tick', () => { it('returns ok status when no alerts', async () => { const result = await engine.tick(); expect(result.status).toBe('ok'); expect(result.checkedItems).toBeGreaterThan(0); expect(result.timestamp).toBeTruthy(); }); it('detects pending tasks', async () => { const mgr = new MemoryManager(); // Create high-importance task memories await mgr.save({ agentId: 'agent-1', content: '完成API集成', type: 'task', importance: 8, source: 'auto', tags: [] }); await mgr.save({ agentId: 'agent-1', content: '修复登录bug', type: 'task', importance: 7, source: 'auto', tags: [] }); const result = await engine.tick(); // With 'light' proactivity, only high urgency alerts pass through // The task check produces medium/high urgency const taskAlerts = result.alerts.filter(a => a.source === 'pending-tasks'); // May or may not produce alert depending on proactivity filter expect(result.checkedItems).toBeGreaterThan(0); }); it('detects memory health issues', async () => { const mgr = new MemoryManager(); // Create many memories to trigger health alert for (let i = 0; i < 510; i++) { await mgr.save({ agentId: 'agent-1', content: `memory entry ${i} with unique content ${Math.random()}`, type: 'fact', importance: 3, source: 'auto', tags: [`batch${i}`], }); } // Use autonomous proactivity to see all alerts const eng = new HeartbeatEngine('agent-1', { proactivityLevel: 'autonomous', quietHoursStart: undefined, quietHoursEnd: undefined }); const result = await eng.tick(); const healthAlerts = result.alerts.filter(a => a.source === 'memory-health'); expect(healthAlerts.length).toBe(1); expect(healthAlerts[0].content).toMatch(/\d{3,}/); }); it('stores tick results in history', async () => { await engine.tick(); await engine.tick(); const history = engine.getHistory(); expect(history.length).toBe(2); }); }); describe('quiet hours', () => { it('returns ok with 0 checks during quiet hours', async () => { // Set quiet hours to cover current time const now = new Date(); const startHour = now.getHours(); const endHour = (startHour + 2) % 24; const eng = new HeartbeatEngine('test', { quietHoursStart: `${String(startHour).padStart(2, '0')}:00`, quietHoursEnd: `${String(endHour).padStart(2, '0')}:00`, }); const result = await eng.tick(); expect(result.checkedItems).toBe(0); expect(result.status).toBe('ok'); }); it('isQuietHours handles cross-midnight range', () => { const eng = new HeartbeatEngine('test', { quietHoursStart: '22:00', quietHoursEnd: '08:00', }); // The method checks against current time, so we just verify it doesn't throw const result = eng.isQuietHours(); expect(typeof result).toBe('boolean'); }); }); describe('custom checks', () => { it('runs registered custom checks', async () => { const eng = new HeartbeatEngine('test', { proactivityLevel: 'autonomous', quietHoursStart: undefined, quietHoursEnd: undefined }); eng.registerCheck(async () => ({ title: 'Custom Alert', content: 'Custom check triggered', urgency: 'medium' as const, source: 'custom', timestamp: new Date().toISOString(), })); const result = await eng.tick(); const custom = result.alerts.filter(a => a.source === 'custom'); expect(custom.length).toBe(1); expect(custom[0].title).toBe('Custom Alert'); }); }); describe('proactivity filtering', () => { it('silent mode suppresses all alerts', async () => { const eng = new HeartbeatEngine('test', { proactivityLevel: 'silent', quietHoursStart: undefined, quietHoursEnd: undefined }); eng.registerCheck(async () => ({ title: 'Test', content: 'Test', urgency: 'high' as const, source: 'test', timestamp: new Date().toISOString(), })); const result = await eng.tick(); expect(result.alerts).toHaveLength(0); }); it('light mode only shows high urgency', async () => { const eng = new HeartbeatEngine('test', { proactivityLevel: 'light', quietHoursStart: undefined, quietHoursEnd: undefined }); eng.registerCheck(async () => ({ title: 'Low', content: 'Low urgency', urgency: 'low' as const, source: 'test-low', timestamp: new Date().toISOString(), })); eng.registerCheck(async () => ({ title: 'High', content: 'High urgency', urgency: 'high' as const, source: 'test-high', timestamp: new Date().toISOString(), })); const result = await eng.tick(); expect(result.alerts.every(a => a.urgency === 'high')).toBe(true); }); }); describe('config', () => { it('returns current config', () => { const config = engine.getConfig(); expect(config.intervalMinutes).toBe(30); expect(config.enabled).toBe(false); }); it('updates config', () => { engine.updateConfig({ intervalMinutes: 15 }); expect(engine.getConfig().intervalMinutes).toBe(15); }); }); describe('alert callback', () => { it('calls onAlert when alerts are produced', async () => { const alerts: HeartbeatAlert[][] = []; const eng = new HeartbeatEngine('test', { enabled: true, proactivityLevel: 'autonomous', quietHoursStart: undefined, quietHoursEnd: undefined }); eng.registerCheck(async () => ({ title: 'Alert', content: 'Test alert', urgency: 'high' as const, source: 'test', timestamp: new Date().toISOString(), })); eng.start((a) => alerts.push(a)); // Manually trigger tick instead of waiting for interval await eng.tick(); eng.stop(); // The tick() call should have triggered onAlert // Note: the start() interval won't fire in test, but manual tick() does call onAlert }); }); }); // ============================================= // ReflectionEngine Tests // ============================================= describe('ReflectionEngine', () => { let engine: ReflectionEngine; beforeEach(() => { localStorageMock.clear(); resetReflectionEngine(); resetMemoryManager(); resetAgentIdentityManager(); resetMemoryExtractor(); engine = new ReflectionEngine(); }); describe('trigger management', () => { it('should not reflect initially', () => { expect(engine.shouldReflect()).toBe(false); }); it('triggers after N conversations', () => { const eng = new ReflectionEngine({ triggerAfterConversations: 3 }); eng.recordConversation(); eng.recordConversation(); expect(eng.shouldReflect()).toBe(false); eng.recordConversation(); expect(eng.shouldReflect()).toBe(true); }); it('resets counter after reflection', async () => { const eng = new ReflectionEngine({ triggerAfterConversations: 2 }); eng.recordConversation(); eng.recordConversation(); expect(eng.shouldReflect()).toBe(true); await eng.reflect('agent-1'); expect(eng.shouldReflect()).toBe(false); expect(eng.getState().conversationsSinceReflection).toBe(0); }); }); describe('reflect', () => { it('returns result with patterns and improvements', async () => { const result = await engine.reflect('agent-1'); expect(result.timestamp).toBeTruthy(); expect(Array.isArray(result.patterns)).toBe(true); expect(Array.isArray(result.improvements)).toBe(true); expect(typeof result.newMemories).toBe('number'); }); it('detects task accumulation pattern', async () => { const mgr = new MemoryManager(); for (let i = 0; i < 6; i++) { await mgr.save({ agentId: 'agent-1', content: `Task ${i}: do something ${Math.random()}`, type: 'task', importance: 6, source: 'auto', tags: [], }); } const result = await engine.reflect('agent-1'); const taskPattern = result.patterns.find(p => p.observation.includes('待办任务')); expect(taskPattern).toBeTruthy(); expect(taskPattern!.sentiment).toBe('negative'); }); it('detects strong preference accumulation as positive', async () => { const mgr = new MemoryManager(); for (let i = 0; i < 6; i++) { await mgr.save({ agentId: 'agent-1', content: `Preference ${i}: likes ${Math.random()}`, type: 'preference', importance: 5, source: 'auto', tags: [], }); } const result = await engine.reflect('agent-1'); const prefPattern = result.patterns.find(p => p.observation.includes('用户偏好')); expect(prefPattern).toBeTruthy(); expect(prefPattern!.sentiment).toBe('positive'); }); it('generates improvement suggestions for low preference count', async () => { // No preferences saved → should suggest enrichment const result = await engine.reflect('agent-1'); const userImprovement = result.improvements.find(i => i.area === '用户理解'); expect(userImprovement).toBeTruthy(); }); it('saves reflection memories', async () => { const mgr = new MemoryManager(); // Create enough data for patterns to be detected for (let i = 0; i < 6; i++) { await mgr.save({ agentId: 'agent-1', content: `Task ${i}: important work item ${Math.random()}`, type: 'task', importance: 7, source: 'auto', tags: [], }); } const result = await engine.reflect('agent-1'); expect(result.newMemories).toBeGreaterThan(0); // Verify reflection memories were saved (reload from localStorage since reflect uses singleton) const mgr2 = new MemoryManager(); const allMemories = await mgr2.getAll('agent-1'); const reflectionMemories = allMemories.filter(m => m.tags.includes('reflection')); expect(reflectionMemories.length).toBeGreaterThan(0); }); it('stores result in history', async () => { await engine.reflect('agent-1'); await engine.reflect('agent-1'); const history = engine.getHistory(); expect(history.length).toBe(2); }); }); describe('identity proposals', () => { it('proposes changes when allowSoulModification is true', async () => { const eng = new ReflectionEngine({ allowSoulModification: true }); const mgr = new MemoryManager(); // Create multiple negative patterns for (let i = 0; i < 6; i++) { await mgr.save({ agentId: 'agent-1', content: `Overdue task ${i}: ${Math.random()}`, type: 'task', importance: 7, source: 'auto', tags: [], }); } const result = await eng.reflect('agent-1'); // May or may not produce identity proposals depending on pattern analysis expect(Array.isArray(result.identityProposals)).toBe(true); }); it('does not propose changes when allowSoulModification is false', async () => { const eng = new ReflectionEngine({ allowSoulModification: false }); const result = await eng.reflect('agent-1'); expect(result.identityProposals).toHaveLength(0); }); }); describe('config', () => { it('returns current config', () => { const config = engine.getConfig(); expect(config.triggerAfterConversations).toBe(5); expect(config.requireApproval).toBe(true); }); it('updates config', () => { engine.updateConfig({ triggerAfterConversations: 10 }); expect(engine.getConfig().triggerAfterConversations).toBe(10); }); }); describe('persistence', () => { it('persists state across instances', () => { engine.recordConversation(); engine.recordConversation(); resetReflectionEngine(); const eng2 = new ReflectionEngine(); expect(eng2.getState().conversationsSinceReflection).toBe(2); }); }); });