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