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

424 lines
13 KiB
TypeScript

import type {
AudioURLPart,
ContentPart,
ImageURLPart,
Message,
StreamedMessagePart,
TextPart,
ThinkPart,
ToolCall,
ToolCallPart,
VideoURLPart,
} from '#/message';
import {
createAssistantMessage,
createToolMessage,
createUserMessage,
extractText,
getTextContent,
isContentPart,
isToolCall,
isToolCallPart,
mergeInPlace,
} from '#/message';
import { describe, expect, it } from 'vitest';
describe('createUserMessage', () => {
it('creates a user message with single text part', () => {
const msg = createUserMessage('hello');
expect(msg.role).toBe('user');
expect(msg.content).toHaveLength(1);
expect(msg.content[0]).toEqual({ type: 'text', text: 'hello' });
});
});
describe('createAssistantMessage', () => {
it('creates an assistant message from content parts', () => {
const parts: ContentPart[] = [
{ type: 'text', text: 'hello' },
{ type: 'text', text: ' world' },
];
const msg = createAssistantMessage(parts);
expect(msg.role).toBe('assistant');
expect(msg.content).toHaveLength(2);
expect(msg.toolCalls).toEqual([]);
});
it('includes toolCalls when provided', () => {
const tc: ToolCall = {
type: 'function',
id: 'call-1',
name: 'search', arguments: '{"q":"ts"}',
};
const msg = createAssistantMessage([{ type: 'text', text: 'ok' }], [tc]);
expect(msg.toolCalls).toHaveLength(1);
expect(msg.toolCalls[0]!.name).toBe('search');
});
it('defaults toolCalls to empty array when not provided', () => {
const msg = createAssistantMessage([{ type: 'text', text: 'test' }]);
expect(msg.toolCalls).toEqual([]);
});
});
describe('createToolMessage', () => {
it('creates a tool message from a string', () => {
const msg = createToolMessage('call-1', 'result data');
expect(msg.role).toBe('tool');
expect(msg.toolCallId).toBe('call-1');
expect(msg.content).toHaveLength(1);
expect(msg.content[0]).toEqual({ type: 'text', text: 'result data' });
});
it('creates a tool message from ContentPart array', () => {
const parts: ContentPart[] = [
{ type: 'text', text: 'line1' },
{ type: 'text', text: 'line2' },
];
const msg = createToolMessage('call-2', parts);
expect(msg.role).toBe('tool');
expect(msg.content).toHaveLength(2);
expect(msg.content[0]).toEqual({ type: 'text', text: 'line1' });
});
});
describe('extractText', () => {
it('extracts text from a message with a single text part', () => {
const msg = createUserMessage('hello world');
expect(extractText(msg)).toBe('hello world');
});
it('concatenates multiple text parts with default separator', () => {
const msg: Message = {
role: 'assistant',
content: [
{ type: 'text', text: 'Hello' },
{ type: 'text', text: ' world' },
],
toolCalls: [],
};
expect(extractText(msg)).toBe('Hello world');
});
it('uses custom separator', () => {
const msg: Message = {
role: 'assistant',
content: [
{ type: 'text', text: 'line1' },
{ type: 'text', text: 'line2' },
],
toolCalls: [],
};
expect(extractText(msg, '\n')).toBe('line1\nline2');
});
it('ignores non-text parts', () => {
const msg: Message = {
role: 'assistant',
content: [
{ type: 'think', think: 'thinking...' },
{ type: 'text', text: 'visible' },
],
toolCalls: [],
};
expect(extractText(msg)).toBe('visible');
});
it('returns empty string for message with no text parts', () => {
const msg: Message = {
role: 'assistant',
content: [{ type: 'think', think: 'thinking only' }],
toolCalls: [],
};
expect(extractText(msg)).toBe('');
});
it('returns empty string for message with empty content', () => {
const msg: Message = { role: 'assistant', content: [], toolCalls: [] };
expect(extractText(msg)).toBe('');
});
});
describe('getTextContent (deprecated alias)', () => {
it('works the same as extractText', () => {
const msg = createUserMessage('test');
expect(getTextContent(msg)).toBe(extractText(msg));
});
});
describe('ContentPart type narrowing', () => {
it('narrows TextPart correctly', () => {
const part: ContentPart = { type: 'text', text: 'hello' };
if (part.type === 'text') {
const textPart: TextPart = part;
expect(textPart.text).toBe('hello');
}
});
it('narrows ThinkPart correctly', () => {
const part: ContentPart = { type: 'think', think: 'reasoning' };
if (part.type === 'think') {
const thinkPart: ThinkPart = part;
expect(thinkPart.think).toBe('reasoning');
}
});
it('narrows ThinkPart with encrypted field', () => {
const part: ContentPart = { type: 'think', think: '', encrypted: 'enc-data' };
if (part.type === 'think') {
expect(part.encrypted).toBe('enc-data');
}
});
it('narrows ImageURLPart correctly', () => {
const part: ContentPart = {
type: 'image_url',
imageUrl: { url: 'https://example.com/img.png' },
};
if (part.type === 'image_url') {
const imgPart: ImageURLPart = part;
expect(imgPart.imageUrl.url).toBe('https://example.com/img.png');
}
});
it('narrows ImageURLPart with optional id', () => {
const part: ContentPart = {
type: 'image_url',
imageUrl: { url: 'https://example.com/img.png', id: 'img-1' },
};
if (part.type === 'image_url') {
expect(part.imageUrl.id).toBe('img-1');
}
});
it('narrows AudioURLPart correctly', () => {
const part: ContentPart = {
type: 'audio_url',
audioUrl: { url: 'https://example.com/audio.mp3' },
};
if (part.type === 'audio_url') {
const audioPart: AudioURLPart = part;
expect(audioPart.audioUrl.url).toBe('https://example.com/audio.mp3');
}
});
it('narrows VideoURLPart correctly', () => {
const part: ContentPart = {
type: 'video_url',
videoUrl: { url: 'https://example.com/video.mp4', id: 'vid-1' },
};
if (part.type === 'video_url') {
const videoPart: VideoURLPart = part;
expect(videoPart.videoUrl.url).toBe('https://example.com/video.mp4');
expect(videoPart.videoUrl.id).toBe('vid-1');
}
});
});
describe('type guards', () => {
it('isContentPart returns true for text', () => {
const part: StreamedMessagePart = { type: 'text', text: 'hi' };
expect(isContentPart(part)).toBe(true);
});
it('isContentPart returns true for think', () => {
const part: StreamedMessagePart = { type: 'think', think: 'hmm' };
expect(isContentPart(part)).toBe(true);
});
it('isContentPart returns true for image_url', () => {
const part: StreamedMessagePart = {
type: 'image_url',
imageUrl: { url: 'http://img' },
};
expect(isContentPart(part)).toBe(true);
});
it('isContentPart returns true for audio_url', () => {
const part: StreamedMessagePart = {
type: 'audio_url',
audioUrl: { url: 'http://audio' },
};
expect(isContentPart(part)).toBe(true);
});
it('isContentPart returns true for video_url', () => {
const part: StreamedMessagePart = {
type: 'video_url',
videoUrl: { url: 'http://video' },
};
expect(isContentPart(part)).toBe(true);
});
it('isContentPart returns false for tool call', () => {
const part: StreamedMessagePart = {
type: 'function',
id: 'c1',
name: 'f', arguments: null,
};
expect(isContentPart(part)).toBe(false);
});
it('isContentPart returns false for tool call part', () => {
const part: StreamedMessagePart = { type: 'tool_call_part', argumentsPart: 'x' };
expect(isContentPart(part)).toBe(false);
});
it('isToolCall returns true for function type', () => {
const part: StreamedMessagePart = {
type: 'function',
id: 'c1',
name: 'f', arguments: null,
};
expect(isToolCall(part)).toBe(true);
});
it('isToolCall returns false for content parts', () => {
const part: StreamedMessagePart = { type: 'text', text: 'hi' };
expect(isToolCall(part)).toBe(false);
});
it('isToolCallPart returns true for tool_call_part', () => {
const part: StreamedMessagePart = { type: 'tool_call_part', argumentsPart: 'abc' };
expect(isToolCallPart(part)).toBe(true);
});
it('isToolCallPart returns false for other types', () => {
const part: StreamedMessagePart = { type: 'text', text: 'hi' };
expect(isToolCallPart(part)).toBe(false);
});
});
describe('mergeInPlace', () => {
it('merges TextPart + TextPart', () => {
const target: TextPart = { type: 'text', text: 'hello' };
const source: TextPart = { type: 'text', text: ' world' };
expect(mergeInPlace(target, source)).toBe(true);
expect(target.text).toBe('hello world');
});
it('merges ThinkPart + ThinkPart', () => {
const target: ThinkPart = { type: 'think', think: 'step1' };
const source: ThinkPart = { type: 'think', think: ' step2' };
expect(mergeInPlace(target, source)).toBe(true);
expect(target.think).toBe('step1 step2');
});
it('merges ThinkPart + ThinkPart with source encrypted', () => {
const target: ThinkPart = { type: 'think', think: 'thought' };
const source: ThinkPart = { type: 'think', think: '', encrypted: 'sig-123' };
expect(mergeInPlace(target, source)).toBe(true);
expect(target.think).toBe('thought');
expect(target.encrypted).toBe('sig-123');
});
it('refuses ThinkPart merge when target already encrypted', () => {
const target: ThinkPart = { type: 'think', think: 'done', encrypted: 'sig-old' };
const source: ThinkPart = { type: 'think', think: ' more' };
expect(mergeInPlace(target, source)).toBe(false);
expect(target.think).toBe('done');
});
it('merges ToolCall + ToolCallPart (null -> part)', () => {
const target: ToolCall = {
type: 'function',
id: 'c1',
name: 'f', arguments: null,
};
const source: ToolCallPart = { type: 'tool_call_part', argumentsPart: '{"a":' };
expect(mergeInPlace(target, source)).toBe(true);
expect(target.arguments).toBe('{"a":');
});
it('merges ToolCall + ToolCallPart (append)', () => {
const target: ToolCall = {
type: 'function',
id: 'c1',
name: 'f', arguments: '{"a":',
};
const source: ToolCallPart = { type: 'tool_call_part', argumentsPart: '1}' };
expect(mergeInPlace(target, source)).toBe(true);
expect(target.arguments).toBe('{"a":1}');
});
it('merges ToolCall + ToolCallPart with null argumentsPart (no-op)', () => {
const target: ToolCall = {
type: 'function',
id: 'c1',
name: 'f', arguments: '{"x":1}',
};
const source: ToolCallPart = { type: 'tool_call_part', argumentsPart: null };
expect(mergeInPlace(target, source)).toBe(true);
expect(target.arguments).toBe('{"x":1}');
});
it('returns false for TextPart + ThinkPart', () => {
const target: TextPart = { type: 'text', text: 'hi' };
const source: ThinkPart = { type: 'think', think: 'nope' };
expect(mergeInPlace(target, source)).toBe(false);
});
it('returns false for ThinkPart + TextPart', () => {
const target: ThinkPart = { type: 'think', think: 'hi' };
const source: TextPart = { type: 'text', text: 'nope' };
expect(mergeInPlace(target, source)).toBe(false);
});
it('returns false for TextPart + ToolCallPart', () => {
const target: TextPart = { type: 'text', text: 'hi' };
const source: ToolCallPart = { type: 'tool_call_part', argumentsPart: 'x' };
expect(mergeInPlace(target, source)).toBe(false);
});
it('returns false for ToolCall + TextPart', () => {
const target: ToolCall = {
type: 'function',
id: 'c1',
name: 'f', arguments: null,
};
const source: TextPart = { type: 'text', text: 'x' };
expect(mergeInPlace(target, source)).toBe(false);
});
});
describe('Message optional fields', () => {
it('message can have name field', () => {
const msg: Message = {
role: 'system',
name: 'system-prompt',
content: [{ type: 'text', text: 'You are helpful.' }],
toolCalls: [],
};
expect(msg.name).toBe('system-prompt');
});
it('message can have toolCalls', () => {
const msg: Message = {
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'call-1',
name: 'search', arguments: '{"q":"test"}',
},
],
};
expect(msg.toolCalls).toHaveLength(1);
expect(msg.toolCalls[0]!.name).toBe('search');
});
it('message can have partial flag', () => {
const msg: Message = {
role: 'assistant',
content: [{ type: 'text', text: 'partial...' }],
toolCalls: [],
partial: true,
};
expect(msg.partial).toBe(true);
});
it('ToolCall can have extras', () => {
const tc: ToolCall = {
type: 'function',
id: 'call-1',
name: 'search', arguments: '{}',
extras: { provider_id: 'anthropic-123' },
};
expect(tc.extras).toEqual({ provider_id: 'anthropic-123' });
});
});