anomalyco / anomalyco/opencode

Silent SSE terminations: EOF without a finish frame completes the turn, chunkTimeout misses stalled streams, provider error bodies are discarded

Open
#39,968 1 comment 0 reactions 1 assignee View on GitHub

@kitlangton is already working on this.

Since Jul 31, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Version: opencode 1.18.2 (also present on current dev), Linux, openai-compatible provider behind an HTTP gateway.

Over a ~12h window of a long-running multi-agent workload (13,312 model requests), the gateway terminated 23 in-flight SSE completion responses with a bare EOF: connection close with no finish frame, no [DONE], no error event (~0.17% of requests; several breaks landed within ~1s of each other on independent machines, so a shared upstream cause). A 24th connection stalled half-open instead of closing. opencode's handling turned this transport blip into wrong results three ways:

  1. EOF without a finish frame concludes the turn as a success. When the stream ends without a finish frame, the AI SDK emits its synthesized fallback finish-step (finishReason stays at the initial "other", surfaced as unknown, all-zero usage). opencode persists it as a completed turn and opencode run exits 0 with the truncated output. In our window: 7 truncated turns accepted as complete, and 16 mid-tool-call truncations whose cut-off tool JSON was absorbed by experimental_repairToolCall as an invalid tool call — the agents then misattributed the failure ("payload too large") and permanently changed strategy. There is even a test pinning the exit-0 behavior (unknown stream finish preserves partial output and exits 0).

  2. chunkTimeout does not bound a stalled model stream. The option is enforced only in the fetch-level SSE wrapper (wrapSSE in packages/opencode/src/provider/provider.ts), which watches raw bytes. Any bytes — SSE keepalive/comment lines included — reset that timer even when the parsed model stream delivers nothing, and a stall in any layer above the byte stream never trips it. The model-call path (streamText consumption in packages/opencode/src/session/llm.ts / session/processor.ts) has no per-chunk deadline at all. One stage sat on a half-open connection for 9.5 hours with chunkTimeout: 300000 configured.

  3. Provider rejections lose their response bodies. ProviderError.message() (packages/opencode/src/provider/error.ts) returns the SDK's message alone whenever it differs from the bare HTTP status text and discards responseBody. A 400-class rejection surfaced as a bare Invalid request. with nothing to diagnose.

Suggested behavior:

  • treat EOF-without-finish (fallback finish-step: reason other/unknown + no raw finish reason + no usage) as a retryable stream error with a small bounded in-turn retry budget, failing the turn (nonzero exit) on exhaustion instead of exiting 0 with half a turn;
  • enforce the existing chunkTimeout option on the parsed-chunk gap in the streamText consumption path (disarmed while local tool calls execute), aborting the request on expiry;
  • keep a bounded copy of the provider response body in the surfaced error message.

I have a fix for all three with regression tests and will open a PR referencing this issue.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.