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

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

View File

@@ -0,0 +1,299 @@
/**
* 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

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