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:
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
Description
When a provider closes a stream with a finish reason that normalizes to
unknownand 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
stopexits too early and silently, while an empty turn finishing withunknownnever exits at all. Both come from the same predicate inprompt.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:
Point a provider at it and run any prompt.
Actual behavior
The run never terminates. Counting inbound requests on the fake provider:
stopunknownEach iteration persists another
step-start/step-finishpair withfinish: "unknown"andinput=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 ondev@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:unknownis treated as "not finished", which is correct while a turn is still mid-flight, but it also means a turn that ended empty withunknownis 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 atRETRY_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:
stopcounts 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:
RETRY_MAX_RETRIESbound, and concerns error payloads rather than empty successful turns.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 existingRETRY_MAX_RETRIESbound apply.Measured against the same repro server, with that guard in place:
unknownstopstopwith text7 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 ondev@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