Skip to content

opencode: surface provider failures instead of silent forever-Working - #169

Merged
wingleeio merged 2 commits into
mainfrom
zeron/opencode-error-surfacing
Aug 19, 2026
Merged

wingleeio merged 2 commits into
mainfrom
zeron/opencode-error-surfacing

Conversation

@wingleeio

@wingleeio wingleeio commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the report that opencode runs "say Working for a few moments then go blank" with Zen models.

What the wire actually does on provider failure (probed live, 1.18.18)

failure opencode's behavior user experience before this PR
fast rejection (e.g. Zen model without subscription, HTTP 401) session/prompt fails with a JSON-RPC error carrying the human-readable reason already works: error chip renders ("This model requires an active OpenCode Zen subscription")
unreachable / erroring provider stream retries forever, never fails the RPC, emits nothing on session/update or the /event sidecar bus; only ~/.local/share/opencode/log records the AI_APICallError loop Working forever; interrupt then leaves a blank transcript — nothing ever told the user

The fix

  1. opencode gets the prompt-stall watchdog (60s default, ZERON_ACP_PROMPT_STALL_MS override), with a new per-spec stall_hint: the error chip now points at the provider setup (opencode auth list, opencode.json, the opencode log) instead of grok's stale-leader story.

  2. Boilerplate no longer disarms the watchdog. The interesting bug: the watchdog cleared its deadline on any incoming notification, and opencode emits available_commands_update right after session/new on every session — provider dead or not. That single frame disarmed the watchdog for the whole turn, which is why the stall guard shipped in opencode: ACP harness with subagent virtualization via the sidecar event bus #157 never fired in the field. The fake agent in acp_stall was fully silent, so the test passed while the field failed; it now emits the boilerplate frame first, making the test honest.

Validation

  • acp_stall suite green, now covering both specs; the opencode test asserts the hint names the provider.
  • Full harness suite: 131 passed, 0 failed.
  • End-to-end against a real daemon + real opencode 1.18.18 + unreachable provider — the doc now carries:

    Error: OpenCode did not respond to the prompt at all (no wire activity for 8s). The model provider is likely unreachable or rejecting requests — opencode retries these silently and never reports the failure. Check the model/provider setup (opencode auth list, opencode.json) or the opencode log.

Effort selector: fixed (second commit — corrects this PR's own earlier claim)

Wing was right that opencode exposes effort — the t3code screenshot led straight to it. opencode's effort rides model variants: when the session's current model has variants (models.dev metadata, or variants in opencode.json), the ACP session advertises an effort config option (category thought_level, values mirroring the variants), reactive to model switches. The original probe ran against a variant-less model and truncated the one response that showed the option — bad probe, wrong conclusion.

Verified end to end against a real daemon: a run with reasoning High → the harness's existing run-start config set applies effort=high → opencode applies the high variant → the provider request carries its options (reasoningEffort: high, proven via a mock provider logging request bodies).

The existing thought_level machinery needed zero changes — the only gate was the spec's empty reasoning_levels keeping the picker from offering effort at all. The ladder is now Low→Max (registry descriptor mirrored). Variant-less models (the Zen frees today) advertise no option, so the set skips and the agent default applies — same blanket-ladder precedent as pi. Exact per-model ladders would need the sidecar's provider.list (model.variants); noted as follow-up.

🤖 Generated with Claude Code

wing-anara and others added 2 commits August 19, 2026 07:36
A dead or rejecting model provider is INVISIBLE on opencode's ACP wire:
the CLI retries the provider stream forever without failing the
session/prompt RPC, and neither session/update nor the /event sidecar
bus carries the error — only ~/.local/share/opencode/log records the
AI_APICallError retry loop (verified live, 1.18.18, unreachable
provider). Fast rejections (a Zen model without a subscription) DO fail
the RPC and already render an error chip; total silence had no path to
the user at all: Working until interrupt, then a blank transcript.

Two changes:

- opencode gets the prompt-stall watchdog (60s; ZERON_ACP_PROMPT_STALL_MS
  overrides), with a new per-spec stall_hint so the chip says what a
  wedge means for THIS agent — provider/auth advice for opencode, the
  stale-leader story for grok.

- The watchdog no longer counts session boilerplate as a sign of life.
  opencode emits available_commands_update right after session/new on
  EVERY session, provider dead or not; that one frame used to disarm
  the watchdog for the whole turn (the fake in acp_stall was silent, so
  the test passed while the field failed). The fake now emits the
  boilerplate frame first, and only non-boilerplate traffic clears the
  deadline.

Validated end-to-end against a real daemon with an unreachable
provider: the doc now carries the provider-pointing error chip instead
of nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correction to this branch's earlier claim (and PR #157's spec comment):
opencode DOES expose effort over ACP. When the session's current model
has variants (models.dev metadata, or `variants` in opencode.json), the
session advertises an `effort` config option — category thought_level,
values mirroring the variants — and it is reactive to model switches.
The original probe ran against a variant-less model and truncated the
one response that showed it. Verified end to end (1.18.18): run with
reasoning=high → set_config_option effort=high → the provider request
carries the high variant's options.

The existing thought_level machinery (discovery ladder mapping +
run-start config set) needed nothing: the only gate was the spec's
empty reasoning_levels, which kept the picker from ever offering an
effort. Ladder is now Low→Max (registry descriptor mirrored,
descriptor-stability rule); variant-less models advertise no option, so
the run-start set skips and the agent default applies — the pi
precedent for a blanket ladder. Exact per-model ladders would need the
sidecar's provider.list (model.variants): follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wingleeio
wingleeio merged commit b8d9996 into main Aug 19, 2026
1 check passed
kalibetre pushed a commit to kalibetre/comet that referenced this pull request Aug 22, 2026
opencode's own ACP layer settles a prompt on the FIRST session idle it
observes — uncorrelated with the submitted turn — and ignores
session.error and session.idle entirely (packages/opencode/src/acp/event.ts),
which is exactly the 'says it's done when it's not done' bug; it also
drops all subagent traffic. The desktop app doesn't use ACP, and now
neither do we.

OpencodeHarness speaks the same v1 wire the opencode desktop app does:
spawn `opencode serve --port <free>` with a per-run Basic password, drive
POST /session/{id}/prompt_async, and read the /global/event SSE bus.

- Done is truthful by construction: only session.status{idle} for OUR
  session settles the turn (deterministic_turn_end retires the engine's
  quiesce watchdog). No quiet-settle heuristics.
- Thinking streams: reasoning parts/deltas map to ReasoningDelta.
- Provider failures are visible (the zeronsh#169 class): retry status surfaces an
  error chip at attempt 3 and aborts at 8 instead of retrying forever; a
  turn with no sign of life within ZERON_OPENCODE_STALL_MS (60s) errors
  out with the provider hint.
- Subagents ride the same bus natively: task parts register chips, child
  sessions bind by spawn metadata (title-match fallback), traffic streams
  tagged, the completing task part settles the chip. Child permission asks
  auto-approve too (the ACP layer silently dropped them and subagents hung).
- Live model discovery off GET /provider ({providerID}/{modelID} ids;
  effort ladder maps to the model's advertised reasoning variants and rides
  prompts as `variant`). Slash commands from GET /command route through
  the command endpoint. Resume reuses the durable server-side session.

The ACP opencode spec, the /event sidecar tracker, and the per-run --port
plumbing are gone; grok keeps its own tracker. Covered by a scripted
in-test HTTP/SSE fake (tests/opencode.rs) where the fixtures own the bus
clock — including the foreign-session-idle regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ogdakke added a commit to ogdakke/comet that referenced this pull request Aug 23, 2026
* Fix transcript viewport restoration

* fix(ui): refine panel resizing and alignment

* fix(ui): gate Git add menu by workspace

* fix(ui): unify new-session action in titlebar

* fix(ui): size project menu with sidebar

* fix(ui): reverse right-pane takeover icon

* Anchor live streams at the transcript end

* Add entry-level message copying and spacing refinements

* Fix transcript copy fidelity and spacing parity

* Fix viewport test row after upstream merge

* v0.2.20

* Reasoning becomes a first-class transcript part

AgentEvent::ReasoningDelta was dropped at the doc fold for every harness —
no client ever rendered thinking. It now folds into a new 'reasoning' doc
part and renders as a collapsible Thinking block on desktop:

- doc: MessagePart::Reasoning rides a dedicated 'reasoning' LoroText field
  (never 'text' — old desktop readers' unknown-kind fallback renders 'text'
  as prose, so thinking on this field degrades to an invisible empty part;
  old iOS drops unknown kinds). Streaming appends RLE-merge like text, the
  WatchDocMessages hot path carries reasoning growth as TextAppend deltas,
  oversized thinking splits at the continuation cap, and salvage recovers
  the field.
- ui: a Thinking row (tool-group header recipe: chevron tile + quiet label)
  auto-opens while it is the live tail of a streaming reply and collapses
  once settled; a user toggle overrides either way. Body renders dimmed
  along the guide rail.
- edge TS mirror: 'reasoning' kind in the doc part codec + split gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* opencode: native HTTP/SSE driver replaces the ACP path

opencode's own ACP layer settles a prompt on the FIRST session idle it
observes — uncorrelated with the submitted turn — and ignores
session.error and session.idle entirely (packages/opencode/src/acp/event.ts),
which is exactly the 'says it's done when it's not done' bug; it also
drops all subagent traffic. The desktop app doesn't use ACP, and now
neither do we.

OpencodeHarness speaks the same v1 wire the opencode desktop app does:
spawn `opencode serve --port <free>` with a per-run Basic password, drive
POST /session/{id}/prompt_async, and read the /global/event SSE bus.

- Done is truthful by construction: only session.status{idle} for OUR
  session settles the turn (deterministic_turn_end retires the engine's
  quiesce watchdog). No quiet-settle heuristics.
- Thinking streams: reasoning parts/deltas map to ReasoningDelta.
- Provider failures are visible (the zeronsh#169 class): retry status surfaces an
  error chip at attempt 3 and aborts at 8 instead of retrying forever; a
  turn with no sign of life within ZERON_OPENCODE_STALL_MS (60s) errors
  out with the provider hint.
- Subagents ride the same bus natively: task parts register chips, child
  sessions bind by spawn metadata (title-match fallback), traffic streams
  tagged, the completing task part settles the chip. Child permission asks
  auto-approve too (the ACP layer silently dropped them and subagents hung).
- Live model discovery off GET /provider ({providerID}/{modelID} ids;
  effort ladder maps to the model's advertised reasoning variants and rides
  prompts as `variant`). Slash commands from GET /command route through
  the command endpoint. Resume reuses the durable server-side session.

The ACP opencode spec, the /event sidecar tracker, and the per-run --port
plumbing are gone; grok keeps its own tracker. Covered by a scripted
in-test HTTP/SSE fake (tests/opencode.rs) where the fixtures own the bus
clock — including the foreign-session-idle regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* opencode: bound every HTTP call; a boot-window request parks forever

Live finding (1.18.18): during opencode serve's boot window the socket
ACCEPTS but the request is parked — and one parked early enough is never
answered at all, so an unbounded send() wedged startup past the whole 300s
budget. Health probes now time out at 2s and re-poll; ordinary calls get a
60s bound. The synchronous per-turn command endpoint deliberately stays
unbounded (its response can take the whole turn and is ignored — the bus
carries the real events), and the interrupt-path abort gets its own 5s
bound so a wedged server can't block the event loop.

Live-validated end to end against real opencode 1.18.18 + a scripted
OpenAI-compatible provider streaming reasoning_content: parent and
subagent thinking stream as ReasoningDelta, the task chip binds/settles,
and the turn completes only on its own session's idle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* opencode: rustfmt the new driver module

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* opencode: gate the first prompt on a live event subscription

Live finding on fresh 1.18.21: the v1 bus has NO replay, and a
fast-failing turn (a bad model id errors in ~200ms) can emit its whole
busy → session.error → idle lifecycle before a late /global/event
subscription exists — the driver then saw nothing and fell back to the
60s stall watchdog instead of surfacing the real error. The bus reader
now reports each stream (re)connect after its first frame; the first
prompt waits (bounded 15s) for the subscription to be live, and a
RECONNECT during an active turn re-syncs from GET /session/status so a
gap can no longer swallow the idle that ends the turn.

Re-validated against opencode 1.18.21 installed via the official
installer: the mock subagent+thinking scenario, the bad-model fast
failure (error chip + Done{Errored} in 2s, previously 60s), and a REAL
anonymous OpenCode Zen model (opencode/mimo-v2.5-free) end to end —
reasoning streamed, a real bash tool call executed, usage reported,
truthful Completed settle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.21

* opencode: the picker offers only connected providers' models

Field report (v0.2.21): the model picker was slow to open, slow to
scroll, and full of models the user can't run. GET /provider's `all` is
the entire models.dev catalog — measured live at 194 providers / 7,203
models, nearly all needing an API key the user hasn't set; picking one
just fails the run with "Model not found". The response's `connected`
list names exactly the usable set (credentialed + config-declared + the
anonymous Zen tier), so discovery now filters to it (falling back to the
full catalog only when the field is absent/empty — older servers). On
this rig that's 7 models instead of 7,203; the lag was the list.

Also from the same report: opencode emits each provider failure TWICE on
the bus (once bare, once wrapped in an exception-name/stack prefix), so
every failed turn rendered two identical error chips. Consecutive
session.error events whose first lines contain each other now dedupe to
one chip.

Plus a live models probe example (cursor precedent):
    cargo run -p zeron-harness --example opencode_models_probe

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.22

* Model picker scales to thousand-model catalogs; rows attribute their provider

Field follow-up on the opencode picker work: the picker must handle
LARGE lists (not just rely on the connected filter keeping them small),
and identically-named models need visible provenance — 'GLM-5.2' exists
under 64 providers in the catalog, so a user with several connected
providers saw indistinguishable duplicate rows.

- The model list is now a virtualized uniform_list: only the visible
  slice renders, so a 7,203-row catalog opens and scrolls as smoothly as
  seven (validated live against the full unfiltered models.dev catalog
  through the real pipeline). The floating scrollbar reads the
  UniformListScrollHandle's base handle; keyboard nav scrolls via
  scroll_to_item(Nearest).
- The flattened row list is cached per (query, rail, harness, lock,
  catalog revision): it was rebuilt — with per-row Model clones, ranked
  and sorted — 2-3x per keystroke and again per render. Favorite lookups
  use a set instead of a per-row Vec scan.
- Rows keep the harness subline and append the model's description when
  it adds information: opencode ships the provider display name there,
  so duplicate names read 'OpenCode · HPC-AI' vs 'OpenCode · Nvidia'.
  The search haystack includes the description, so typing a provider
  name filters to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.23

* Thinking rides the tool-group accordion

Field follow-up on the thinking feature: the standalone 'Thought process'
row didn't animate like the other transcript accordions (its body
mounted at intrinsic height — no analytic height, no tween), and wing
wants thinking INSIDE the combined 'Ran N commands' fold rather than as
its own row between groups.

Reasoning parts now become thought chips in the tool group itself:

- rows_for_entry routes MessagePart::Reasoning into the pending group
  (agent/spawn groups stay pure, same as the tool genus rule), so
  thought → tool → thought → tool folds into ONE accordion row.
- A thought chip is a synthesized ToolItem ('Thought process' header,
  chat bubble icon) whose text is an output-style detail: soft-wrapped
  at word boundaries into lines, capped like tool outputs (a STREAMING
  thought keeps the tail — the fresh thinking is the signal). Line-based
  details have ANALYTIC heights, so the group's existing 200ms fold
  tween covers thoughts with zero new animation code — the smoothness
  ask is satisfied by construction.
- A live thought chip defaults open (the group auto-opens as the live
  tail already); it settles closed once text or a tool follows. User
  toggles override either way via the existing per-chip fold state.
- The collapsed summary names the thinking: 'Thought process' for a
  thought-only group, 'Thought 3 times · Ran 2 commands' when mixed
  (thought chips are UI-synthesized, so the shared view summary never
  sees them and the terminal viewport is untouched).
- RowKind::Thinking, its renderer, and the special top-gap case are
  deleted — one fewer bespoke row kind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.24

* Chat rows survive configs from newer peers; iOS learns opencode

Field report (multi-device): new sessions never appeared in a work
laptop's sidebar while old sessions kept updating, and on iOS opencode
sessions wore the claude mark and opened showing claude/Fable.

Two independent gaps, one cause — the fleet learned a harness id
('opencode', v0.2.10) that older surfaces don't know:

- Desktop: the typed row decode (read_kind → RawChat) SKIPPED any row it
  couldn't parse, and ChatConfig.harness is a closed enum — so a client
  older than the newest harness id drops every such chat row wholesale.
  Exactly 'old rows update, new rows invisible'. The config field now
  decodes leniently: an unknown harness/reasoning/sandbox value costs
  the CONFIG (row renders with generic defaults), never the row. Same
  rule as transcript salvage: a missing field costs at most what it
  carried. Regression-tested with a future-harness row.
- iOS: HarnessCatalog and BrandMark had no 'opencode' entry, so the
  default arms answered with the claude mark and the claude model list.
  Added the label, the mark (opencode-mark.svg's frame path), and a
  static model fallback (the anonymous Zen tier; a reachable host still
  answers listModels with live discovery).

The laptop itself needs an app update to pick this up — the tolerance
protects every FUTURE skew, it cannot reach builds that predate it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.25

* Registry cursor can no longer jump over unapplied rows

Field incident, root-caused for real this time (the earlier stale-version
theory was wrong — the affected laptop ran v0.2.24): new sessions created
on one device never appeared in another device's sidebar, while updates
to already-known rows kept flowing.

The hole is deterministic on the HTTPS transport (used exactly when a
network strips WebSocket upgrades — zeronsh#168's dual transport, v0.2.12): one
sync cycle pushes before it pulls, the push ack set the cursor to OUR
batch's seq, and the pull then fetched 'since' that jumped cursor —
permanently skipping every batch other devices wrote since the last
sync. A single local write (a seen-marker sufficed) while behind made
peers' new chats invisible forever; rows touched later self-healed,
which is why statuses kept updating.

- ack_batch no longer advances the cursor (the ack's seq is our own
  batch; the cursor only moves with actual row payloads — re-pulling our
  own batch is an idempotent LWW no-op).
- apply_rows guards contiguity: a broadcast past a seq gap (hibernating
  DO, half-dead socket) applies its rows but HOLDS the cursor and the
  client reconnects — the fresh hello backfills the window.
- Persisted snapshots gain a resync epoch: cursors written before this
  fix may already have jumped past invisible rows, so the first load
  after upgrading zeroes the cursor once and pulls full state (rows are
  kept; local-only rows re-seed through the existing full-state path).
- iOS mirrors all three (its WS ordering has masked the ack bug so far;
  gap-hold converges on the next natural reconnect).

Verified against production data: the 'missing' chats and their space
rows are complete and correct in the registry — the failure was purely
the reader's cursor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.26

* Orphan sweep waits for server truth; unreadable HTTP ack retries

Two residuals from the missing-sessions audit (v0.2.26 follow-up):

- sweep_orphans ran on whatever the local replica held, including a
  replica that had not heard from the server that boot (gapped cursor,
  pre-heal, offline at boot). On such a view "space row absent" means
  "not received yet", not "deleted" — and the sweep's deletes are
  global and permanent. The sweep now gates on a `synced` latch that
  flips once a server state applies this process (WS hello answer or
  HTTPS pull); local-only hosts (no edge configured) are exempt since
  their table is the whole truth.

- spawn_offline_sync silently ignored a push ack it could not parse
  (captive portal / proxy bodies), leaving the batch marked in-flight.
  With no socket session there is no disconnect to un-mark it, so the
  write was stranded until process restart. An unreadable ack now
  counts as a failed push: the batch becomes pushable again and the
  next cycle retries it.

`synced` also rides RoomStatsSnapshot / SyncStatus for observability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.27

* ci: add permanent internal TestFlight workflow (zeronsh#213)

* Promote queued prompts on delivery and keep hang-turn tests live

write_user_message treated an existing queued id as a no-op, so drain
never flipped the row to Complete. WorkOS-unset boot without a bearer
also failed to enter development, which broke the offline-dev test.

Hang-turn e2e scripts no longer emit Done before interrupt, so a
steerable harness cannot park and empty the queue early.

* Silence merge leftover compile warnings

Wire the model-picker scrollbar drag-move listener so grab_offset is
read. Drop unused RunRequest bindings in test harnesses, stop importing
CHAT_PANEL_MIN into the shell lib, and allow TILE_CORNER_RADIUS on
non-macOS where hover surfaces are not painted.

* Apply the remaining upstream merge leftovers

Right-pane drag and target width now keep a 300px chat floor and use
takeover width instead of the old 52% cap. Opening the model picker
revalidates catalogs and retries a cold OpenCode probe. Studio's
titlebar uses the same right inset as chat. PARITY.md lists Cursor,
Pi, and OpenCode as native drivers.

* Keep queued prompts in the tray until the live turn ends

Queued user entries were painted as transcript bubbles the moment they were
parked. Skip them in rows_for_entry, and add an e2e that the follow-up stays
Queued and unsent until the live turn commits it.

---------

Co-authored-by: José Gurruchaga <jsgrrchg@gmail.com>
Co-authored-by: gaelcado <cado.gael@gmail.com>
Co-authored-by: Wing <44917547+wingleeio@users.noreply.github.com>
Co-authored-by: wing-anara <wing@anara.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
guilhermexp pushed a commit to guilhermexp/comet that referenced this pull request Aug 30, 2026
…acing

opencode: surface provider failures instead of silent forever-Working
guilhermexp pushed a commit to guilhermexp/comet that referenced this pull request Aug 30, 2026
opencode's own ACP layer settles a prompt on the FIRST session idle it
observes — uncorrelated with the submitted turn — and ignores
session.error and session.idle entirely (packages/opencode/src/acp/event.ts),
which is exactly the 'says it's done when it's not done' bug; it also
drops all subagent traffic. The desktop app doesn't use ACP, and now
neither do we.

OpencodeHarness speaks the same v1 wire the opencode desktop app does:
spawn `opencode serve --port <free>` with a per-run Basic password, drive
POST /session/{id}/prompt_async, and read the /global/event SSE bus.

- Done is truthful by construction: only session.status{idle} for OUR
  session settles the turn (deterministic_turn_end retires the engine's
  quiesce watchdog). No quiet-settle heuristics.
- Thinking streams: reasoning parts/deltas map to ReasoningDelta.
- Provider failures are visible (the zeronsh#169 class): retry status surfaces an
  error chip at attempt 3 and aborts at 8 instead of retrying forever; a
  turn with no sign of life within ZERON_OPENCODE_STALL_MS (60s) errors
  out with the provider hint.
- Subagents ride the same bus natively: task parts register chips, child
  sessions bind by spawn metadata (title-match fallback), traffic streams
  tagged, the completing task part settles the chip. Child permission asks
  auto-approve too (the ACP layer silently dropped them and subagents hung).
- Live model discovery off GET /provider ({providerID}/{modelID} ids;
  effort ladder maps to the model's advertised reasoning variants and rides
  prompts as `variant`). Slash commands from GET /command route through
  the command endpoint. Resume reuses the durable server-side session.

The ACP opencode spec, the /event sidecar tracker, and the per-run --port
plumbing are gone; grok keeps its own tracker. Covered by a scripted
in-test HTTP/SSE fake (tests/opencode.rs) where the fixtures own the bus
clock — including the foreign-session-idle regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
guilhermexp pushed a commit to guilhermexp/comet that referenced this pull request Aug 30, 2026
opencode's own ACP layer settles a prompt on the FIRST session idle it
observes — uncorrelated with the submitted turn — and ignores
session.error and session.idle entirely (packages/opencode/src/acp/event.ts),
which is exactly the 'says it's done when it's not done' bug; it also
drops all subagent traffic. The desktop app doesn't use ACP, and now
neither do we.

OpencodeHarness speaks the same v1 wire the opencode desktop app does:
spawn `opencode serve --port <free>` with a per-run Basic password, drive
POST /session/{id}/prompt_async, and read the /global/event SSE bus.

- Done is truthful by construction: only session.status{idle} for OUR
  session settles the turn (deterministic_turn_end retires the engine's
  quiesce watchdog). No quiet-settle heuristics.
- Thinking streams: reasoning parts/deltas map to ReasoningDelta.
- Provider failures are visible (the zeronsh#169 class): retry status surfaces an
  error chip at attempt 3 and aborts at 8 instead of retrying forever; a
  turn with no sign of life within ZERON_OPENCODE_STALL_MS (60s) errors
  out with the provider hint.
- Subagents ride the same bus natively: task parts register chips, child
  sessions bind by spawn metadata (title-match fallback), traffic streams
  tagged, the completing task part settles the chip. Child permission asks
  auto-approve too (the ACP layer silently dropped them and subagents hung).
- Live model discovery off GET /provider ({providerID}/{modelID} ids;
  effort ladder maps to the model's advertised reasoning variants and rides
  prompts as `variant`). Slash commands from GET /command route through
  the command endpoint. Resume reuses the durable server-side session.

The ACP opencode spec, the /event sidecar tracker, and the per-run --port
plumbing are gone; grok keeps its own tracker. Covered by a scripted
in-test HTTP/SSE fake (tests/opencode.rs) where the fixtures own the bus
clock — including the foreign-session-idle regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants