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
This commit is contained in:
iven
2026-03-20 22:14:13 +08:00
parent 6f72442531
commit 1cf3f585d3
43 changed files with 2826 additions and 3103 deletions

View File

@@ -342,6 +342,22 @@ function resetClientMocks() {
});
}
// Helper to inject mockClient into all domain stores
async function injectMockClient() {
const { setAgentStoreClient } = await import('../../desktop/src/store/agentStore');
const { setHandStoreClient } = await import('../../desktop/src/store/handStore');
const { setWorkflowStoreClient } = await import('../../desktop/src/store/workflowStore');
const { setConfigStoreClient } = await import('../../desktop/src/store/configStore');
const { setSecurityStoreClient } = await import('../../desktop/src/store/securityStore');
const { setSessionStoreClient } = await import('../../desktop/src/store/sessionStore');
setAgentStoreClient(mockClient);
setHandStoreClient(mockClient);
setWorkflowStoreClient(mockClient);
setConfigStoreClient(mockClient);
setSecurityStoreClient(mockClient);
setSessionStoreClient(mockClient);
}
describe('gatewayStore desktop flows', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -350,51 +366,59 @@ describe('gatewayStore desktop flows', () => {
});
it('loads post-connect data and syncs agents after a successful connection', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useConnectionStore } = await import('../../desktop/src/store/connectionStore');
const { useAgentStore } = await import('../../desktop/src/store/agentStore');
const { useConfigStore } = await import('../../desktop/src/store/configStore');
await useGatewayStore.getState().connect('ws://127.0.0.1:18789', 'token-123');
await useConnectionStore.getState().connect('ws://127.0.0.1:18789', 'token-123');
// Post-connect: load data from domain stores (mimics facade connect)
await Promise.allSettled([
useConfigStore.getState().loadQuickConfig(),
useConfigStore.getState().loadWorkspaceInfo(),
useAgentStore.getState().loadClones(),
useAgentStore.getState().loadUsageStats(),
useAgentStore.getState().loadPluginStatus(),
useConfigStore.getState().loadScheduledTasks(),
useConfigStore.getState().loadSkillsCatalog(),
useConfigStore.getState().loadChannels(),
]);
const state = useGatewayStore.getState();
expect(mockClient.updateOptions).toHaveBeenCalledWith({
url: 'ws://127.0.0.1:18789',
token: 'token-123',
});
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(state.connectionState).toBe('connected');
expect(state.gatewayVersion).toBe('2026.3.11');
expect(state.quickConfig.gatewayUrl).toBe('ws://127.0.0.1:18789');
expect(state.workspaceInfo?.resolvedPath).toBe('C:/Users/test/.openclaw/zclaw-workspace');
expect(state.pluginStatus).toHaveLength(1);
expect(state.skillsCatalog).toHaveLength(1);
expect(state.channels).toEqual([
expect(useConnectionStore.getState().connectionState).toBe('connected');
expect(useConnectionStore.getState().gatewayVersion).toBe('2026.3.11');
expect(useConfigStore.getState().quickConfig.gatewayUrl).toBe('ws://127.0.0.1:18789');
expect(useConfigStore.getState().workspaceInfo?.resolvedPath).toBe('C:/Users/test/.openclaw/zclaw-workspace');
expect(useAgentStore.getState().pluginStatus).toHaveLength(1);
expect(useConfigStore.getState().skillsCatalog).toHaveLength(1);
expect(useConfigStore.getState().channels).toEqual([
{ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'active', accounts: 1 },
]);
expect(syncAgentsMock).toHaveBeenCalledWith([
{
id: 'clone_alpha',
name: 'Alpha',
role: '代码助手',
createdAt: '2026-03-13T00:00:00.000Z',
},
]);
expect(setStoredGatewayUrlMock).toHaveBeenCalledWith('ws://127.0.0.1:18789');
});
it('falls back to feishu probing with the correct chinese label when channels.list is unavailable', async () => {
mockClient.listChannels.mockRejectedValueOnce(new Error('channels.list unavailable'));
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useConfigStore } = await import('../../desktop/src/store/configStore');
await useGatewayStore.getState().loadChannels();
await useConfigStore.getState().loadChannels();
expect(useGatewayStore.getState().channels).toEqual([
expect(useConfigStore.getState().channels).toEqual([
{ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'active', accounts: 1 },
]);
});
it('merges and persists quick config updates through the gateway store', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
it('merges and persists quick config updates through the config store', async () => {
await injectMockClient();
const { useConfigStore } = await import('../../desktop/src/store/configStore');
useGatewayStore.setState({
useConfigStore.setState({
quickConfig: {
agentName: 'Alpha',
theme: 'light',
@@ -403,7 +427,7 @@ describe('gatewayStore desktop flows', () => {
},
});
await useGatewayStore.getState().saveQuickConfig({
await useConfigStore.getState().saveQuickConfig({
gatewayToken: 'new-token',
workspaceDir: 'C:/workspace-next',
});
@@ -416,7 +440,7 @@ describe('gatewayStore desktop flows', () => {
workspaceDir: 'C:/workspace-next',
});
expect(setStoredGatewayTokenMock).toHaveBeenCalledWith('new-token');
expect(useGatewayStore.getState().quickConfig.workspaceDir).toBe('C:/workspace-next');
expect(useConfigStore.getState().quickConfig.workspaceDir).toBe('C:/workspace-next');
});
it('returns the updated clone and refreshes the clone list after update', async () => {
@@ -446,10 +470,11 @@ describe('gatewayStore desktop flows', () => {
clones: refreshedClones,
});
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useAgentStore } = await import('../../desktop/src/store/agentStore');
await useGatewayStore.getState().loadClones();
const updated = await useGatewayStore.getState().updateClone('clone_alpha', {
await useAgentStore.getState().loadClones();
const updated = await useAgentStore.getState().updateClone('clone_alpha', {
name: 'Alpha Prime',
role: '架构助手',
});
@@ -459,7 +484,7 @@ describe('gatewayStore desktop flows', () => {
name: 'Alpha Prime',
role: '架构助手',
});
expect(useGatewayStore.getState().clones).toEqual(refreshedClones);
expect(useAgentStore.getState().clones).toEqual(refreshedClones);
});
});
@@ -471,12 +496,13 @@ describe('OpenFang actions', () => {
});
it('loads hands from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
await useGatewayStore.getState().loadHands();
await useHandStore.getState().loadHands();
expect(mockClient.listHands).toHaveBeenCalledTimes(1);
expect(useGatewayStore.getState().hands).toEqual([
expect(useHandStore.getState().hands).toEqual([
{
id: 'echo',
name: 'echo',
@@ -503,9 +529,10 @@ describe('OpenFang actions', () => {
});
it('triggers a hand and returns the run result', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
const result = await useGatewayStore.getState().triggerHand('echo', { message: 'hello' });
const result = await useHandStore.getState().triggerHand('echo', { message: 'hello' });
expect(mockClient.triggerHand).toHaveBeenCalledWith('echo', { message: 'hello' });
expect(result).toMatchObject({
@@ -516,29 +543,32 @@ describe('OpenFang actions', () => {
it('sets error when triggerHand fails', async () => {
mockClient.triggerHand.mockRejectedValueOnce(new Error('Hand not found'));
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
const result = await useGatewayStore.getState().triggerHand('nonexistent');
const result = await useHandStore.getState().triggerHand('nonexistent');
expect(result).toBeUndefined();
expect(useGatewayStore.getState().error).toBe('Hand not found');
expect(useHandStore.getState().error).toBe('Hand not found');
});
it('loads workflows from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
await useGatewayStore.getState().loadWorkflows();
await useWorkflowStore.getState().loadWorkflows();
expect(mockClient.listWorkflows).toHaveBeenCalledTimes(1);
expect(useGatewayStore.getState().workflows).toEqual([
expect(useWorkflowStore.getState().workflows).toEqual([
{ id: 'wf_1', name: 'Data Pipeline', steps: 3 },
]);
});
it('executes a workflow and returns the run result', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
const result = await useGatewayStore.getState().executeWorkflow('wf_1', { input: 'data' });
const result = await useWorkflowStore.getState().triggerWorkflow('wf_1', { input: 'data' });
expect(mockClient.executeWorkflow).toHaveBeenCalledWith('wf_1', { input: 'data' });
expect(result).toMatchObject({
@@ -548,46 +578,50 @@ describe('OpenFang actions', () => {
});
it('loads triggers from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
await useGatewayStore.getState().loadTriggers();
await useHandStore.getState().loadTriggers();
expect(mockClient.listTriggers).toHaveBeenCalledTimes(1);
expect(useGatewayStore.getState().triggers).toEqual([
expect(useHandStore.getState().triggers).toEqual([
{ id: 'trig_1', type: 'webhook', enabled: true },
]);
});
it('loads audit logs from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
await useGatewayStore.getState().loadAuditLogs({ limit: 50, offset: 0 });
await useSecurityStore.getState().loadAuditLogs({ limit: 50, offset: 0 });
expect(mockClient.getAuditLogs).toHaveBeenCalledWith({ limit: 50, offset: 0 });
expect(useGatewayStore.getState().auditLogs).toEqual([
expect(useSecurityStore.getState().auditLogs).toEqual([
{ id: 'log_1', timestamp: '2026-03-13T10:00:00Z', action: 'hand.trigger', actor: 'user1' },
]);
});
it('initializes OpenFang state with empty arrays', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
const { useHandStore } = await import('../../desktop/src/store/handStore');
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
const state = useGatewayStore.getState();
expect(state.hands).toEqual([]);
expect(state.workflows).toEqual([]);
expect(state.triggers).toEqual([]);
expect(state.auditLogs).toEqual([]);
expect(useHandStore.getState().hands).toEqual([]);
expect(useWorkflowStore.getState().workflows).toEqual([]);
expect(useHandStore.getState().triggers).toEqual([]);
expect(useSecurityStore.getState().auditLogs).toEqual([]);
});
// === Security Tests ===
it('loads security status from the gateway', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
await useGatewayStore.getState().loadSecurityStatus();
await useSecurityStore.getState().loadSecurityStatus();
expect(mockClient.getSecurityStatus).toHaveBeenCalledTimes(1);
const { securityStatus } = useGatewayStore.getState();
const { securityStatus } = useSecurityStore.getState();
expect(securityStatus).not.toBeNull();
expect(securityStatus?.totalCount).toBe(16);
expect(securityStatus?.enabledCount).toBe(11);
@@ -595,21 +629,23 @@ describe('OpenFang actions', () => {
});
it('calculates security level correctly (critical for 14+ layers)', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
await useGatewayStore.getState().loadSecurityStatus();
await useSecurityStore.getState().loadSecurityStatus();
const { securityStatus } = useGatewayStore.getState();
const { securityStatus } = useSecurityStore.getState();
// 11/16 enabled = 68.75% = 'high' level
expect(securityStatus?.securityLevel).toBe('high');
});
it('identifies disabled security layers', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
await useGatewayStore.getState().loadSecurityStatus();
await useSecurityStore.getState().loadSecurityStatus();
const { securityStatus } = useGatewayStore.getState();
const { securityStatus } = useSecurityStore.getState();
const disabledLayers = securityStatus?.layers.filter(l => !l.enabled) || [];
expect(disabledLayers.length).toBe(5);
expect(disabledLayers.map(l => l.name)).toContain('Content Filtering');
@@ -617,31 +653,33 @@ describe('OpenFang actions', () => {
});
it('sets isLoading during loadHands', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useHandStore } = await import('../../desktop/src/store/handStore');
// Reset store state
useGatewayStore.setState({ hands: [], isLoading: false });
useHandStore.setState({ hands: [], isLoading: false });
const loadPromise = useGatewayStore.getState().loadHands();
const loadPromise = useHandStore.getState().loadHands();
// Check isLoading was set to true at start
// (this might be false again by the time we check due to async)
await loadPromise;
// After completion, isLoading should be false
expect(useGatewayStore.getState().isLoading).toBe(false);
expect(useGatewayStore.getState().hands.length).toBeGreaterThan(0);
expect(useHandStore.getState().isLoading).toBe(false);
expect(useHandStore.getState().hands.length).toBeGreaterThan(0);
});
it('sets isLoading during loadWorkflows', async () => {
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
await injectMockClient();
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
// Reset store state
useGatewayStore.setState({ workflows: [], isLoading: false });
useWorkflowStore.setState({ workflows: [], isLoading: false });
await useGatewayStore.getState().loadWorkflows();
await useWorkflowStore.getState().loadWorkflows();
expect(useGatewayStore.getState().isLoading).toBe(false);
expect(useGatewayStore.getState().workflows.length).toBeGreaterThan(0);
expect(useWorkflowStore.getState().isLoading).toBe(false);
expect(useWorkflowStore.getState().workflows.length).toBeGreaterThan(0);
});
});

View File

@@ -1,424 +0,0 @@
/**
* 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);
});
});

View File

@@ -428,11 +428,13 @@ describe('SkillDiscoveryEngine', () => {
});
it('toggles install status', () => {
engine.setSkillInstalled('code-review', false);
const r1 = engine.setSkillInstalled('code-review', false, { skipAutonomyCheck: true });
expect(r1.success).toBe(true);
const skill = engine.getAllSkills().find(s => s.id === 'code-review');
expect(skill!.installed).toBe(false);
engine.setSkillInstalled('code-review', true);
const r2 = engine.setSkillInstalled('code-review', true, { skipAutonomyCheck: true });
expect(r2.success).toBe(true);
const skill2 = engine.getAllSkills().find(s => s.id === 'code-review');
expect(skill2!.installed).toBe(true);
});

View File

@@ -1,299 +0,0 @@
/**
* Vector Memory Tests - Phase 4.2 Semantic Search
*
* Tests for vector-based semantic memory search:
* - VectorMemoryService initialization
* - Semantic search with OpenViking
* - Similar memory finding
* - Clustering functionality
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
VectorMemoryService,
getVectorMemory,
resetVectorMemory,
semanticSearch,
findSimilarMemories,
isVectorSearchAvailable,
DEFAULT_VECTOR_CONFIG,
type VectorSearchOptions,
type VectorSearchResult,
} from '../../desktop/src/lib/vector-memory';
import { getVikingClient, resetVikingClient } from '../../desktop/src/lib/viking-client';
import { getMemoryManager, resetMemoryManager } from '../../desktop/src/lib/agent-memory';
// === Mock Dependencies ===
const mockVikingClient = {
isAvailable: vi.fn(async () => true),
find: vi.fn(async () => [
{ uri: 'memories/agent1/memory1', content: '用户偏好简洁的回答', score: 0.9, metadata: { tags: ['preference'] } },
{ uri: 'memories/agent1/memory2', content: '项目使用 TypeScript', score: 0.7, metadata: { tags: ['fact'] } },
{ uri: 'memories/agent1/memory3', content: '需要完成性能测试', score: 0.5, metadata: { tags: ['task'] } },
]),
addResource: vi.fn(async () => ({ uri: 'test', status: 'ok' })),
removeResource: vi.fn(async () => undefined),
};
vi.mock('../../desktop/src/lib/viking-client', () => ({
getVikingClient: vi.fn(() => mockVikingClient),
resetVikingClient: vi.fn(),
VikingHttpClient: vi.fn(),
}));
const mockMemoryManager = {
getByAgent: vi.fn(() => [
{ id: 'memory1', agentId: 'agent1', content: '用户偏好简洁的回答', type: 'preference', importance: 7, createdAt: new Date().toISOString(), source: 'auto', tags: ['style'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
{ id: 'memory2', agentId: 'agent1', content: '项目使用 TypeScript', type: 'fact', importance: 6, createdAt: new Date().toISOString(), source: 'auto', tags: ['tech'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
{ id: 'memory3', agentId: 'agent1', content: '需要完成性能测试', type: 'task', importance: 8, createdAt: new Date().toISOString(), source: 'auto', tags: ['todo'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
]),
getAll: vi.fn(async () => [
{ id: 'memory1', agentId: 'agent1', content: '用户偏好简洁的回答', type: 'preference', importance: 7, createdAt: new Date().toISOString(), source: 'auto', tags: ['style'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
{ id: 'memory2', agentId: 'agent1', content: '项目使用 TypeScript', type: 'fact', importance: 6, createdAt: new Date().toISOString(), source: 'auto', tags: ['tech'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
{ id: 'memory3', agentId: 'agent1', content: '需要完成性能测试', type: 'task', importance: 8, createdAt: new Date().toISOString(), source: 'auto', tags: ['todo'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
]),
save: vi.fn(async () => 'memory-id'),
};
vi.mock('../../desktop/src/lib/agent-memory', () => ({
getMemoryManager: vi.fn(() => mockMemoryManager),
resetMemoryManager: vi.fn(),
}));
// === VectorMemoryService Tests ===
describe('VectorMemoryService', () => {
let service: VectorMemoryService;
beforeEach(() => {
vi.clearAllMocks();
resetVectorMemory();
resetVikingClient();
service = new VectorMemoryService();
});
afterEach(() => {
resetVectorMemory();
});
describe('Initialization', () => {
it('should initialize with default config', () => {
const config = service.getConfig();
expect(config.enabled).toBe(true);
expect(config.defaultTopK).toBe(10);
expect(config.defaultMinScore).toBe(0.3);
expect(config.defaultLevel).toBe('L1');
});
it('should accept custom config', () => {
const customService = new VectorMemoryService({
defaultTopK: 20,
defaultMinScore: 0.5,
});
const config = customService.getConfig();
expect(config.defaultTopK).toBe(20);
expect(config.defaultMinScore).toBe(0.5);
});
it('should update config', () => {
service.updateConfig({ defaultTopK: 15 });
const config = service.getConfig();
expect(config.defaultTopK).toBe(15);
});
});
describe('Semantic Search', () => {
it('should perform semantic search', async () => {
const results = await service.semanticSearch('用户偏好');
expect(mockVikingClient.find).toHaveBeenCalled();
expect(results.length).toBeGreaterThan(0);
expect(results[0].score).toBeGreaterThanOrEqual(0);
});
it('should respect topK option', async () => {
await service.semanticSearch('测试', { topK: 5 });
expect(mockVikingClient.find).toHaveBeenCalledWith(
'测试',
expect.objectContaining({ limit: 5 })
);
});
it('should respect minScore option', async () => {
await service.semanticSearch('测试', { minScore: 0.8 });
expect(mockVikingClient.find).toHaveBeenCalledWith(
'测试',
expect.objectContaining({ minScore: 0.8 })
);
});
it('should respect level option', async () => {
await service.semanticSearch('测试', { level: 'L2' });
expect(mockVikingClient.find).toHaveBeenCalledWith(
'测试',
expect.objectContaining({ level: 'L2' })
);
});
it('should return empty array when disabled', async () => {
service.updateConfig({ enabled: false });
const results = await service.semanticSearch('测试');
expect(results).toEqual([]);
});
it('should filter by types when specified', async () => {
const results = await service.semanticSearch('用户偏好', { types: ['preference'] });
// Should only return preference type memories
for (const result of results) {
expect(result.memory.type).toBe('preference');
}
});
});
describe('Find Similar', () => {
it('should find similar memories', async () => {
const results = await service.findSimilar('memory1', { agentId: 'agent1' });
expect(mockMemoryManager.getAll).toHaveBeenCalledWith('agent1');
expect(mockVikingClient.find).toHaveBeenCalled();
});
it('should return empty array for non-existent memory', async () => {
mockMemoryManager.getAll.mockResolvedValueOnce([]);
const results = await service.findSimilar('non-existent', { agentId: 'agent1' });
expect(results).toEqual([]);
});
});
describe('Find By Concept', () => {
it('should find memories by concept', async () => {
const results = await service.findByConcept('代码优化');
expect(mockVikingClient.find).toHaveBeenCalledWith(
'代码优化',
expect.any(Object)
);
expect(results.length).toBeGreaterThanOrEqual(0);
});
});
describe('Clustering', () => {
it('should cluster memories', async () => {
const clusters = await service.clusterMemories('agent1', 3);
expect(mockMemoryManager.getAll).toHaveBeenCalledWith('agent1');
expect(Array.isArray(clusters)).toBe(true);
});
it('should return empty array for agent with no memories', async () => {
mockMemoryManager.getAll.mockResolvedValueOnce([]);
const clusters = await service.clusterMemories('empty-agent');
expect(clusters).toEqual([]);
});
});
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);
});
});
describe('Cache', () => {
it('should clear cache', () => {
service.clearCache();
// No error means success
expect(true).toBe(true);
});
});
});
// === Helper Function Tests ===
describe('Helper Functions', () => {
beforeEach(() => {
vi.clearAllMocks();
resetVectorMemory();
});
afterEach(() => {
resetVectorMemory();
});
describe('getVectorMemory', () => {
it('should return singleton instance', () => {
const instance1 = getVectorMemory();
const instance2 = getVectorMemory();
expect(instance1).toBe(instance2);
});
});
describe('semanticSearch helper', () => {
it('should call service.semanticSearch', async () => {
const results = await semanticSearch('测试查询');
expect(mockVikingClient.find).toHaveBeenCalled();
expect(Array.isArray(results)).toBe(true);
});
});
describe('findSimilarMemories helper', () => {
it('should call service.findSimilar', async () => {
const results = await findSimilarMemories('memory1', 'agent1');
expect(mockMemoryManager.getAll).toHaveBeenCalled();
expect(Array.isArray(results)).toBe(true);
});
});
describe('isVectorSearchAvailable helper', () => {
it('should call service.isAvailable', async () => {
const available = await isVectorSearchAvailable();
expect(typeof available).toBe('boolean');
});
});
});
// === Integration Tests ===
describe('VectorMemoryService Integration', () => {
it('should handle Viking client errors gracefully', async () => {
mockVikingClient.find.mockRejectedValueOnce(new Error('Connection failed'));
const service = new VectorMemoryService();
const results = await service.semanticSearch('测试');
expect(results).toEqual([]);
});
it('should handle missing Viking client gracefully', async () => {
vi.mocked(getVikingClient).mockImplementation(() => {
throw new Error('Viking not available');
});
const service = new VectorMemoryService();
const results = await service.semanticSearch('测试');
expect(results).toEqual([]);
});
});

View File

@@ -1,446 +0,0 @@
/**
* Tests for VikingAdapter and ContextBuilder
*
* Tests the ZCLAW ↔ OpenViking integration layer with mocked HTTP responses.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VikingHttpClient, VikingError } from '../../desktop/src/lib/viking-client';
import {
VikingAdapter,
VIKING_NS,
resetVikingAdapter,
} from '../../desktop/src/lib/viking-adapter';
import {
ContextBuilder,
resetContextBuilder,
} from '../../desktop/src/lib/context-builder';
// === Mock fetch globally ===
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
function mockJsonResponse(data: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
};
}
// === VikingHttpClient Tests ===
describe('VikingHttpClient', () => {
let client: VikingHttpClient;
beforeEach(() => {
client = new VikingHttpClient('http://localhost:1933');
mockFetch.mockReset();
});
describe('status', () => {
it('returns server status on success', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ status: 'ok', version: '0.1.18' })
);
const result = await client.status();
expect(result.status).toBe('ok');
expect(result.version).toBe('0.1.18');
});
it('throws VikingError on server error', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ error: 'internal' }, 500)
);
await expect(client.status()).rejects.toThrow(VikingError);
});
});
describe('isAvailable', () => {
it('returns true when server responds ok', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ status: 'ok' })
);
expect(await client.isAvailable()).toBe(true);
});
it('returns false when server is down', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
expect(await client.isAvailable()).toBe(false);
});
});
describe('find', () => {
it('sends correct find request', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
results: [
{ uri: 'viking://user/memories/preferences/lang', score: 0.9, content: '中文', level: 'L1' },
],
})
);
const results = await client.find('language preference', {
scope: 'viking://user/memories/',
level: 'L1',
limit: 10,
});
expect(results).toHaveLength(1);
expect(results[0].score).toBe(0.9);
expect(results[0].content).toBe('中文');
// Verify request body
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.query).toBe('language preference');
expect(callBody.scope).toBe('viking://user/memories/');
expect(callBody.level).toBe('L1');
});
});
describe('addResource', () => {
it('sends correct add request', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://user/memories/preferences/lang', status: 'ok' })
);
const result = await client.addResource(
'viking://user/memories/preferences/lang',
'用户偏好中文回复',
{ metadata: { type: 'preference' }, wait: true }
);
expect(result.status).toBe('ok');
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.uri).toBe('viking://user/memories/preferences/lang');
expect(callBody.content).toBe('用户偏好中文回复');
expect(callBody.wait).toBe(true);
});
});
describe('extractMemories', () => {
it('sends session content for extraction', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
memories: [
{
category: 'user_preference',
content: '用户喜欢简洁回答',
tags: ['communication'],
importance: 8,
suggestedUri: 'viking://user/memories/preferences/communication',
},
{
category: 'agent_lesson',
content: '使用飞书API前需验证token',
tags: ['feishu', 'api'],
importance: 7,
suggestedUri: 'viking://agent/zclaw/memories/lessons/feishu_token',
},
],
summary: '讨论了飞书集成和回复风格偏好',
})
);
const result = await client.extractMemories(
'[user]: 帮我集成飞书API\n[assistant]: 好的,我来...',
'zclaw-main'
);
expect(result.memories).toHaveLength(2);
expect(result.memories[0].category).toBe('user_preference');
expect(result.memories[1].category).toBe('agent_lesson');
expect(result.summary).toContain('飞书');
});
});
});
// === VikingAdapter Tests ===
describe('VikingAdapter', () => {
let adapter: VikingAdapter;
beforeEach(() => {
resetVikingAdapter();
adapter = new VikingAdapter({ serverUrl: 'http://localhost:1933' });
mockFetch.mockReset();
});
describe('saveUserPreference', () => {
it('saves to correct viking:// URI', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://user/memories/preferences/language', status: 'ok' })
);
await adapter.saveUserPreference('language', '中文优先');
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.uri).toBe('viking://user/memories/preferences/language');
expect(callBody.content).toBe('中文优先');
expect(callBody.metadata.type).toBe('preference');
});
});
describe('saveAgentLesson', () => {
it('saves to agent-specific lessons URI', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://agent/zclaw-main/memories/lessons_learned/123', status: 'ok' })
);
await adapter.saveAgentLesson('zclaw-main', '飞书API需要先验证token', ['feishu', 'auth']);
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.uri).toContain('viking://agent/zclaw-main/memories/lessons_learned/');
expect(callBody.content).toBe('飞书API需要先验证token');
expect(callBody.metadata.tags).toBe('feishu,auth');
});
});
describe('buildEnhancedContext', () => {
it('performs L0 scan then L1 load', async () => {
// Mock L0 user memories search
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
results: [
{ uri: 'viking://user/memories/preferences/lang', score: 0.85, content: '中文', level: 'L0' },
{ uri: 'viking://user/memories/facts/project', score: 0.7, content: '飞书集成', level: 'L0' },
],
})
);
// Mock L0 agent memories search
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
results: [
{ uri: 'viking://agent/zclaw/memories/lessons/feishu', score: 0.8, content: 'API认证', level: 'L0' },
],
})
);
// Mock L1 reads for relevant items (score >= 0.5)
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ content: '用户偏好中文回复,简洁风格' })
);
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ content: '飞书API需要先验证app_id和app_secret' })
);
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ content: '调用飞书API前确保token未过期' })
);
const result = await adapter.buildEnhancedContext('帮我处理飞书集成', 'zclaw');
expect(result.memories.length).toBeGreaterThan(0);
expect(result.totalTokens).toBeGreaterThan(0);
expect(result.systemPromptAddition).toContain('记忆');
});
it('returns empty context when Viking is unavailable', async () => {
// Both L0 searches fail
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await adapter.buildEnhancedContext('test message', 'zclaw');
expect(result.memories).toHaveLength(0);
expect(result.totalTokens).toBe(0);
});
});
describe('extractAndSaveMemories', () => {
it('extracts and saves memories to correct categories', async () => {
// Mock extraction call
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
memories: [
{
category: 'user_preference',
content: '用户喜欢TypeScript',
tags: ['coding'],
importance: 8,
suggestedUri: 'viking://user/memories/preferences/coding',
},
{
category: 'agent_lesson',
content: 'Vitest配置需要在tsconfig中设置paths',
tags: ['testing', 'vitest'],
importance: 7,
suggestedUri: 'viking://agent/zclaw/memories/lessons/vitest_config',
},
],
summary: '讨论了TypeScript测试配置',
})
);
// Mock save calls (2 saves)
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://user/memories/preferences/coding', status: 'ok' })
);
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://agent/zclaw/memories/lessons/123', status: 'ok' })
);
const result = await adapter.extractAndSaveMemories(
[
{ role: 'user', content: '帮我配置Vitest' },
{ role: 'assistant', content: '好的需要在tsconfig中...' },
],
'zclaw'
);
expect(result.saved).toBe(2);
expect(result.userMemories).toBe(1);
expect(result.agentMemories).toBe(1);
});
it('handles extraction failure gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Server error'));
const result = await adapter.extractAndSaveMemories(
[{ role: 'user', content: 'test' }],
'zclaw'
);
expect(result.saved).toBe(0);
});
});
describe('VIKING_NS', () => {
it('generates correct namespace URIs', () => {
expect(VIKING_NS.userPreferences).toBe('viking://user/memories/preferences');
expect(VIKING_NS.agentLessons('zclaw')).toBe('viking://agent/zclaw/memories/lessons_learned');
expect(VIKING_NS.agentIdentity('zclaw')).toBe('viking://agent/zclaw/identity');
});
});
});
// === ContextBuilder Tests ===
describe('ContextBuilder', () => {
let builder: ContextBuilder;
beforeEach(() => {
resetContextBuilder();
resetVikingAdapter();
builder = new ContextBuilder({ enabled: true });
mockFetch.mockReset();
});
describe('buildContext', () => {
it('returns empty prompt when disabled', async () => {
builder.updateConfig({ enabled: false });
const result = await builder.buildContext('test', 'zclaw');
expect(result.systemPrompt).toBe('');
expect(result.tokensUsed).toBe(0);
});
it('returns empty prompt when Viking is unavailable', async () => {
// isAvailable check fails
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await builder.buildContext('test', 'zclaw');
expect(result.memoriesInjected).toBe(0);
});
});
describe('checkAndCompact', () => {
it('returns null when under threshold', async () => {
const messages = [
{ role: 'user' as const, content: '你好' },
{ role: 'assistant' as const, content: '你好!有什么可以帮你?' },
];
const result = await builder.checkAndCompact(messages, 'zclaw');
expect(result).toBeNull();
});
it('compacts and flushes memory when over threshold', async () => {
// Create messages that exceed the threshold
const longContent = '这是一段很长的对话内容。'.repeat(500);
const messages = [
{ role: 'user' as const, content: longContent },
{ role: 'assistant' as const, content: longContent },
{ role: 'user' as const, content: longContent },
{ role: 'assistant' as const, content: longContent },
{ role: 'user' as const, content: '最近的消息1' },
{ role: 'assistant' as const, content: '最近的消息2' },
{ role: 'user' as const, content: '最近的消息3' },
{ role: 'assistant' as const, content: '最近的消息4' },
{ role: 'user' as const, content: '最近的消息5' },
{ role: 'assistant' as const, content: '最近的回复5' },
];
// Mock memory flush extraction call
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
memories: [
{
category: 'user_fact',
content: '讨论了长文本处理',
tags: ['text'],
importance: 5,
suggestedUri: 'viking://user/memories/facts/text',
},
],
summary: '长文本处理讨论',
})
);
// Mock save call for flushed memory
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://user/memories/facts/text/123', status: 'ok' })
);
builder.updateConfig({ compactionThresholdTokens: 100 }); // Low threshold for test
const result = await builder.checkAndCompact(messages, 'zclaw');
expect(result).not.toBeNull();
expect(result!.compactedMessages.length).toBeLessThan(messages.length);
expect(result!.compactedMessages[0].content).toContain('对话摘要');
// Recent messages preserved
expect(result!.compactedMessages.some(m => m.content === '最近的回复5')).toBe(true);
});
});
describe('extractMemoriesFromConversation', () => {
it('skips extraction when disabled', async () => {
builder.updateConfig({ autoExtractOnComplete: false });
const result = await builder.extractMemoriesFromConversation(
[
{ role: 'user', content: '你好' },
{ role: 'assistant', content: '你好!' },
],
'zclaw'
);
expect(result.saved).toBe(0);
});
it('skips extraction for short conversations', async () => {
const result = await builder.extractMemoriesFromConversation(
[{ role: 'user', content: '你好' }],
'zclaw'
);
expect(result.saved).toBe(0);
});
});
describe('configuration', () => {
it('can update and read config', () => {
builder.updateConfig({ maxMemoryTokens: 4000, enabled: false });
const config = builder.getConfig();
expect(config.maxMemoryTokens).toBe(4000);
expect(config.enabled).toBe(false);
expect(builder.isEnabled()).toBe(false);
});
});
});