Skip to content

fix(cli): stream run output, add empty-text warning, flush race-late parts#31578

Open
dblagbro wants to merge 1 commit into
anomalyco:devfrom
dblagbro:devin/run-streaming-fix
Open

fix(cli): stream run output, add empty-text warning, flush race-late parts#31578
dblagbro wants to merge 1 commit into
anomalyco:devfrom
dblagbro:devin/run-streaming-fix

Conversation

@dblagbro

Copy link
Copy Markdown

Summary

opencode run has three independent gaps that leave CLI users staring at silent exits and dropped answers. This PR fixes all three without changing the working loop structure on dev.

What's broken today

  1. No streaming in default text mode. Text parts only reach stdout when part.time?.end is set (run.ts L683-694). Long generations look frozen; short single-token responses can land just after session.idle triggers the loop break and never reach stdout at all.

  2. Silent exit on empty completions. When the upstream LLM returns 2xx with no content — common with thinking-mode models that consume the entire max_tokens budget on internal reasoning, or with compliance proxies that return 2xx-empty on transient backend errors — the CLI exits 0 with zero output. From the caller's perspective this is indistinguishable from a hung process.

  3. PR fix(cli): flush run parts after json stream idle #31505's hang regression. The earlier attempt at fixing this extracted the part handler into a function that returns from handlePart() where the loop body expected continue. In default mode the for-await loop never advances past the first event, hanging forever waiting for session.idle that never gets dispatched. I verified this locally — a clean build of the fix(cli): flush run parts after json stream idle #31505 branch hangs opencode run "Reply with: PONG" at 60s timeout with zero stdout.

Three layered fixes

a) Delta streamingmessage.part.delta events now write text and reasoning fragments directly to stdout (raw deltas in default text mode, NDJSON delta lines in --format json). Emitted part IDs go into a Set<string> so the matching message.part.updated event doesn't double-print.

b) Belt-and-suspenders flush — after client.session.prompt / client.session.command returns, walk the resolved assistant message's parts array and emit any text/reasoning not already covered by deltas/updates. This catches the race where session.idle fires before the final part.updated event reaches our subscription. (Same idea as #31505, but applied to both formats and the loop structure is preserved so the default path doesn't hang.)

c) Empty-text detection — track whether any assistant text reached stdout during the run. If nothing did, write a clear stderr warning describing the most likely causes and what to try:

[opencode run] WARNING: model returned no assistant text. This usually means
the provider responded with an empty completion (thinking-mode models can
consume the full max_tokens on internal reasoning leaving no budget for
output, and some upstream proxies return 2xx-empty on transient backend
errors). Retry, raise max_tokens, or check the provider.

Opt-in non-zero exit via OPENCODE_RUN_EXIT_ON_EMPTY=1 for CI/CD pipelines that want to fail loudly. The default is still exit 0 to preserve back-compat.

JSON consumers can detect the no-text case structurally so they don't get the stderr warning; their stdout stays clean and machine-parseable.

Why this is independent of #31505

That PR is scoped to the --format json idle flush. Even with it applied, the default text path is broken in two distinct ways (no streaming, no empty-detection) — and the extracted-function refactor introduced the hang regression in the default path. This PR addresses the three failure modes without that refactor, and is mergeable independently or as a strict superset.

Closes #22243, #20799, #27669, #29997, #30100. Supersedes #31482's flush by also covering the default mode. Builds cleanly on top of dev.

Test plan

Tested on a Linux x64 build (bun run build --single --skip-install --skip-embed-web-ui) against an OpenAI-compatible compliance relay that fronts gemini-2.5-flash:

  • opencode run "Reply with: PONG" (default mode) — exits 0 in 2-4s instead of hanging at 60s timeout. Empty-completion case now surfaces the stderr warning instead of silent exit-0.
  • opencode run --format json "Reply with: PONG" — emits step_start, step_finish, and (when the model produces output) delta and text NDJSON lines.
  • OPENCODE_RUN_EXIT_ON_EMPTY=1 opencode run "..." — exits 2 on empty-completion case, 0 otherwise.
  • opencode run --print-logs --log-level=DEBUG "..." — debug logs still emit, no change in behavior.
  • opencode run -s ses_xxxx "..." resume — emitted-set is per-process so the resume reuses no stale state; the flush of result.data.parts still works.
  • No regression in tool-call display, --continue, --agent, or --command.

…parts

`opencode run` had three independent gaps that left CLI users staring at
silent exits and dropped answers:

  1. Default text mode never streamed partial output. Text parts only
     surfaced when their part.time?.end was set — long generations
     looked like the process was stuck, and short single-token responses
     could land just after the loop broke on session.idle and never
     reach stdout at all.

  2. When the upstream LLM returned a successful (2xx) response with
     empty content — common with thinking-mode models that consume the
     full max_tokens budget on internal reasoning, and with some compliance
     proxies that return 2xx-empty on transient backend errors — the CLI
     exited 0 with zero output. Indistinguishable from a hung process.

  3. PR anomalyco#31505 attempted a flush but extracted the part handler into a
     function that returned early, breaking the loop's continue flow and
     causing the default path to hang at session.idle indefinitely.

This patch keeps the dev branch's loop structure intact (no extracted
handlePart with return statements) and adds three layered fixes:

  - Delta streaming: message.part.delta events now write text/reasoning
    fragments directly to stdout (raw in default mode, NDJSON "delta"
    lines in --format json). Tracks emitted part IDs via a Set so
    matching message.part.updated events don't double-print.

  - Belt-and-suspenders flush: after client.session.prompt / .command
    returns, walk the resolved assistant message's parts and emit any
    text/reasoning not already covered by deltas/updates. Catches the
    race where session.idle fires before the final part.updated event
    reaches our subscription.

  - Empty-text detection: track whether any assistant text reached
    stdout during the run. If nothing did, write a clear warning to
    stderr describing the most likely causes (thinking budget, upstream
    empty completion) and what to try (retry, raise max_tokens, check
    provider). Opt-in non-zero exit via OPENCODE_RUN_EXIT_ON_EMPTY=1
    for CI/CD pipelines that want to fail loudly.

Verified on fall-compute-25 against a compliance-substituting hub relay
that fronts gemini-2.5-flash:

  - Default mode: 0/3 hangs in 4 runs (was 4/4 hanging with PR anomalyco#31505).
    Warning fires correctly on empty completions; exits in 2-4s instead
    of 30-60s timeout.
  - JSON mode: emits step_start, step_finish, and delta/text events.
  - No regression in tool-call display, --print-logs, or --continue.

Refs anomalyco#22243 anomalyco#31482 anomalyco#20799 anomalyco#27669 anomalyco#29997 anomalyco#30100 anomalyco#31505
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Jun 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Related PRs Found

PR #31505fix(cli): flush run parts after json stream idle

PR #31446fix: drain pending events before breaking on session idle in JSON format mode

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

Labels

needs:compliance This means the issue will auto-close after 2 hours.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

opencode run produces no stdout output while running

1 participant