Repository navigation
Empty completions with an unknown finish reason retry without any bound #45315
Description
Description
When a provider closes a stream with a finish reason that normalizes to unknown and sends no content, the session loop re-requests the turn forever. There is no bound and no surfaced error.
This is the mirror image of #37735: an empty turn finishing with stop exits too early and silently, while an empty turn finishing with unknown never exits at all. Both come from the same predicate in prompt.ts.
Steps to reproduce
A minimal OpenAI-compatible SSE server that opens the assistant role and closes with a non-standard finish reason, sending no content delta:
data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{},"finish_reason":"bogus_reason"}]}
data: [DONE]
Point a provider at it and run any prompt.
Actual behavior
The run never terminates. Counting inbound requests on the fake provider:
| Empty turn finishing with | Requests to the provider |
|---|---|
stop |
2, then a silent success (#37735) |
non-standard reason → unknown |
2517 before I killed the process |
Each iteration persists another step-start / step-finish pair with finish: "unknown" and input=0, output=0. No error is surfaced and the loop shows no sign of stopping.
Reproduced on 1.18.23 (Homebrew binary) and from source on dev @ 902e67e.
Expected behavior
An empty turn should be retried a bounded number of times and then surface an error, the same way other unrecoverable provider failures do.
Root cause
packages/opencode/src/session/prompt.ts:
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)unknown is treated as "not finished", which is correct while a turn is still mid-flight, but it also means a turn that ended empty with unknown is indistinguishable from one that needs continuation. The loop returns "continue" and re-requests. With a provider that always returns empty, that repeats without limit.
Note this is the turn loop, not SessionRetry.policy. The policy already bounds at RETRY_MAX_RETRIES = 5 (retry.ts), so this is a second, separate retry level. Worth keeping distinct when discussing fixes.
The same predicate causes #37735 from the other direction: stop counts as finished, so the loop breaks without checking whether the turn produced any output. Neither branch asks whether the turn generated something.
Prior art check
Existing issues that look adjacent but are distinct:
- Auto-compaction infinite loop when assistant ended its turn (finish !== tool-calls) #15533 — auto-compaction injecting a synthetic continue. Different trigger (compaction), and the loop there is driven by the injected message.
- [Bug]: Session processor retries indefinitely with unbounded exponential backoff — no max retries or circuit breaker #17648 — unbounded retries on transient provider errors. Predates the current
RETRY_MAX_RETRIESbound, and concerns error payloads rather than empty successful turns. - Session silently stops on empty LLM response (finish: unknown, 0 tokens) #41469 / fix(opencode): retry empty unknown responses #41466 — empty
unknownresponses being recorded as successful completions. Related and the closest match, but that thread is about the turn being silently accepted; the unbounded looping behavior is not described there and fix(opencode): retry empty unknown responses #41466's guard does not address it.
Fix direction
Guarding on whether the turn produced output rather than on the finish reason resolves this and #37735 together. Tracking whether the stream opened any content block (text-start, reasoning-start, tool-input-start, tool-call) and failing the attempt when it did not lets the existing RETRY_MAX_RETRIES bound apply.
Measured against the same repro server, with that guard in place:
| Mode | Before | After |
|---|---|---|
empty, unknown |
2517+, never terminates | 7 requests, then a surfaced error |
empty, stop |
2 requests, silent success | 7 requests, then a surfaced error |
stop with text |
1 request | 1 request, unchanged |
7 requests = 1 attempt + 5 retries + 1, consistent with RETRY_MAX_RETRIES = 5.
Full details, the A/B comparison against #41466, and test results (bun test test/session/ → 417 pass, 0 fail on dev @ 902e67e) are in my comment on #37735: #37735 (comment)
Happy to open a PR covering both if the approach looks right.
OpenCode version
1.18.23 (also reproduced from source on dev @ 902e67e)
Operating System
macOS 26.5.2, arm64
Terminal
zsh
Activity
bekandev commentedon Aug 26, 2026
Correction on the request counts, same instrumentation error as in my #37735 comment: opencode requests the session title through the same
/chat/completionsendpoint, and my fake provider counted it as part of the turn. The 2517 figure for the unboundedunknowncase is unaffected in substance (it was still climbing without limit when I killed it), but the comparison row forstopshould read 1 request rather than 2 — the unpatched client makes no retry at all there.The bounded-after-fix figure is 6 requests for the turn (1 attempt + 5 retries), consistent with
RETRY_MAX_RETRIES = 5.amelvil2-ford commentedon Sep 3, 2026
Hi @bekandev
I've ran into this problem as well, I wanted to share my PR with you #47079
This seems to be working well for my use case, what do you think?
neriousy commentedon Oct 19, 2026
V2 no longer loops on this. A response that ends with a non-standard or unknown finish reason now counts as a provider error, retries are capped, and once they run out the error is shown on the assistant message. To get v2:
npm i -g @opencode/cli@latestorcurl -fsSL https://opencode.ai/v2/install | bash(desktop: update the app). If you still see this on v2, please open a new issue with the details.