Skip to content

fix(session): retry empty unknown-finish streams instead of stopping - #43881

Open
moritzscheele wants to merge 1 commit into
anomalyco:devfrom
moritzscheele:fix/empty-stream-silent-abort
Open

fix(session): retry empty unknown-finish streams instead of stopping#43881
moritzscheele wants to merge 1 commit into
anomalyco:devfrom
moritzscheele:fix/empty-stream-silent-abort

Conversation

@moritzscheele

@moritzscheelemoritzscheele commented Aug 21, 2026

Copy link
Copy Markdown

Issue for this PR

Closes#41469
Closes#43622

Type of change

  • Bug fix

What does this PR do?

A provider or gateway can close the SSE stream cleanly without sending content or a finish frame. The AI SDK then reports a step finish reason of unknown with zero usage. The processor persists finish=unknown, and the prompt loop only excludes tool-calls from its exit check, so the turn ends silently and looks like a normal completion.

I hit this repeatedly on opencode/x-preview-f-free: 189 logged stream error entries in one day, and many assistant messages stored with finish=unknown, zero output tokens, and no error field. Their parts contain only step-start + step-finish(reason=unknown), which shows the stream ended cleanly instead of throwing.

The fix fails such streams after a clean drain: if finish === "unknown" and output tokens are zero (and compaction did not request the early stop), process fails with ProviderError.ResponseStreamError. MessageV2.fromError already maps that to a retryable APIError, so the existing SessionRetry.policy applies backoff and retries, and halt sets a visible error if all retries fail. Streams that end unknown but produced real output are untouched.

Note: #41466 addresses the same problem. This PR was developed independently and keeps the change smaller by reusing the existing error type instead of adding a new one.

How did you verify your code works?

  • bun run typecheck passes
  • bun test test/session/processor-effect.test.ts test/session/retry.test.ts: 77 pass
  • Rebuilt the desktop app with this patch and ran it against the same flaky gateway; empty streams now trigger the visible retry flow instead of ending the turn
  • Verification with long-running tasks is still being performed; I will post results here

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

A provider or gateway can close the SSE stream cleanly without emitting
content or a finish frame. The AI SDK then reports a step finish reason
of "unknown" with zero usage, the processor persists finish=unknown,
and the prompt loop treats any non-tool-calls finish as a completed
turn. The session ends silently and looks like a normal completion.
Fail such streams with ProviderError.ResponseStreamError after a clean
drain. MessageV2.fromError already maps that error to a retryable
APIError, so the existing SessionRetry policy applies backoff and
retries, and halt surfaces an error if all retries fail.
Observed against the opencode gateway: 189 logged stream errors in one
day, with repeated assistant messages holding finish=unknown, zero
output tokens, and no error field.
Fixesanomalyco#41469, fixesanomalyco#43622
@github-actionsgithub-actionsBot added the needs:compliance This means the issue will auto-close after 2 hours. label Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Based on my search, I found several related PRs that address similar issues with empty/truncated provider streams and unknown finish reasons:

Potentially Related PRs

  1. PR fix(opencode): retry empty unknown responses #41466 - fix(opencode): retry empty unknown responses

    • Directly related to retrying empty unknown responses
  2. PR fix(opencode): surface truncated turns instead of ending the loop #40142 - fix(opencode): surface truncated turns instead of ending the loop

    • Addresses similar issue of turns ending silently
  3. PR fix(opencode): make long-lived provider streams robust to silent SSE terminations #39970 - fix(opencode): make long-lived provider streams robust to silent SSE terminations

    • Handles silent SSE stream terminations
  4. PR fix: retry truncated provider streams #39473 - fix: retry truncated provider streams

    • Related to retrying truncated streams
  5. PR fix(session): set finish status on halted assistant message #42176 - fix(session): set finish status on halted assistant message

    • Related to finish status handling in session messages
  6. PR fix(session): retry empty stream truncations and discard partial parts #26167 - fix(session): retry empty stream truncations and discard partial parts

    • Historical PR addressing empty stream truncations

These PRs appear to tackle the same root problem of empty/unknown finish streams and silent session stops, though this PR (43881) may take a different or more comprehensive approach than its predecessors.

@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@moritzscheele

Copy link
Copy Markdown
Author

Long-running verification finished, result: green.

Setup: desktop build with this patch, sidecar running from fix/empty-stream-silent-abort, ~3.5 hours of continuous use against the same gateway and model (opencode/x-preview-f-free).

Observations for the test window:

  • The gateway stayed flaky: 124 stream error entries logged
  • Zero assistant messages were persisted with the previous failure signature (finishunknown/empty, zero output tokens, no error field). Before the patch that signature appeared repeatedly throughout the day
  • All affected turns recovered through the retry flow or completed normally; long-running tasks ran to completion without silent stops

One honest caveat: the clean-EOF variant this PR targets did not recur during the window — the observed failures were thrown API errors that the existing retry path already handles. So the guard served as a safety net here; the before/after difference in persisted dead messages is the strongest signal I can offer from live data.

renekris added a commit to renekris/opencode-lowmem that referenced this pull request Aug 22, 2026
…ray semantics
Deviations from the ported upstream hunks, per review:
- anomalyco#43881: the empty-stream guard now fails only when the attempt produced
no text/reasoning delta and no tool-call event. Providers may stream real
content but omit the usage block and finish reason; retrying those would
duplicate already-persisted output (up to 6 attempts). Empty-content
deltas still count as empty, so the original clean-EOF retry is intact.
- anomalyco#38939: listeners are an Array again. The Set container silently
deduplicated identical callback registrations and removed every entry on
the first unsubscribe; duplicates must deliver independently.
- adds the missing multi-byte UTF-8 split regression for anomalyco#43607's
streaming TextDecoder
renekris added a commit to renekris/opencode-lowmem that referenced this pull request Aug 22, 2026
…co#42150anomalyco#42176anomalyco#43881anomalyco#43607)
- O(N) text/reasoning delta accumulation instead of O(N^2) string
concat (anomalyco#42150) — the lazy chunk buffer joins on read
- finish reason 'error' is set when a stream fails mid-flight (anomalyco#42176)
- clean-EOF empty provider streams retry like transient errors (anomalyco#43881),
narrowed from the upstream patch: only an attempt that produced no
text/reasoning delta and no tool call qualifies, so providers that
stream content but omit usage/finish are not retried into duplicate
output
- SSE comment heartbeats no longer reset the chunk timeout (anomalyco#43607);
the streaming TextDecoder handles multi-byte characters split across
reads (regression covered)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@moritzscheele