Backend (Rust): - viking_commands.rs: Tauri commands for server status/start/stop/restart - memory/mod.rs: Memory module exports - memory/context_builder.rs: Context building with memory injection - memory/extractor.rs: Memory extraction from conversations - llm/mod.rs: LLM integration for memory summarization Frontend (TypeScript): - context-builder.ts: Context building with OpenViking integration - viking-client.ts: OpenViking API client - viking-local.ts: Local storage fallback when Viking unavailable - viking-memory-adapter.ts: Memory extraction and persistence Features: - Multi-mode adapter (local/sidecar/remote) with auto-detection - Privacy-first: all data stored in ~/.openviking/, server only on 127.0.0.1 - Graceful degradation when local server unavailable - Context compaction with memory flush before compression Tests: 21 passing (viking-adapter.test.ts) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|
||
});
|