/** * 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); }); }); });