You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Session exits silently (exit 0, no error) when a turn returns finish "unknown": prompt.ts:1113 omits it from the loop-exit exclusion #43622
packages/opencode/src/session/prompt.ts:1113 treats an assistant finish of "unknown" as a completed turn and breaks the agent loop. The session logs "exiting loop", disposes the instance and exits 0 with no error, discarding the turn. For a headless driver this is indistinguishable from the agent deciding it was done.
About 180 lines later the same file disagrees with itself: :1295 computes finished as finish && !["tool-calls", "unknown"].includes(finish), correctly excluding "unknown", while :1113 excludes only "tool-calls".
Six of sixteen headless implement sessions died this way in a single day driving opencode-go/deepseek-v4-flash, across five unrelated tasks at 46k to 230k context. Identical logs each time, with step_finish reporting all-zero usage and zero cost. An external driver reasonably reads exit 0 with no error as success, so the work is silently dropped.
Please do not ship the one-liner on its own
Adding "unknown" at :1113 removes the silent exit but produces an unbounded retry loop (138 steps, no cap observed). The shape that already works is at :1301, where a content-filter finish is surfaced as an error, with the comment "previously the session went idle silently". Same bug class, already fixed once for a different finish reason.
Note on history
An older checkout carries !["tool-calls", "unknown"] at the equivalent site. It is absent from v1.15.0, v1.16.0, v1.17.0, v1.18.0, v1.18.10, v1.18.18 and v1.18.19, and the surrounding condition was refactored somewhere in that window (!hasToolCalls added, the id comparison changed to parentID). It reads as a guard dropped during a refactor rather than a deliberate removal.
Environment
opencode 1.18.18 and 1.18.19, macOS arm64
Headless opencode run --format json, driven by an external loop
Reproduced against a local fake provider, so it is not provider-specific
Repro script and full session logs available on request.
message="exiting loop" #38801: message="exiting loop" — related discussion on the loop exit condition and id comparison bug
Your report has excellent detail (code references, reproduction table, real-world impact data) that may help advance the fix if the above issues don't fully capture the :1113 vs :1295 inconsistency angle.
Confirmed with live data on the new Ox Alpha Free model (providerID=opencode, modelID=x-preview-f-free): repeated assistant messages persisted with finish=unknown, zero output tokens, and no error field, while the log showed exiting loop — exactly the loop-exit gap described here. The inconsistency between the exit check (only excludes tool-calls) and the finished computation (also excludes unknown) is real in current dev.
One addition to the analysis: fixing only the loop side would make the loop re-enter processing without backoff. The cleaner trigger point is SessionProcessor.process: after a clean drain, a stream that ended with finish === "unknown" and zero output tokens can be failed with ProviderError.ResponseStreamError, which MessageV2.fromError already maps to a retryable APIError. That routes it through the existing SessionRetry.policy (backoff, up to 5 retries, retry status visible in the UI) and surfaces a real error via halt if retries are exhausted.
Opened PR #43881 implementing this. Long-running task verification is still being performed.
We shipped changes in v1.18.20 to retry midstream network errors that were previously being mapped to finish: unknown. Please update to v1.18.20 or newer and let us know if this still reproduces. If it does, it would be useful to distinguish a dropped/midstream network stream from a clean stream that ends without a finish reason.
Still reproduces on v1.18.21 (macOS arm64, headless opencode run --format json) — but the failure mode has changed since 1.18.19, and we can now answer the dropped-vs-clean question empirically. Setup: a local OpenAI-compatible fake provider; the only variable between runs is the shape of the SSE stream it serves.
Three stream shapes, everything else held constant:
stream shape
behavior on v1.18.21
terminal state
finish_reason:"stop" present (control)
1 step, step-finish reason:"stop", usage reported
exit 0, healthy
socket cut mid-body: 2 content chunks, connection aborted, no [DONE], no finish chunk
visible APIError error event on the JSON stream, exit 1 — the v1.18.20 midstream-retry change works ✅
clean drain: [DONE] sentinel sent, but no non-null finish_reason anywhere
hot unbounded loop: 982 turns in 38s (~26 turns/s), every step-finish is reason:"unknown" with all-zero tokens/cost, zero error events, no backoff
never exits on its own; had to be killed externally
(A second run of the same clean-drain shape spun 1,661 turns / 88s before being killed.)
To answer the two questions directly:
Yes, it still reproduces on ≥ v1.18.20 — but as a hang, not a silent success. The midstream-retry change stopped the drained-empty stream from mapping to a completed turn terminally, but nothing catches it either: each turn still "completes" cleanly as far as the SDK can tell, so the prompt loop re-enters immediately — no cap, no backoff, no error. Against a real gateway, every spin re-sends and re-bills the full context window, so an overnight driver can burn significant spend per incident.
The two cases are already distinguishable by behavior today. A dropped/midstream stream throws inside the SDK and gets the proper retry-then-visible-error treatment (verified above — that path is fixed). A clean stream that ends without a finish reason throws nothing, so none of that machinery engages: it flows through as a finished turn carrying finish: "unknown". The discriminator is right there in the payload: a clean drain whose finish is "unknown" with zero output tokens. Failing that shape as a retryable provider error would route the quiet case into the same backoff-and-halt path the dropped case already takes — which matches the approach in fix(session): retry empty unknown-finish streams instead of stopping #43881.
Repro is a ~90-line Go server serving chat.completion.chunk frames in three modes against @ai-sdk/openai-compatible; happy to attach the script plus the raw run.jsonl captures for all three runs if useful.
Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.
Activity
github-actions commentedon Aug 20, 2026
This issue might be a duplicate of existing issues. Please check:
Your report has excellent detail (code references, reproduction table, real-world impact data) that may help advance the fix if the above issues don't fully capture the :1113 vs :1295 inconsistency angle.
docs(opencode): record which build the fleet runs, and why not opencode2
moritzscheele commentedon Aug 21, 2026
Confirmed with live data on the new Ox Alpha Free model (
providerID=opencode,modelID=x-preview-f-free): repeated assistant messages persisted withfinish=unknown, zero output tokens, and no error field, while the log showedexiting loop— exactly the loop-exit gap described here. The inconsistency between the exit check (only excludestool-calls) and thefinishedcomputation (also excludesunknown) is real in currentdev.One addition to the analysis: fixing only the loop side would make the loop re-enter processing without backoff. The cleaner trigger point is
SessionProcessor.process: after a clean drain, a stream that ended withfinish === "unknown"and zero output tokens can be failed withProviderError.ResponseStreamError, whichMessageV2.fromErroralready maps to a retryableAPIError. That routes it through the existingSessionRetry.policy(backoff, up to 5 retries, retry status visible in the UI) and surfaces a real error viahaltif retries are exhausted.Opened PR #43881 implementing this. Long-running task verification is still being performed.
opencode-agent commentedon Aug 21, 2026
We shipped changes in v1.18.20 to retry midstream network errors that were previously being mapped to
finish: unknown. Please update to v1.18.20 or newer and let us know if this still reproduces. If it does, it would be useful to distinguish a dropped/midstream network stream from a clean stream that ends without a finish reason.lecstor commentedon Aug 22, 2026
Still reproduces on v1.18.21 (macOS arm64, headless
opencode run --format json) — but the failure mode has changed since 1.18.19, and we can now answer the dropped-vs-clean question empirically. Setup: a local OpenAI-compatible fake provider; the only variable between runs is the shape of the SSE stream it serves.Three stream shapes, everything else held constant:
finish_reason:"stop"present (control)step-finish reason:"stop", usage reported[DONE], no finish chunkAPIErrorerror event on the JSON stream, exit 1 — the v1.18.20 midstream-retry change works ✅[DONE]sentinel sent, but no non-nullfinish_reasonanywherestep-finishisreason:"unknown"with all-zero tokens/cost, zero error events, no backoff(A second run of the same clean-drain shape spun 1,661 turns / 88s before being killed.)
To answer the two questions directly:
Yes, it still reproduces on ≥ v1.18.20 — but as a hang, not a silent success. The midstream-retry change stopped the drained-empty stream from mapping to a completed turn terminally, but nothing catches it either: each turn still "completes" cleanly as far as the SDK can tell, so the prompt loop re-enters immediately — no cap, no backoff, no error. Against a real gateway, every spin re-sends and re-bills the full context window, so an overnight driver can burn significant spend per incident.
The two cases are already distinguishable by behavior today. A dropped/midstream stream throws inside the SDK and gets the proper retry-then-visible-error treatment (verified above — that path is fixed). A clean stream that ends without a finish reason throws nothing, so none of that machinery engages: it flows through as a finished turn carrying
finish: "unknown". The discriminator is right there in the payload: a clean drain whose finish is"unknown"with zero output tokens. Failing that shape as a retryable provider error would route the quiet case into the same backoff-and-halt path the dropped case already takes — which matches the approach in fix(session): retry empty unknown-finish streams instead of stopping #43881.Repro is a ~90-line Go server serving
chat.completion.chunkframes in three modes against@ai-sdk/openai-compatible; happy to attach the script plus the rawrun.jsonlcaptures for all three runs if useful.lecstor commentedon Aug 22, 2026
btw, this only happens on OpenCode Go, if I swap to Ox Alpha on Open Router I do not see any drops.
lecstor commentedon Aug 25, 2026
also seeing the same drop issue with DeepSeek V4 Flash on OpenCode Go
thdxr commentedon Oct 11, 2026
Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (
opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.— from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊