Browse Source

fix(llm): preserve empty assistant serialization

Tianyi Cui 2 months ago
parent
commit
1e73d96bb1

+ 9 - 4
packages/llm/llm-deepseek/src/serialize.ts

@@ -40,10 +40,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.

+ 15 - 5
packages/llm/llm-deepseek/tests/serialize.spec.ts

@@ -187,12 +187,22 @@ describe('serializeRequest', () => {
   })
 })
 
-describe('assistant empty and tool-call 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).
+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([{ 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', () => {