Skip to content

Empty completions with an unknown finish reason retry without any bound #45315

Description

@bekandev

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions