kimi-code/packages/kosong/test/scripted-echo-provider.test.ts
2026-05-25 23:02:25 +08:00

183 lines
6.4 KiB
TypeScript

import { ScriptedEchoChatProvider } from './fixtures/echo-provider';
import { ChatProviderError } from '#/errors';
import type {
AudioURLPart,
StreamedMessagePart,
TextPart,
ToolCall,
ToolCallPart,
VideoURLPart,
} from '#/message';
import { describe, it, expect } from 'vitest';
describe('ScriptedEchoChatProvider', () => {
it('streams parts from first and second scripts', async () => {
const dsl = [
'id: scripted-1',
'usage: {"input_other": 4, "output": 1, "input_cache_read": 2}',
'text: Hello,',
'text: world!',
'think: thinking...',
'image_url: {"url": "https://example.com/image.png", "id": "img-1"}',
'audio_url: https://example.com/audio.mp3',
'video_url: https://example.com/video.mp4',
'tool_call: {"id": "call-1", "name": "search", "arguments": "{\\"q\\":\\"python\\"", "extras": {"source": "test"}}',
'tool_call_part: {"arguments_part": "}"}',
].join('\n');
const secondDsl = ['id: scripted-2', 'text: second turn'].join('\n');
const provider = new ScriptedEchoChatProvider([dsl, secondDsl]);
// First call
const parts: StreamedMessagePart[] = [];
const stream = await provider.generate('', [], []);
for await (const part of stream) {
parts.push(part);
}
expect(stream.id).toBe('scripted-1');
expect(stream.usage).toEqual({
inputOther: 4,
output: 1,
inputCacheRead: 2,
inputCacheCreation: 0,
});
expect(parts).toEqual([
{ type: 'text', text: 'Hello,' } satisfies TextPart,
{ type: 'text', text: ' world!' } satisfies TextPart,
{ type: 'think', think: 'thinking...' },
{
type: 'image_url',
imageUrl: { url: 'https://example.com/image.png', id: 'img-1' },
},
{
type: 'audio_url',
audioUrl: { url: 'https://example.com/audio.mp3' },
} satisfies AudioURLPart,
{
type: 'video_url',
videoUrl: { url: 'https://example.com/video.mp4' },
} satisfies VideoURLPart,
{
type: 'function',
id: 'call-1',
name: 'search', arguments: '{"q":"python"',
} satisfies ToolCall,
{ type: 'tool_call_part', argumentsPart: '}' } satisfies ToolCallPart,
]);
// Second call
const secondStream = await provider.generate('', [], []);
const secondParts: StreamedMessagePart[] = [];
for await (const part of secondStream) {
secondParts.push(part);
}
expect(secondStream.id).toBe('scripted-2');
expect(secondStream.usage).toBeNull();
expect(secondParts).toEqual([{ type: 'text', text: 'second turn' }]);
});
it('throws when scripts are exhausted', async () => {
const provider = new ScriptedEchoChatProvider(['text: only once']);
await provider.generate('', [], []);
await expect(provider.generate('', [], [])).rejects.toThrow(ChatProviderError);
});
it('rejects non-string arguments in tool_call', async () => {
const dsl = 'tool_call: {"id": "call-1", "name": "search", "arguments": {"q": "python"}}';
const provider = new ScriptedEchoChatProvider([dsl]);
await expect(provider.generate('', [], [])).rejects.toThrow(ChatProviderError);
});
it('rejects empty DSL content', async () => {
const provider = new ScriptedEchoChatProvider(['# comment only\n```']);
await expect(provider.generate('', [], [])).rejects.toThrow(ChatProviderError);
});
it('has correct default properties', () => {
const provider = new ScriptedEchoChatProvider([]);
expect(provider.name).toBe('scripted_echo');
expect(provider.modelName).toBe('scripted_echo');
expect(provider.thinkingEffort).toBeNull();
});
it('withThinking returns a new provider with remaining scripts', async () => {
const provider = new ScriptedEchoChatProvider(['text: first', 'text: second']);
// consume first script
await provider.generate('', [], []);
const newProvider = provider.withThinking('high');
expect(newProvider).toBeInstanceOf(ScriptedEchoChatProvider);
expect(newProvider).not.toBe(provider);
// new provider should have only the remaining script
const stream = await newProvider.generate('', [], []);
const parts: StreamedMessagePart[] = [];
for await (const part of stream) {
parts.push(part);
}
expect(parts).toEqual([{ type: 'text', text: 'second' }]);
});
it('exposes per-script finish_reason values across sequential generate() calls', async () => {
// Each script carries a different DSL finish_reason and the returned
// stream must expose the right normalized + raw pair.
const scripts = [
['id: scripted-fr-1', 'text: turn-1', 'finish_reason: stop'].join('\n'),
['id: scripted-fr-2', 'text: turn-2', 'finish_reason: length'].join('\n'),
['id: scripted-fr-3', 'text: turn-3', 'finish_reason: tool_calls'].join('\n'),
['id: scripted-fr-4', 'text: turn-4', 'finish_reason: null'].join('\n'),
['id: scripted-fr-5', 'text: turn-5'].join('\n'), // default
];
const provider = new ScriptedEchoChatProvider(scripts);
const expected: Array<{
finishReason: 'completed' | 'tool_calls' | 'truncated' | null;
rawFinishReason: string | null;
}> = [
{ finishReason: 'completed', rawFinishReason: 'stop' },
{ finishReason: 'truncated', rawFinishReason: 'length' },
{ finishReason: 'tool_calls', rawFinishReason: 'tool_calls' },
{ finishReason: null, rawFinishReason: null },
{ finishReason: 'completed', rawFinishReason: 'stop' },
];
for (const exp of expected) {
const stream = await provider.generate('', [], []);
for await (const _ of stream) {
void _;
}
expect({
finishReason: stream.finishReason,
rawFinishReason: stream.rawFinishReason,
}).toEqual(exp);
}
});
it('generate merges tool_call arguments via mergeInPlace', async () => {
const dsl = [
'id: scripted-merge-1',
'tool_call: {"id": "call-1", "name": "search", "arguments": "{\\"q\\":\\"py"}',
'tool_call_part: {"arguments_part": "thon\\"}"}',
].join('\n');
const provider = new ScriptedEchoChatProvider([dsl]);
const { generate } = await import('#/generate');
const result = await generate(provider, '', [], []);
expect(result.message.toolCalls).toHaveLength(1);
const tc = result.message.toolCalls[0]!;
expect(tc.id).toBe('call-1');
expect(tc.name).toBe('search');
expect(tc.arguments).toBe('{"q":"python"}');
});
});