The native adapter's route was named deepseek, colliding with pi-ai's catalog provider of the same name, so the two DeepSeek paths could never be mounted side by side. The web settings page needs both configurable at once. Compositions, fixtures, goldens, scaffolding defaults, and docs all move together (pre-release, no shim); TUI/session-query-spill/ missing-credential goldens re-recorded through their keyless refresh modes because provider-name length shifts box padding and spill truncation points.
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
import { serializeMessages, serializeRequest } from '../src/serialize.ts'
|
|
|
|
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
|
|
return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides }
|
|
}
|
|
|
|
describe('serializeMessages', () => {
|
|
it('maps user text to string content', () => {
|
|
const wire = serializeMessages([
|
|
createUserMessage({
|
|
content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}),
|
|
])
|
|
expect(wire).toEqual([{ role: 'user', content: 'hello world' }])
|
|
})
|
|
|
|
it('maps system-role messages in history', () => {
|
|
const wire = serializeMessages([
|
|
createMessage({
|
|
role: 'system', content: [{ type: 'text', text: 'be brief' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}),
|
|
])
|
|
expect(wire).toEqual([{ role: 'system', content: 'be brief' }])
|
|
})
|
|
|
|
it('maps plain assistant text without reasoning_content', () => {
|
|
const wire = serializeMessages([
|
|
createMessage({
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'reasoning', text: 'thinking…' },
|
|
{ type: 'text', text: 'answer' },
|
|
],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}),
|
|
])
|
|
// Tool-call-free turn: reasoning is dropped (ignored by the API anyway).
|
|
expect(wire).toEqual([{ role: 'assistant', content: 'answer' }])
|
|
})
|
|
|
|
it('passes reasoning_content back on tool-call turns (official passback rule)', () => {
|
|
const wire = serializeMessages([
|
|
createMessage({
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'reasoning', text: 'I should check the weather.' },
|
|
{ type: 'tool-call', id: CallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' },
|
|
],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}),
|
|
])
|
|
expect(wire).toEqual([{
|
|
role: 'assistant',
|
|
// "" (not null) on tool-call turns — mirrors the official samples'
|
|
// verbatim message replay; some gateways reject null.
|
|
content: '',
|
|
reasoning_content: 'I should check the weather.',
|
|
tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }],
|
|
}])
|
|
})
|
|
|
|
it('serializes parallel tool calls in order', () => {
|
|
const wire = serializeMessages([
|
|
createMessage({
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'tool-call', id: CallId('a'), name: 'one', arguments: '{}' },
|
|
{ type: 'tool-call', id: CallId('b'), name: 'two', arguments: '{}' },
|
|
],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}),
|
|
])
|
|
const assistant = wire[0] as { tool_calls: { id: string }[] }
|
|
expect(assistant.tool_calls.map(call => call.id)).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('turns tool results into role:tool messages', () => {
|
|
const wire = serializeMessages([
|
|
createUserMessage({
|
|
content: [{
|
|
type: 'tool-result',
|
|
toolCallId: CallId('call-1'),
|
|
content: [{ type: 'text', text: 'Sunny 22C' }],
|
|
}],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}),
|
|
])
|
|
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: 'Sunny 22C' }])
|
|
})
|
|
|
|
it('sends a sentinel for empty tool-result content', () => {
|
|
const wire = serializeMessages([
|
|
createUserMessage({
|
|
content: [{ type: 'tool-result', toolCallId: CallId('call-1'), content: [] }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}),
|
|
])
|
|
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: '(no output)' }])
|
|
})
|
|
|
|
it('splits mixed user text + tool results into separate wire messages', () => {
|
|
const wire = serializeMessages([
|
|
createUserMessage({
|
|
content: [
|
|
{ type: 'text', text: 'context note' },
|
|
{ type: 'tool-result', toolCallId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }] },
|
|
],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}),
|
|
])
|
|
expect(wire).toEqual([
|
|
{ role: 'user', content: 'context note' },
|
|
{ role: 'tool', tool_call_id: 'call-1', content: 'ok' },
|
|
])
|
|
})
|
|
|
|
it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => {
|
|
const wire = serializeMessages([
|
|
createUserMessage({
|
|
content: [
|
|
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
|
|
{ type: 'text', text: 'see chart' },
|
|
],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}),
|
|
])
|
|
expect(wire).toEqual([{ role: 'user', content: 'see chart' }])
|
|
})
|
|
|
|
it('emits an empty user message rather than dropping block-less messages', () => {
|
|
const wire = serializeMessages([createUserMessage({
|
|
content: [],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})])
|
|
expect(wire).toEqual([{ role: 'user', content: '' }])
|
|
})
|
|
})
|
|
|
|
describe('serializeRequest', () => {
|
|
const history: Message[] = [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})]
|
|
|
|
it('always streams with usage and maps the basics', () => {
|
|
const wire = serializeRequest(request({ messages: history }))
|
|
expect(wire).toEqual({
|
|
model: 'deepseek-v4-flash',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
stream: true,
|
|
stream_options: { include_usage: true },
|
|
})
|
|
})
|
|
|
|
it('prepends the system prompt', () => {
|
|
const wire = serializeRequest(request({ messages: history, system: 'be helpful' }))
|
|
expect(wire.messages[0]).toEqual({ role: 'system', content: 'be helpful' })
|
|
expect(wire.messages[1]).toEqual({ role: 'user', content: 'hi' })
|
|
})
|
|
|
|
it('maps sampling params and stop sequences', () => {
|
|
const wire = serializeRequest(request({ messages: history, temperature: 0.2, maxTokens: 100, stop: ['END'] }))
|
|
expect(wire.temperature).toBe(0.2)
|
|
expect(wire.max_tokens).toBe(100)
|
|
expect(wire.stop).toEqual(['END'])
|
|
})
|
|
|
|
it('maps tools to the wire function shape', () => {
|
|
const wire = serializeRequest(request({
|
|
messages: history,
|
|
tools: [
|
|
{ name: 'a', description: 'A', parameters: { type: 'object', properties: {} } },
|
|
{ name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } },
|
|
],
|
|
}))
|
|
expect(wire.tools).toEqual([
|
|
{ type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } },
|
|
{ type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } },
|
|
])
|
|
})
|
|
|
|
it('omits an empty tools array', () => {
|
|
const wire = serializeRequest(request({ messages: history, tools: [] }))
|
|
expect(wire.tools).toBeUndefined()
|
|
})
|
|
|
|
it('maps adapter-default thinking and the request reasoning effort', () => {
|
|
const wire = serializeRequest(
|
|
request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
|
|
{ thinking: 'enabled', reasoningEffort: 'high' },
|
|
)
|
|
expect(wire.thinking).toEqual({ type: 'enabled' })
|
|
expect(wire.reasoning_effort).toBe('max')
|
|
})
|
|
|
|
it('maps off to disabled thinking without a wire reasoning effort', () => {
|
|
const wire = serializeRequest(
|
|
request({ messages: history, reasoningEffort: ReasoningEffortId('off') }),
|
|
{ thinking: 'enabled', reasoningEffort: 'max' },
|
|
)
|
|
expect(wire.thinking).toEqual({ type: 'disabled' })
|
|
expect(wire.reasoning_effort).toBeUndefined()
|
|
})
|
|
|
|
it('re-enables thinking when max overrides an off default', () => {
|
|
const wire = serializeRequest(
|
|
request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
|
|
{ reasoningEffort: 'off' },
|
|
)
|
|
expect(wire.thinking).toEqual({ type: 'enabled' })
|
|
expect(wire.reasoning_effort).toBe('max')
|
|
})
|
|
|
|
it('rejects enabling thinking when the deployment is locked to disabled', () => {
|
|
expect(() => serializeRequest(
|
|
request({ messages: history, reasoningEffort: ReasoningEffortId('high') }),
|
|
{ thinking: 'disabled' },
|
|
)).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
|
|
})
|
|
|
|
it('disables thinking for session-title requests without changing adapter defaults', () => {
|
|
const wire = serializeRequest(
|
|
request({
|
|
messages: history,
|
|
purpose: 'session-title',
|
|
reasoningEffort: ReasoningEffortId('max'),
|
|
}),
|
|
{ thinking: 'enabled', reasoningEffort: 'max' },
|
|
)
|
|
expect(wire.thinking).toEqual({ type: 'disabled' })
|
|
expect(wire.reasoning_effort).toBeUndefined()
|
|
})
|
|
|
|
it('omits thinking fields when unset (provider default applies)', () => {
|
|
const wire = serializeRequest(request({ messages: history }))
|
|
expect(wire.thinking).toBeUndefined()
|
|
expect(wire.reasoning_effort).toBeUndefined()
|
|
})
|
|
|
|
it('preserves an explicit enabled default without inventing a wire effort', () => {
|
|
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' })
|
|
expect(wire.thinking).toEqual({ type: 'enabled' })
|
|
expect(wire.reasoning_effort).toBeUndefined()
|
|
})
|
|
|
|
it('rejects an effort outside the DeepSeek capability', () => {
|
|
expect(() => serializeRequest(request({
|
|
messages: history,
|
|
reasoningEffort: ReasoningEffortId('medium'),
|
|
}))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
|
|
})
|
|
})
|
|
|
|
describe('review fixes: assistant content shapes', () => {
|
|
it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => {
|
|
// Aborted/empty assistant turns: no text, no calls → "". The earlier
|
|
// null shape was live-falsified: the API 400s a null-content assistant
|
|
// message without tool_calls ("content or tool_calls must be set").
|
|
const wire = serializeMessages([createMessage({
|
|
role: 'assistant', content: [],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})])
|
|
expect(wire).toEqual([{ role: 'assistant', content: '' }])
|
|
})
|
|
|
|
it('serializes a reasoning-ONLY assistant message as "" content with the reasoning dropped', () => {
|
|
// The model can answer entirely in the reasoning channel (a v4-flash
|
|
// greeting did, live). The passback rule keeps reasoning_content off
|
|
// plain turns, and content must still be SET — a null here poisoned the
|
|
// session log and bricked every later turn of that session.
|
|
const wire = serializeMessages([createMessage({
|
|
role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})])
|
|
expect(wire).toEqual([{ role: 'assistant', content: '' }])
|
|
})
|
|
|
|
it('serializes tool-call turns with empty string content, not null', () => {
|
|
const wire = serializeMessages([createMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'tool-call', id: CallId('c'), name: 'f', arguments: '{}' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})])
|
|
expect(wire[0]).toMatchObject({ content: '' })
|
|
})
|
|
})
|