anomalyco / anomalyco/opencode
Kimi (openai-compatible) rejects replayed reasoning+tool assistant turns: "message at position N with role 'assistant' must not be empty"
@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.
-
packages/opencode/src/session/message-v2.ts:245const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` -
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, }) } -
@ai-sdk/openai-compatible(convertToOpenAICompatibleChatMessages, assistant case) folds thereasoningblock into the non-standardreasoning_contentfield and leavescontent: ""when there is notextpart: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— theinterleavedcapability (which moves reasoning intoreasoning_contentvia the transform) defaults to deepseek-only; Kimi openai-compatible models getfalse. And even when enabled it still leavescontent: ""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
providerIDthan 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 totext→contentis non-empty → works. - Continue on the same
providerID/modelIDthat 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.
message-v2.ts:362-376— in thedifferentModel === falsebranch, 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 thedifferentModel === truebranch already does, applied per block.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 emptycontent(even if it hasreasoning_content/tool_calls) gets a single space (" "). Since serialization already split blocks into separate messages, a per-message guard here is sufficient.- Allow
interleaved: "reasoning_content"opt-in for Kimi models — note this alone is insufficient, since the interleaved transform still leavescontent: ""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-compatible→https://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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.