Repository navigation
Description
Description
When a turn streams only reasoning and ends with finish_reason: "length", OpenCode 2.x continues the session with its output-limit recovery
message ("Your last response hit the output token limit (stop reason: length). Do not apologize, recap, or repeat yourself. Break the remaining
work into smaller pieces."). The request that carries it contains the truncated turn as an assistant message with content: "" (the thinking is in
reasoning_content). Providers that reject assistant messages with no content and no tool calls answer 400. Moonshot's Kimi API does, with the text
quoted in #37946 ("the message at position 29 with role 'assistant' must not be empty (400)", there for a replayed reasoning-only turn), so the
run fails with provider.invalid-request and exits 1.
1.18.32 does not send a follow-up request in the same situation, so this path is new in 2.x. It is the same failure class as #37946 (an empty assistant
message replayed to the provider), reached through a different path. A reasoning model that spends its whole output budget on thinking gets there easily.
Plugins
None.
OpenCode version
@opencode/cli 2.2.0 and 2.2.1 (linux-x64). 1.18.32 for comparison.
Steps to reproduce
Save the script below as repro-empty-assistant.mjs and run node repro-empty-assistant.mjs /path/to/opencode. It starts a fake OpenAI-compatible server
on 127.0.0.1 (no account, key or model), runs opencode run against it with a throwaway HOME, and prints the messages array of every request. The fake
server answers 400 when an assistant message has neither content nor tool calls, as Kimi's API does.
// Reproduction: after a turn that streams only reasoning and ends with finish_reason "length", OpenCode 2.x continues the session with an
// assistant message that has empty content (and a user message after it). A provider that rejects empty assistant messages answers 400.
//
// node repro-empty-assistant.mjs /path/to/opencode
//
// Needs Node 20+ and an `opencode` binary. A fake OpenAI-compatible server on 127.0.0.1 stands in for the provider; no account, key or model is used.
// OpenCode runs with a throwaway HOME and XDG directories. The fake server is strict in the way Kimi's API is: it answers 400 when an
// assistant message has no content and no tool calls.
import { createServer } from 'node:http';
import { spawn, execFileSync } from 'node:child_process';
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const bin = process.argv[2];
if (!bin) { console.error('usage: node repro-empty-assistant.mjs /path/to/opencode'); process.exit(2); }
const version = execFileSync(bin, ['--version'], { encoding: 'utf8' }).trim();
const v2 = /(^|\s)v?2\./.test(version); // 2.x needs --standalone, or `run` talks to a shared background server
const emptyAssistant = m => m.role === 'assistant' && !m.tool_calls?.length && !(typeof m.content === 'string' ? m.content : (m.content ?? []).map(p => p.text ?? '').join('')).length;
const text = m => typeof m.content === 'string' ? m.content : (m.content ?? []).map(p => p.text ?? '').join('');
const describe = (m, i) => m.role + (m.role === 'assistant' ? `(content=${JSON.stringify(text(m))}${m.reasoning_content ? `, reasoning_content=${JSON.stringify(m.reasoning_content)}` : ''})`
: m.role === 'user' && i > 1 ? `(${JSON.stringify(text(m).slice(0, 90))}...)` : '');
const requests = [];
const server = createServer(async (req, res) => {
let raw = ''; for await (const chunk of req) raw += chunk;
const body = JSON.parse(raw || '{}');
requests.push(body.messages);
const send = choices => res.write(`data: ${JSON.stringify({ id: 'c', object: 'chat.completion.chunk', created: 1, model: 'fixture', choices })}\n\n`);
const bad = body.messages.findIndex(emptyAssistant);
if (bad >= 0) {
res.statusCode = 400; res.setHeader('Content-Type', 'application/json');
return res.end(JSON.stringify({ error: { type: 'invalid_request_error', message: `the message at position ${bad} with role 'assistant' must not be empty` } }));
}
res.setHeader('Content-Type', 'text/event-stream');
if (requests.length === 1) { // a reasoning model that spends its whole output budget on thinking
send([{ index: 0, delta: { role: 'assistant', reasoning_content: 'thinking about it' }, finish_reason: null }]);
send([{ index: 0, delta: {}, finish_reason: 'length' }]);
} else {
send([{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: null }]);
send([{ index: 0, delta: {}, finish_reason: 'stop' }]);
}
res.write('data: [DONE]\n\n'); res.end();
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const root = await mkdtemp(join(tmpdir(), 'opencode-repro-'));
const project = join(root, 'project'), home = join(root, 'home');
await mkdir(project); await mkdir(home);
const config = { enabled_providers: ['fixture'], provider: { fixture: { npm: '@ai-sdk/openai-compatible', name: 'Fixture',
options: { baseURL: `http://127.0.0.1:${server.address().port}/v1`, apiKey: 'not-a-key' }, models: { fixture: { name: 'fixture', limit: { context: 32000, output: 1024 } } } } } };
const env = { PATH: process.env.PATH, HOME: home, XDG_CONFIG_HOME: join(home, 'config'), XDG_DATA_HOME: join(home, 'data'), XDG_CACHE_HOME: join(home, 'cache'),
XDG_STATE_HOME: join(home, 'state'), TMPDIR: root, OPENCODE_CONFIG_CONTENT: JSON.stringify(config), OPENCODE_DISABLE_MODELS_FETCH: 'true', OPENCODE_DISABLE_AUTOUPDATE: 'true' };
// --title keeps OpenCode from sending a second, title-generating request to the same model.
const args = ['run', ...(v2 ? ['--standalone'] : []), '--format', 'json', '--model', 'fixture/fixture', '--title', 'repro', 'Say hi.'];
let stdout = '';
const child = spawn(bin, args, { cwd: project, env, stdio: ['ignore', 'pipe', 'ignore'] });
child.stdout.on('data', b => { stdout += b; });
const code = await new Promise(resolve => child.once('close', resolve));
console.log(`opencode: ${version}${v2 ? ' (run --standalone)' : ''}`);
requests.forEach((messages, i) => {
const bad = messages.findIndex(emptyAssistant);
console.log(`request ${i + 1}: [${messages.map(describe).join(', ')}]${bad >= 0 ? ` -> 400, empty assistant message at position ${bad}` : ''}`);
});
const events = stdout.split('\n').filter(Boolean).map(line => JSON.parse(line));
console.log(`exit code: ${code}`);
for (const e of events) if (e.type === 'error') console.log(`error: ${e.error.type}: ${e.error.message}`);
server.closeAllConnections(); server.close(); await rm(root, { recursive: true, force: true });Output (the same in two runs of each; I ran it in a network namespace with only loopback, which the script does not need):
1.18.32
opencode: 1.18.32
request 1: [system, user]
exit code: 0
2.2.0
opencode: opencode v2.2.0 (run --standalone)
request 1: [system, user]
request 2: [system, user, assistant(content="", reasoning_content="thinking about it"), user("Your last response hit the output token limit (stop reason: length). Do not apologize, rec"...)] -> 400, empty assistant message at position 2
exit code: 1
error: provider.invalid-request: the message at position 2 with role 'assistant' must not be empty
2.2.1
opencode: opencode v2.2.1 (run --standalone)
request 1: [system, user]
request 2: [system, user, assistant(content="", reasoning_content="thinking about it"), user("Your last response hit the output token limit (stop reason: length). Do not apologize, rec"...)] -> 400, empty assistant message at position 2
exit code: 1
error: provider.invalid-request: the message at position 2 with role 'assistant' must not be empty
Expected
The truncated turn is not sent as an empty assistant message. For example: put the reasoning into content, leave the turn out of the request, or ask for a
larger output budget. A recovery request that a strict provider is bound to reject cannot recover anything.
Actual
The recovery request is rejected with 400, the run ends with provider.invalid-request, and the empty assistant message is part of the session history
that later turns replay (what #37946 describes; I did not test a second turn here).
Related
- Aborted assistant turn persisted with zero content bricks the session: empty assistant message replayed to provider (400 "must not be empty") #37946 "Aborted assistant turn persisted with zero content bricks the session: empty assistant message replayed to provider (400 "must not be empty")", closed as not planned.
- Assistant messages with no provider content permanently wedge sessions on strict providers (Kimi 400 must not be empty) #53571 "Assistant messages with no provider content permanently wedge sessions on strict providers (Kimi 400 must not be empty)", closed by the compliance bot, not by a maintainer. It proposes that an assistant message is sent only when it has text, a tool call or reasoning content, which is the guard fix(session): drop assistant messages with no provider content (empty-assistant-guard) #53575 carried.
- fix(session): drop assistant messages with no provider content (empty-assistant-guard) #53575 "fix(session): drop assistant messages with no provider content (empty-assistant-guard)", closed without being merged.
Operating System
Linux x86_64 (glibc), Node 24 for the script.
Terminal
None: non-interactive opencode run --format json, started from a wrapper that parses the JSON events.
Activity
opencodejr commentedon Oct 29, 2026
Thanks for the detailed report and the script. I could reproduce this.
The script below is your reproduction, lightly adapted. It runs
opencode run --standalone --format jsonagainst a fake OpenAI-compatible server that rejects empty assistant messages, and its first reply is reasoning only, ending withfinish_reason: "length".assistant(content="", reasoning_content="thinking about it"). The request gets a 400 and the run exits 1 withprovider.invalid-request: the message at position 2 with role 'assistant' must not be empty.dev(3eb834b): same requests, same 400, same exit code.Related:
Reproduction script (repro-empty-assistant.mjs)
Run it:
bun repro-empty-assistant.mjs