Skip to content

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

Closed

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

Activity

  1. bekandev commented on Aug 26, 2026

    @bekandev
    Author

    Correction on the request counts, same instrumentation error as in my #37735 comment: opencode requests the session title through the same /chat/completions endpoint, and my fake provider counted it as part of the turn. The 2517 figure for the unbounded unknown case is unaffected in substance (it was still climbing without limit when I killed it), but the comparison row for stop should 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.

  2. amelvil2-ford commented on Sep 3, 2026

    @amelvil2-ford

    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?

  3. neriousy commented on Oct 19, 2026

    @neriousy
    Member

    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@latest or curl -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.

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

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