fix(cli): stream run output, add empty-text warning, flush race-late parts#31578
Closed
dblagbro wants to merge 1 commit into
Closed
fix(cli): stream run output, add empty-text warning, flush race-late parts#31578dblagbro wants to merge 1 commit into
dblagbro wants to merge 1 commit into
Conversation
…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
Contributor
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
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. |
Contributor
|
The following comment was made by an LLM, it may be inaccurate: Related PRs FoundPR #31505 —
PR #31446 —
|
Contributor
|
This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window. Feel free to open a new pull request that follows our guidelines. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
opencode runhas 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 ondev.What's broken today
No streaming in default text mode. Text parts only reach stdout when
part.time?.endis set (run.ts L683-694). Long generations look frozen; short single-token responses can land just aftersession.idletriggers the loop break and never reach stdout at all.Silent exit on empty completions. When the upstream LLM returns 2xx with no content — common with thinking-mode models that consume the entire
max_tokensbudget 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.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 fromhandlePart()where the loop body expectedcontinue. In default mode the for-await loop never advances past the first event, hanging forever waiting forsession.idlethat never gets dispatched. I verified this locally — a clean build of the fix(cli): flush run parts after json stream idle #31505 branch hangsopencode run "Reply with: PONG"at 60s timeout with zero stdout.Three layered fixes
a) Delta streaming —
message.part.deltaevents now write text and reasoning fragments directly to stdout (raw deltas in default text mode, NDJSONdeltalines in--format json). Emitted part IDs go into aSet<string>so the matchingmessage.part.updatedevent doesn't double-print.b) Belt-and-suspenders flush — after
client.session.prompt/client.session.commandreturns, walk the resolved assistant message'spartsarray and emit anytext/reasoningnot already covered by deltas/updates. This catches the race wheresession.idlefires before the finalpart.updatedevent 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:
Opt-in non-zero exit via
OPENCODE_RUN_EXIT_ON_EMPTY=1for CI/CD pipelines that want to fail loudly. The default is stillexit 0to 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 jsonidle 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 frontsgemini-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"— emitsstep_start,step_finish, and (when the model produces output)deltaandtextNDJSON 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 ofresult.data.partsstill works.--continue,--agent, or--command.