import type { ModelMessage } from 'ai'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import type { AiAPIConfig } from '../../src/services/agentInstance/promptConcat/promptConcatSchema'; import { streamFromProvider } from '../../src/services/externalAPI/callProviderAPI'; import type { AIProviderConfig } from '../../src/services/externalAPI/interface'; import { MockOpenAIServer } from '../supports/mockOpenAI'; describe('Mock OpenAI Server', () => { let server: MockOpenAIServer; beforeAll(async () => { const rules = [ // Call 1: Wiki search tool use { response: '{"workspaceName":"-VPTqPdNOEZHGO5vkwllY","filter":"[title[Index]]"}', stream: false, }, // Call 2: Wiki search explanation { response: '在 TiddlyWiki 中,Index 条目提供了编辑卡片的方法说明,点击右上角的编辑按钮可以开始对当前卡片进行编辑。', stream: false, }, // Call 3: Wiki operation with default workspace (will fail) { response: '{"workspaceName":"default","operation":"wiki-add-tiddler","title":"testNote","text":"test"}', stream: false, }, // Call 4: Wiki operation with wiki workspace (will succeed) { response: '{"workspaceName":"wiki","operation":"wiki-add-tiddler","title":"test","text":"这是测试内容"}', stream: false, }, // Call 5: Wiki operation confirmation { response: '已成功在工作区 wiki 中创建条目 "test"。', stream: false, }, // Call 6: General test response { response: '这是一个测试响应。', stream: false, }, ]; server = new MockOpenAIServer(undefined, rules); await server.start(); }); beforeEach(async () => { // Reset call count before each test await fetch(`${server.baseUrl}/reset`, { method: 'POST' }); }); afterAll(async () => { await server.stop(); }); it('should return valid chat completion with tool call (first API call)', async () => { const response = await fetch(`${server.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key', }, body: JSON.stringify({ model: 'test-model', messages: [ { role: 'user', content: '搜索 wiki 中的 index 条目并解释', }, ], }), }); expect(response.status).toBe(200); const data = await response.json(); expect(data).toHaveProperty('id'); expect(data).toHaveProperty('object', 'chat.completion'); expect(data).toHaveProperty('created'); expect(data).toHaveProperty('model', 'test-model'); // Verify it returns the requested model expect(data).toHaveProperty('choices'); expect(data.choices).toHaveLength(1); expect(data.choices[0]).toHaveProperty('message'); expect(data.choices[0].message).toHaveProperty('content'); expect(data.choices[0].message.content).toContain(''); expect(data.choices[0].message.content).toContain('workspaceName'); expect(data.choices[0].message.content).toContain('-VPTqPdNOEZHGO5vkwllY'); expect(data.choices[0].finish_reason).toBe('stop'); }); it('should return valid chat completion with tool result response (second API call)', async () => { // This simulates the second API call in a conversation const response = await fetch(`${server.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key', }, body: JSON.stringify({ model: 'test-model', // Use the same model as in feature test messages: [ { role: 'user', content: '搜索 wiki 中的 index 条目并解释', }, { role: 'assistant', content: '{"workspaceName":"-VPTqPdNOEZHGO5vkwllY","filter":"[title[Index]]"}', }, { role: 'tool', content: 'Tool: wiki-search\nParameters: {"workspaceName":"-VPTqPdNOEZHGO5vkwllY","filter":"[title[Index]]"}\nError: Workspace with name or ID "-VPTqPdNOEZHGO5vkwllY" does not exist. Available workspaces: wiki (abc123), agent (agent), help (help), guide (guide), add (add)', }, ], }), }); expect(response.status).toBe(200); const data = await response.json(); expect(data.choices[0].message.role).toBe('assistant'); // Each test is reset, so this is also the first call returning wiki-search tool use expect(data.choices[0].message.content).toContain(''); expect(data.choices[0].finish_reason).toBe('stop'); expect(data.model).toBe('test-model'); // Verify it returns the requested model }); it('should work with different model names (first API call)', async () => { const response = await fetch(`${server.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key', }, body: JSON.stringify({ model: 'custom-model-name', messages: [ { role: 'user', content: 'Hello', }, ], }), }); expect(response.status).toBe(200); const data = await response.json(); expect(data.model).toBe('custom-model-name'); expect(data.choices[0].message.role).toBe('assistant'); // First call returns wiki-search tool use, not the Hello response expect(data.choices[0].message.content).toContain(''); }); it('should support streaming response (first API call)', async () => { const response = await fetch(`${server.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key', }, body: JSON.stringify({ model: 'test-model', stream: true, messages: [ { role: 'user', content: 'Hello', }, ], }), }); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8'); const reader = response.body?.getReader(); const decoder = new TextDecoder(); let chunks = ''; if (reader) { let done = false; while (!done) { const { value, done: readerDone } = await reader.read(); done = readerDone; if (value) { chunks += decoder.decode(value); } } } expect(chunks).toContain('data:'); expect(chunks).toContain('[DONE]'); expect(chunks).toContain('chat.completion.chunk'); // First call returns wiki-search tool use (check for JSON-escaped content) expect(chunks).toContain('wiki-search'); }); it('should reproduce exact three-call conversation (wiki search + wiki operation)', async () => { // Call 1: First API call returns wiki search tool use let res = await fetch(`${server.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' }, body: JSON.stringify({ model: 'test-model', messages: [{ role: 'user', content: '搜索 wiki 中的 index 条目并解释' }] }), }); expect(res.status).toBe(200); let data = await res.json(); expect(String(data.choices[0].message.content)).toBe( '{"workspaceName":"-VPTqPdNOEZHGO5vkwllY","filter":"[title[Index]]"}', ); // Call 2: Second API call returns explanation res = await fetch(`${server.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' }, body: JSON.stringify({ model: 'test-model', messages: [ { role: 'user', content: '搜索 wiki 中的 index 条目并解释' }, { role: 'assistant', content: '{"workspaceName":"-VPTqPdNOEZHGO5vkwllY","filter":"[title[Index]]"}' }, { role: 'tool', content: 'Tool: wiki-search\nParameters: ...\nError: Workspace not found' }, ], }), }); expect(res.status).toBe(200); data = await res.json(); expect(String(data.choices[0].message.content)).toContain('TiddlyWiki 中,Index 条目提供了编辑卡片的方法说明'); // Call 3: Third API call (start wiki operation) returns default workspace tool use res = await fetch(`${server.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' }, body: JSON.stringify({ model: 'test-model', messages: [ { role: 'user', content: '在 wiki 里创建一个新笔记,内容为 test' }, ], }), }); expect(res.status).toBe(200); data = await res.json(); expect(String(data.choices[0].message.content)).toBe( '{"workspaceName":"default","operation":"wiki-add-tiddler","title":"testNote","text":"test"}', ); }); it('should integrate with streamFromProvider (SDK) for streaming responses', async () => { // Reuse the existing server and update its rules to a single streaming rule const streamingRule = [{ response: 'chunkAchunkBchunkC', stream: true }]; server.setRules(streamingRule); // Build provider config that points to our mock server as openAICompatible const providerConfig: AIProviderConfig = { provider: 'TestProvider', providerClass: 'openAICompatible', baseURL: `${server.baseUrl}/v1`, apiKey: 'test-key', models: [{ name: 'test-model' }], enabled: true, }; const messages: ModelMessage[] = [ { role: 'user', content: 'Start streaming' }, ]; // streamFromProvider returns an object from streamText; call it and iterate const aiConfig: AiAPIConfig = { api: { provider: 'TestProvider', model: 'test-model' }, modelParameters: {} } as AiAPIConfig; const stream = streamFromProvider(aiConfig, messages, new AbortController().signal, providerConfig); // The returned stream should expose `.textStream` as an AsyncIterable // We'll collect chunks as they arrive and assert intermediate states are streaming const receivedChunks: string[] = []; if (!stream.textStream) throw new Error('Expected stream.textStream to be present'); for await (const chunk of stream.textStream) { if (!chunk) continue; let contentPiece: string | undefined; if (typeof chunk === 'string') contentPiece = chunk; else if (typeof chunk === 'object' && chunk !== null && 'content' in (chunk as Record)) { const c = (chunk as Record).content; if (typeof c === 'string') contentPiece = c; } if (contentPiece) { // Append to receivedChunks and assert intermediate streaming behavior receivedChunks.push(contentPiece); // Intermediate assertions: // - After first chunk: should contain only chunkA and not yet contain chunkC if (receivedChunks.length === 1) { expect(receivedChunks.join('')).toContain('chunkA'); expect(receivedChunks.join('')).not.toContain('chunkC'); } // - After second chunk: should contain chunkA and chunkB, but not chunkC if (receivedChunks.length === 2) { expect(receivedChunks.join('')).toContain('chunkA'); expect(receivedChunks.join('')).toContain('chunkB'); expect(receivedChunks.join('')).not.toContain('chunkC'); } } } // After stream completion, assemble chunks using the same '' separator used by rules const assembled = receivedChunks.join(''); // streamingRule[0].response uses '' as separator, verify equality expect(assembled).toBe(streamingRule[0].response); }); });