Repository navigation
opencode: surface provider failures instead of silent forever-Working - #169
Merged
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
session/promptfails with a JSON-RPC error carrying the human-readable reason/eventsidecar bus; only~/.local/share/opencode/logrecords theAI_APICallErrorloopThe fix
opencode gets the prompt-stall watchdog (60s default,
ZERON_ACP_PROMPT_STALL_MSoverride), with a new per-specstall_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.Boilerplate no longer disarms the watchdog. The interesting bug: the watchdog cleared its deadline on any incoming notification, and opencode emits
available_commands_updateright aftersession/newon 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 inacp_stallwas fully silent, so the test passed while the field failed; it now emits the boilerplate frame first, making the test honest.Validation
acp_stallsuite green, now covering both specs; the opencode test asserts the hint names the provider.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
variantsin opencode.json), the ACP session advertises aneffortconfig option (categorythought_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_levelskeeping 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'sprovider.list(model.variants); noted as follow-up.🤖 Generated with Claude Code