docs(claude): restructure documentation management and add feedback system
- Restructure §8 from "文档沉淀规则" to "文档管理规则" with 4 subsections - Add docs/ structure with features/ and knowledge-base/ directories - Add feature documentation template with 7 sections (概述/设计初衷/技术设计/预期作用/实际效果/演化路线/头脑风暴) - Add feature update trigger matrix (新增/修改/完成/问题/反馈) - Add documentation quality checklist - Add §16
This commit is contained in:
424
tests/desktop/session-persistence.test.ts
Normal file
424
tests/desktop/session-persistence.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user