Skip to content

2.x: recovery after a reasoning-only turn that stops with length replays an empty assistant message (400 "must not be empty" on strict providers) #60292

Open

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

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

  1. opencodejr commented on Oct 29, 2026

    @opencodejr
    Contributor

    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 json against a fake OpenAI-compatible server that rejects empty assistant messages, and its first reply is reasoning only, ending with finish_reason: "length".

    • 2.2.1: the output-limit recovery request replays the truncated turn as assistant(content="", reasoning_content="thinking about it"). The request gets a 400 and the run exits 1 with provider.invalid-request: the message at position 2 with role 'assistant' must not be empty.
    • Latest dev (3eb834b): same requests, same 400, same exit code.

    Related:

    Reproduction script (repro-empty-assistant.mjs)
    // Reproduction for anomalyco/opencode#60292 (adapted from the report).
    // A turn that streams only reasoning and ends with finish_reason "length" makes OpenCode 2.x send an
    // output-limit recovery request that replays the truncated turn as an assistant message with content "".
    // A fake OpenAI-compatible server on 127.0.0.1 stands in for a strict provider (like Kimi): it answers 400
    // when an assistant message has neither content nor tool calls.
    //
    //   node repro-empty-assistant.mjs [opencode-binary]      (default: `opencode` from PATH)
    //
    // No account or key is needed. OpenCode runs with a throwaway HOME and XDG directories.
    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] ?? 'opencode';
    const version = execFileSync(bin, ['--version'], { encoding: 'utf8' }).trim();
    
    const text = m => typeof m.content === 'string' ? m.content : (m.content ?? []).map(p => p.text ?? '').join('');
    const emptyAssistant = m => m.role === 'assistant' && !m.tool_calls?.length && !text(m).length;
    const describe = (m, i) => m.role + (m.role === 'assistant'
      ? `(content=${JSON.stringify(m.content)}${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) { // reasoning only, then the output budget runs out
        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' };
    const args = ['run', '--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}`);
    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}` : ''}`);
    });
    console.log(`exit code: ${code}`);
    for (const line of stdout.split('\n').filter(Boolean)) {
      try { const e = JSON.parse(line); if (e.type === 'error') console.log(`error: ${e.error?.type}: ${e.error?.message}`); } catch {}
    }
    server.closeAllConnections(); server.close(); await rm(root, { recursive: true, force: true });

    Run it: bun repro-empty-assistant.mjs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions