anomalyco / anomalyco/opencode

Kimi (openai-compatible) rejects replayed reasoning+tool assistant turns: "message at position N with role 'assistant' must not be empty"

Open
#46,577 1 comment 0 reactions 1 assignee View on GitHub

@kitlangton is already working on this.

Since Sep 1, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Summary

When opencode replays an assistant turn whose only content parts are a reasoning block + a tool call (no text) back to the same provider/model that generated it, the message is serialized to Kimi's OpenAI-compatible endpoint (api.kimi.com/coding/v1) with an empty content string. Kimi rejects this:

AI_APICallError: the message at position N with role 'assistant' must not be empty

Once this happens the session is stuck: every retry re-sends the same history and hits the same message, so it loops.

This is still reproducible on the latest release (v1.18.25). Previously reported without diagnosis in #6056 (Kimi K2, v1.0.193, closed) and #37887 (Kimi K3, closed as not planned). Neither identified the root cause; this issue provides the mechanism, source references, a minimal repro, and a fix.

Root cause

The bug is in how reasoning parts are serialized for OpenAI-compatible providers on same-model replay.

  1. packages/opencode/src/session/message-v2.ts:245

    const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
    
  2. packages/opencode/src/session/message-v2.ts:362-376 — reasoning handling:

    if (part.type === "reasoning") {
      if (differentModel) {
        if (part.text.trim().length > 0)
          assistantMessage.parts.push({ type: "text", text: part.text })   // different model -> becomes text (non-empty content)
        continue
      }
      assistantMessage.parts.push({                                        // SAME model -> kept as reasoning
        type: "reasoning",
        text: part.text,
        providerMetadata: part.metadata,
      })
    }
    
  3. @ai-sdk/openai-compatible (convertToOpenAICompatibleChatMessages, assistant case) folds the reasoning block into the non-standard reasoning_content field and leaves content: "" when there is no text part:

    case "assistant": {
      let text = "";
      let reasoning = "";
      const toolCalls = [];
      for (const part of content) {
        switch (part.type) {
          case "text":      text += part.text; break;
          case "reasoning": reasoning += part.text; break;   // consumed here
          case "tool-call": toolCalls.push(...); break;
        }
      }
      messages.push({
        role: "assistant",
        content: text,                                       // stays "" -> Kimi rejects
        ...(reasoning.length > 0 ? { reasoning_content: reasoning } : {}),
        tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
      });
    }
    

There is no mitigation for this path:

  • packages/opencode/src/provider/provider.ts:1538-1543 — the interleaved capability (which moves reasoning into reasoning_content via the transform) defaults to deepseek-only; Kimi openai-compatible models get false. And even when enabled it still leaves content: "" for reasoning+tool-only turns.
  • packages/opencode/src/provider/transform.ts — the empty-content guard exists only for Anthropic (~lines 168-195) and Bedrock, not for openai-compatible.

DeepSeek tolerates content: ""; Kimi does not, which is why this surfaces specifically on Kimi.

Important nuance: emptiness is per-BLOCK, not per-message

convertToModelMessages emits one assistant wire-message per "block", where each step-start part begins a new block (ai dist, assistant case: it pushes parts into a block and calls processBlock() on every step-start). A block that contains only reasoning and/or tool parts — with no non-empty text — serializes to content: "".

Crucially, a single stored assistant message can produce multiple wire-messages. Example of a real failing message:

[step-start, reasoning]                              // block 0 -> content:""  (Kimi rejects THIS)
[step-start, reasoning, text, tool, step-finish]     // block 1 -> content:"Found it…"  (fine)

The message as a whole has text (in block 1), but block 0 is still emitted as an empty assistant message. So any mitigation — and any workaround plugin — must operate per step-start-delimited block, not on the message as a whole. A whole-message "does it have any text?" check misses this case.

Why it looks intermittent / provider-dependent

Because the trigger is differentModel:

  • Continue a session on a different providerID than the one that generated the history (e.g. two Kimi providers pointing at the same endpoint with different keys, or any other model) → differentModel = true → reasoning is converted to textcontent is non-empty → works.
  • Continue on the same providerID/modelID that generated the history → differentModel = false → reasoning kept → content: ""fails.

So with identical session history, the same request "works on provider A but fails on provider B" purely because provider B authored the history. This makes it look flaky and hard to attribute.

Minimal reproduction

Standalone script using the same AI SDK conversion opencode uses:

import { convertToModelMessages } from "ai"

// Mirrors @ai-sdk/openai-compatible assistant serialization:
function serializeAssistant(modelMessages) {
  return modelMessages.filter(m => m.role === "assistant").map(m => {
    let text = "", reasoning = ""; const toolCalls = []
    const content = Array.isArray(m.content) ? m.content : [{ type: "text", text: m.content }]
    for (const part of content) {
      if (part.type === "text") text += part.text
      else if (part.type === "reasoning") reasoning += part.text
      else if (part.type === "tool-call") toolCalls.push(part)
    }
    return { role: "assistant", content: text,
      ...(reasoning ? { reasoning_content: reasoning } : {}),
      tool_calls: toolCalls.length ? toolCalls : undefined }
  })
}

// Assistant turn as produced by message-v2.ts on same-model replay:
// reasoning + tool call, NO text part.
const uiMessage = { role: "assistant", parts: [
  { type: "step-start" },
  { type: "reasoning", text: "Let me inspect the file before editing." },
  { type: "tool-bash", state: "output-available", toolCallId: "call_1",
    input: { command: "ls" }, output: "file1\nfile2" },
]}
const tools = { bash: { toModelOutput: o => ({ type: "text", value: String(o) }) } }

const model = await convertToModelMessages(
  [uiMessage].filter(m => m.parts.some(p => p.type !== "step-start")), { tools })
console.log(JSON.stringify(serializeAssistant(model), null, 2))
// => assistant message has content: ""  (Kimi: "must not be empty")

Output shows content: "" with reasoning_content and tool_calls populated — exactly what Kimi rejects.

Proposed fixes (ranked)

Any fix must be block-aware (see the per-block nuance above): it has to guarantee that every step-start-delimited assistant block emits non-empty content, not just the message overall.

  1. message-v2.ts:362-376 — in the differentModel === false branch, when a step-start-delimited block has a reasoning/tool part but no non-empty text part, convert that block's reasoning to text (or push a minimal non-empty text placeholder into that block). This mirrors what the differentModel === true branch already does, applied per block.
  2. transform.ts — add an openai-compatible empty-assistant-content guard analogous to the existing Anthropic guard (~168-195): after serialization, any assistant message that would have empty content (even if it has reasoning_content/tool_calls) gets a single space (" "). Since serialization already split blocks into separate messages, a per-message guard here is sufficient.
  3. Allow interleaved: "reasoning_content" opt-in for Kimi models — note this alone is insufficient, since the interleaved transform still leaves content: "" for reasoning/tool-only blocks.
Workaround (plugin) — verified working

Until this is fixed upstream, this plugin fixes it for all sessions/providers via the experimental.chat.messages.transform hook. It is block-aware: it splits each assistant message into step-start-delimited blocks and injects a synthetic single-space text part into any block that has a reasoning/tool part but no non-empty text. It is idempotent (won't double-inject).

Two things that matter and are easy to get wrong:

  • Block granularity — a whole-message text check is insufficient (see the per-block nuance above). You must inject per block.
  • Plugin shape — a bare named-function export is not reliably picked up for a path plugin; opencode's v1 loader wants a default export exposing id + server().
// ~/.config/opencode/plugins/kimi-empty-content-fix.js
// register in opencode.json:  "plugin": ["/abs/path/to/kimi-empty-content-fix.js"]
const PLACEHOLDER = " "

function isNonEmptyText(p) {
  return p.type === "text" && typeof p.text === "string" && p.text.trim().length > 0
}

// Split parts into step-start-delimited blocks (matching convertToModelMessages).
function splitBlocks(parts) {
  const blocks = []
  let current = null
  for (let i = 0; i < parts.length; i++) {
    const p = parts[i]
    if (p.type === "step-start") {
      if (current) blocks.push(current)
      current = { start: i, parts: [] }
      continue
    }
    if (!current) current = { start: i, parts: [] }
    current.parts.push(p)
  }
  if (current) blocks.push(current)
  return blocks
}

function transform(_input, output) {
  for (const msg of output.messages) {
    if (msg?.info?.role !== "assistant" || !Array.isArray(msg.parts)) continue
    const inserts = []
    for (const block of splitBlocks(msg.parts)) {
      const hasAnchor = block.parts.some((p) => p.type === "tool" || p.type === "reasoning")
      const hasText = block.parts.some(isNonEmptyText)
      const alreadyFixed = block.parts.some((p) => p.type === "text" && p.synthetic === true)
      if (hasAnchor && !hasText && !alreadyFixed) {
        const startsWithStepStart = msg.parts[block.start]?.type === "step-start"
        inserts.push(startsWithStepStart ? block.start + 1 : block.start)
      }
    }
    // Splice from the highest index down so earlier splices don't shift later ones.
    for (let k = inserts.length - 1; k >= 0; k--) {
      msg.parts.splice(inserts[k], 0, {
        id: `prt_kimifix_${msg.info.id}_${inserts[k]}`,
        sessionID: msg.info.sessionID,
        messageID: msg.info.id,
        type: "text",
        text: PLACEHOLDER,
        synthetic: true,
      })
    }
  }
}

export default {
  id: "kimi-empty-content-fix",
  async server() {
    return {
      "experimental.chat.messages.transform": async (input, output) => transform(input, output),
    }
  },
}

Verified end-to-end against a real session's message history (feeding the mutated messages through the actual convertToModelMessages + openai-compatible serialization yields zero empty-content assistant messages) and confirmed live: the same-provider Kimi request that previously failed at "position 667" now succeeds. Note: a full opencode restart is required for the plugin to register.

Environment
  • opencode: reproduced on v1.18.23 and verified code path unchanged on v1.18.25 (latest)
  • ai: 6.0.168
  • @ai-sdk/openai-compatible: 2.0.37 / 2.0.41
  • Provider: @ai-sdk/openai-compatiblehttps://api.kimi.com/coding/v1 (Kimi K3); also reported for Moonshot Kimi K2 (#6056)
Related
  • #6056 (Kimi K2, closed, no diagnosis)
  • #37887 (Kimi K3, closed as not planned, no diagnosis)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.