anomalyco / anomalyco/opencode
Silent SSE terminations: EOF without a finish frame completes the turn, chunkTimeout misses stalled streams, provider error bodies are discarded
@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:
-
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 (
finishReasonstays at the initial"other", surfaced asunknown, all-zero usage). opencode persists it as a completed turn andopencode runexits 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 byexperimental_repairToolCallas aninvalidtool 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). -
chunkTimeoutdoes not bound a stalled model stream. The option is enforced only in the fetch-level SSE wrapper (wrapSSEinpackages/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 (streamTextconsumption inpackages/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 withchunkTimeout: 300000configured. -
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 discardsresponseBody. A 400-class rejection surfaced as a bareInvalid 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
chunkTimeoutoption on the parsed-chunk gap in thestreamTextconsumption 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.