Files
deepseek-harness/packages/llm-deepseek/tests/serialize.spec.ts
T
Tianyi Cui ab19fed77c Add two DeepSeek LLM adapters: dsh-llm-deepseek and dsh-llm-pi-ai
The first real LlmAdapter implementations, shipped as a deliberate pair:
same models and wire protocol, completely different internals, so the
StreamChunk protocol is verified across independent implementations.

- dsh-llm-deepseek: hand-rolled fetch + SSE parser + chunk-translation
  state machine against the official chat-completions format (thinking
  mode via top-level thinking/reasoning_effort; the empty-string
  reasoning_content first chunk; usage attached to the finish chunk or
  trailing; reasoning_content passback on tool-call turns; disjoint
  cache-token accounting).
- dsh-llm-pi-ai: the same endpoint through @earendil-works/pi-ai,
  mapping its event vocabulary (parsed tool arguments, in-stream error
  events, folded reasoning tokens) onto the same chunks.

The agent loop now honors the in-band error path: an adapter that ends
its stream with finish {kind:error|aborted} (the only option for
adapters that can't throw mid-stream, like pi-ai) is translated into a
step error, so the turn ends error/aborted with a logged error event
instead of a normal completed assistant message. This makes the
StreamChunk error contract real for both adapters; docs/architecture.md
and the StreamChunk doc are updated accordingly.

New yarn test:e2e (vitest.e2e.config.ts, *.e2e.ts) runs key-gated
real-API matrices for both adapters across V4 Flash/Pro and all
thinking/effort levels; it self-skips without DEEPSEEK_API_KEY. Unit
suites run against local node:http mock SSE servers at 100% per-file
coverage.
2026-06-13 18:30:03 +08:00

211 lines
7.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek'
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
return { model: 'deepseek-v4-flash', messages: [], ...overrides }
}
describe('serializeMessages', () => {
it('maps user text to string content', () => {
const wire = serializeMessages([
{ role: 'user', content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }] },
])
expect(wire).toEqual([{ role: 'user', content: 'hello world' }])
})
it('maps system-role messages in history', () => {
const wire = serializeMessages([
{ role: 'system', content: [{ type: 'text', text: 'be brief' }] },
])
expect(wire).toEqual([{ role: 'system', content: 'be brief' }])
})
it('maps plain assistant text without reasoning_content', () => {
const wire = serializeMessages([
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'thinking…' },
{ type: 'text', text: 'answer' },
],
},
])
// 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([
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'I should check the weather.' },
{ type: 'tool-call', id: CallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' },
],
},
])
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([
{
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('a'), name: 'one', arguments: '{}' },
{ type: 'tool-call', id: CallId('b'), name: 'two', arguments: '{}' },
],
},
])
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([
{
role: 'user',
content: [{
type: 'tool-result',
toolCallId: CallId('call-1'),
content: [{ type: 'text', text: 'Sunny 22C' }],
}],
},
])
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([
{
role: 'user',
content: [{ type: 'tool-result', toolCallId: CallId('call-1'), content: [] }],
},
])
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([
{
role: 'user',
content: [
{ type: 'text', text: 'context note' },
{ type: 'tool-result', toolCallId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }] },
],
},
])
expect(wire).toEqual([
{ role: 'user', content: 'context note' },
{ role: 'tool', tool_call_id: 'call-1', content: 'ok' },
])
})
it('skips image blocks (documented MVP limitation)', () => {
const wire = serializeMessages([
{ role: 'user', content: [{ type: 'image', url: 'data:image/png;base64,x' }, { type: 'text', text: 'see image' }] },
])
expect(wire).toEqual([{ role: 'user', content: 'see image' }])
})
it('emits an empty user message rather than dropping block-less messages', () => {
const wire = serializeMessages([{ role: 'user', content: [] }])
expect(wire).toEqual([{ role: 'user', content: '' }])
})
})
describe('serializeRequest', () => {
const history: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]
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 with strict passthrough', () => {
const wire = serializeRequest(request({
messages: history,
tools: [
{ name: 'a', description: 'A', parameters: { type: 'object', properties: {} } },
{ name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true },
],
}))
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: {} }, strict: true } },
])
})
it('omits an empty tools array', () => {
const wire = serializeRequest(request({ messages: history, tools: [] }))
expect(wire.tools).toBeUndefined()
})
it('applies adapter defaults for thinking and effort', () => {
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled', reasoningEffort: 'max' })
expect(wire.thinking).toEqual({ type: 'enabled' })
expect(wire.reasoning_effort).toBe('max')
})
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('rejects prefill with an UNSUPPORTED LlmError', () => {
expect(() => serializeRequest(request({ prefill: [{ type: 'text', text: 'Sure' }] })))
.toThrow(LlmError)
try {
serializeRequest(request({ prefill: [] }))
expect.unreachable()
} catch (error) {
expect((error as LlmError).code).toBe('UNSUPPORTED')
}
})
})
describe('review fixes: assistant content shapes', () => {
it('serializes a content-less, tool-call-less assistant message as null content', () => {
// Aborted/empty assistant turns: no text, no calls → null (the wire
// accepts it; "" is reserved for tool-call turns per the samples).
const wire = serializeMessages([{ role: 'assistant', content: [] }])
expect(wire).toEqual([{ role: 'assistant', content: null }])
})
it('serializes tool-call turns with empty string content, not null', () => {
const wire = serializeMessages([{
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c'), name: 'f', arguments: '{}' }],
}])
expect(wire[0]).toMatchObject({ content: '' })
})
})