fix(llm-deepseek): a reasoning-only assistant turn serializes as "" content, never null

Live failure: deepseek-v4-flash answered a greeting entirely in the
reasoning channel — no text block, no tool calls. The serializer's
null-content fallback produced an assistant message with neither
content nor tool_calls, which the API 400s ('Invalid assistant message:
content or tool_calls must be set'). Because that message sits durably
in the session log, every later turn of the session re-derived the same
history and failed identically — one all-reasoning response bricked the
session permanently (log: turns 2 and 3 failing byte-identically).

content is now always the flattened text ('' when there is none); the
passback rule still keeps reasoning_content off plain turns. The old
null shape was pinned by a test whose comment claimed the wire accepts
it — live-falsified, updated together with the code, plus a regression
test for the reasoning-only shape. Existing bricked logs resume cleanly
under the fix (the poisoned message now serializes as '').
This commit is contained in:
kingwl
2026-07-10 15:17:49 +08:00
parent 02e0756b78
commit 61922af5cd
2 changed files with 23 additions and 8 deletions

View File

@@ -49,10 +49,15 @@ function serializeAssistant(message: Message): WireMessage {
return {
role: 'assistant',
// Tool-call turns send "" rather than null: the live API answers both,
// but the official samples replay message.content verbatim (which is ""
// for pure tool-call responses) and some gateways reject null outright.
content: text.length > 0 ? text : toolCalls.length > 0 ? '' : null,
// Text-less turns send "" — NEVER null. Pure tool-call turns: the
// official samples replay message.content verbatim (which is "") and
// some gateways reject null outright. Reasoning-ONLY turns (the model
// can answer entirely in the reasoning channel, e.g. a v4-flash
// greeting): the live API rejects null-content/no-tool_calls assistant
// messages with a 400 ("content or tool_calls must be set"), and since
// the message sits durably in the session log, a null here bricks every
// later turn of that session.
content: text,
// Official passback rule (guides/thinking_mode.mdx): reasoning_content
// must return on tool-call turns; it is ignored on plain turns, so we
// drop it there to save tokens.

View File

@@ -188,11 +188,21 @@ describe('serializeRequest', () => {
})
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).
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([{ role: 'assistant', content: [] }])
expect(wire).toEqual([{ role: 'assistant', content: null }])
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([{ role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }] }])
expect(wire).toEqual([{ role: 'assistant', content: '' }])
})
it('serializes tool-call turns with empty string content, not null', () => {