Files
zclaw_openfang/docs/archive/v1-viking-dead-code/tests/session-persistence.test.ts
iven 1cf3f585d3 refactor(store): split gatewayStore into specialized domain stores
Major restructuring:
- Split monolithic gatewayStore into 5 focused stores:
  - connectionStore: WebSocket connection and gateway lifecycle
  - configStore: quickConfig, workspaceInfo, MCP services
  - agentStore: clones, usage stats, agent management
  - handStore: hands, approvals, triggers, hand runs
  - workflowStore: workflows, workflow runs, execution

- Update all components to use new stores with selector pattern
- Remove
2026-03-20 22:14:13 +08:00

425 lines
13 KiB
TypeScript

/**
* 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);
});
});