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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user