/** * Session Persistence Tests - Phase 4.3 * * Tests for automatic session data persistence: * - Session lifecycle (start/add/end) * - Auto-save functionality * - Memory extraction * - Session compaction * - Crash recovery */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SessionPersistenceService, getSessionPersistence, resetSessionPersistence, startSession, addSessionMessage, endCurrentSession, getCurrentSession, DEFAULT_SESSION_CONFIG, type SessionState, type PersistenceResult, } from '../../desktop/src/lib/session-persistence'; // === Mock Dependencies === const mockVikingClient = { isAvailable: vi.fn(async () => true), addResource: vi.fn(async () => ({ uri: 'test-uri', status: 'ok' })), removeResource: vi.fn(async () => undefined), compactSession: vi.fn(async () => '[会话摘要]\n讨论主题: 代码优化\n关键决策: 使用缓存策略'), extractMemories: vi.fn(async () => ({ memories: [ { content: '用户偏好简洁的回答', type: 'preference', importance: 7 }, ], summary: 'Extracted 1 memory', tokensSaved: 100, })), }; vi.mock('../../desktop/src/lib/viking-client', () => ({ getVikingClient: vi.fn(() => mockVikingClient), resetVikingClient: vi.fn(), VikingHttpClient: vi.fn(), })); const mockMemoryExtractor = { extractFromConversation: vi.fn(async () => ({ items: [{ content: 'Test memory', type: 'fact', importance: 5, tags: [] }], saved: 1, skipped: 0, userProfileUpdated: false, })), }; vi.mock('../../desktop/src/lib/memory-extractor', () => ({ getMemoryExtractor: vi.fn(() => mockMemoryExtractor), resetMemoryExtractor: vi.fn(), })); const mockAutonomyManager = { evaluate: vi.fn(() => ({ action: 'memory_save', allowed: true, requiresApproval: false, reason: 'Auto-approved', riskLevel: 'low', importance: 5, timestamp: new Date().toISOString(), })), }; vi.mock('../../desktop/src/lib/autonomy-manager', () => ({ canAutoExecute: vi.fn(() => ({ canProceed: true, decision: mockAutonomyManager.evaluate() })), executeWithAutonomy: vi.fn(async (_action: string, _importance: number, executor: () => unknown) => { const result = await executor(); return { executed: true, result }; }), getAutonomyManager: vi.fn(() => mockAutonomyManager), })); // === Session Persistence Tests === describe('SessionPersistenceService', () => { let service: SessionPersistenceService; beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); resetSessionPersistence(); service = new SessionPersistenceService(); }); afterEach(() => { service.stopAutoSave(); resetSessionPersistence(); }); describe('Initialization', () => { it('should initialize with default config', () => { const config = service.getConfig(); expect(config.enabled).toBe(true); expect(config.autoSaveIntervalMs).toBe(60000); expect(config.maxMessagesBeforeCompact).toBe(100); expect(config.extractMemoriesOnEnd).toBe(true); }); it('should accept custom config', () => { const customService = new SessionPersistenceService({ autoSaveIntervalMs: 30000, maxMessagesBeforeCompact: 50, }); const config = customService.getConfig(); expect(config.autoSaveIntervalMs).toBe(30000); expect(config.maxMessagesBeforeCompact).toBe(50); }); it('should update config', () => { service.updateConfig({ autoSaveIntervalMs: 120000 }); const config = service.getConfig(); expect(config.autoSaveIntervalMs).toBe(120000); }); }); describe('Session Lifecycle', () => { it('should start a new session', () => { const session = service.startSession('agent1', { model: 'gpt-4' }); expect(session.id).toBeDefined(); expect(session.agentId).toBe('agent1'); expect(session.status).toBe('active'); expect(session.messageCount).toBe(0); expect(session.metadata.model).toBe('gpt-4'); }); it('should end previous session when starting new one', () => { service.startSession('agent1'); const session2 = service.startSession('agent2'); expect(session2.agentId).toBe('agent2'); }); it('should add messages to session', () => { service.startSession('agent1'); const msg1 = service.addMessage({ role: 'user', content: 'Hello' }); const msg2 = service.addMessage({ role: 'assistant', content: 'Hi there!' }); expect(msg1).not.toBeNull(); expect(msg2).not.toBeNull(); expect(msg1?.role).toBe('user'); expect(msg2?.role).toBe('assistant'); const current = service.getCurrentSession(); expect(current?.messageCount).toBe(2); }); it('should return null when adding message without session', () => { const msg = service.addMessage({ role: 'user', content: 'Hello' }); expect(msg).toBeNull(); }); it('should end session and return result', async () => { service.startSession('agent1'); service.addMessage({ role: 'user', content: 'Hello' }); service.addMessage({ role: 'assistant', content: 'Hi!' }); const result = await service.endSession(); expect(result.saved).toBe(true); expect(result.messageCount).toBe(2); expect(service.getCurrentSession()).toBeNull(); }); it('should return empty result when no session', async () => { const result = await service.endSession(); expect(result.saved).toBe(false); expect(result.error).toBe('No active session'); }); }); describe('Session Compaction', () => { it('should trigger compaction when threshold reached', async () => { const customService = new SessionPersistenceService({ maxMessagesBeforeCompact: 5, }); customService.startSession('agent1'); // Add more messages than threshold for (let i = 0; i < 7; i++) { customService.addMessage({ role: 'user', content: `Message ${i}` }); customService.addMessage({ role: 'assistant', content: `Response ${i}` }); } // Wait for async compaction to complete await new Promise(resolve => setTimeout(resolve, 100)); // Compaction should have been triggered // Since compaction is async and creates a summary, we verify it was attempted const session = customService.getCurrentSession(); // Compaction may or may not complete in time, but session should still be valid expect(session).not.toBeNull(); expect(session!.messages.length).toBeGreaterThan(0); customService.stopAutoSave(); }); }); describe('Memory Extraction', () => { it('should extract memories on session end', async () => { service.startSession('agent1'); // Add enough messages for extraction for (let i = 0; i < 5; i++) { service.addMessage({ role: 'user', content: `User message ${i}` }); service.addMessage({ role: 'assistant', content: `Assistant response ${i}` }); } const result = await service.endSession(); expect(result.extractedMemories).toBeGreaterThanOrEqual(0); }); it('should skip extraction for short sessions', async () => { service.startSession('agent1'); service.addMessage({ role: 'user', content: 'Hi' }); const result = await service.endSession(); // Should not extract memories for sessions with < 4 messages expect(mockMemoryExtractor.extractFromConversation).not.toHaveBeenCalled(); }); }); describe('Session History', () => { it('should track session history', async () => { service.startSession('agent1'); service.addMessage({ role: 'user', content: 'Hello' }); await service.endSession(); const history = service.getSessionHistory(); expect(history.length).toBe(1); expect(history[0].agentId).toBe('agent1'); }); it('should limit history size', async () => { const customService = new SessionPersistenceService({ maxSessionHistory: 3, }); // Create 5 sessions for (let i = 0; i < 5; i++) { customService.startSession(`agent${i}`); customService.addMessage({ role: 'user', content: 'Test' }); await customService.endSession(); } const history = customService.getSessionHistory(); expect(history.length).toBe(3); }); it('should delete session from history', async () => { service.startSession('agent1'); service.addMessage({ role: 'user', content: 'Test' }); const result = await service.endSession(); const deleted = service.deleteSession(result.sessionId); expect(deleted).toBe(true); const history = service.getSessionHistory(); expect(history.length).toBe(0); }); }); describe('Crash Recovery', () => { it('should recover from crash', () => { // Start a session const session = service.startSession('agent1'); service.addMessage({ role: 'user', content: 'Before crash' }); // Simulate crash by not ending session const savedSession = service.getCurrentSession(); expect(savedSession).not.toBeNull(); // Reset service (simulates restart) resetSessionPersistence(); service = new SessionPersistenceService(); // Recover const recovered = service.recoverFromCrash(); expect(recovered).not.toBeNull(); expect(recovered?.agentId).toBe('agent1'); expect(recovered?.status).toBe('active'); }); it('should not recover timed-out sessions', async () => { const customService = new SessionPersistenceService({ sessionTimeoutMs: 1000, // 1 second }); customService.startSession('agent1'); customService.addMessage({ role: 'user', content: 'Test' }); // Manually set lastActivityAt to past and save to localStorage const session = customService.getCurrentSession(); if (session) { session.lastActivityAt = new Date(Date.now() - 5000).toISOString(); // Force save to localStorage so recovery can find it localStorage.setItem('zclaw-current-session', JSON.stringify(session)); } // Stop auto-save to prevent overwriting customService.stopAutoSave(); // Reset and try to recover resetSessionPersistence(); const newService = new SessionPersistenceService({ sessionTimeoutMs: 1000 }); const recovered = newService.recoverFromCrash(); expect(recovered).toBeNull(); }); }); describe('Availability', () => { it('should check availability', async () => { const available = await service.isAvailable(); expect(available).toBe(true); }); it('should return false when disabled', async () => { service.updateConfig({ enabled: false }); const available = await service.isAvailable(); expect(available).toBe(false); }); }); }); // === Helper Function Tests === describe('Helper Functions', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); resetSessionPersistence(); }); afterEach(() => { resetSessionPersistence(); }); it('should start session via helper', () => { const session = startSession('agent1'); expect(session.agentId).toBe('agent1'); }); it('should add message via helper', () => { startSession('agent1'); const msg = addSessionMessage({ role: 'user', content: 'Test' }); expect(msg?.content).toBe('Test'); }); it('should end session via helper', async () => { startSession('agent1'); addSessionMessage({ role: 'user', content: 'Test' }); const result = await endCurrentSession(); expect(result.saved).toBe(true); }); it('should get current session via helper', () => { startSession('agent1'); const session = getCurrentSession(); expect(session?.agentId).toBe('agent1'); }); }); // === Integration Tests === describe('Session Persistence Integration', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); resetSessionPersistence(); }); afterEach(() => { resetSessionPersistence(); }); it('should handle Viking client errors gracefully', async () => { mockVikingClient.addResource.mockRejectedValueOnce(new Error('Viking error')); const service = new SessionPersistenceService({ fallbackToLocal: true }); service.startSession('agent1'); service.addMessage({ role: 'user', content: 'Test' }); const result = await service.endSession(); // Should still save to local storage expect(result.saved).toBe(true); }); it('should handle memory extractor errors gracefully', async () => { mockMemoryExtractor.extractFromConversation.mockRejectedValueOnce(new Error('Extraction failed')); const service = new SessionPersistenceService(); service.startSession('agent1'); for (let i = 0; i < 5; i++) { service.addMessage({ role: 'user', content: `Message ${i}` }); service.addMessage({ role: 'assistant', content: `Response ${i}` }); } const result = await service.endSession(); // Should still complete session even if extraction fails expect(result.saved).toBe(true); expect(result.extractedMemories).toBe(0); }); });