Repository navigation
tui: make API failures explicit and actionable #36582
Description
Summary
The V2 TUI does not model API failures consistently. Many calls use the generated SDK's default { data, error } result without checking error, turn missing data into a valid empty value, fire-and-forget a promise, or catch and discard failures. Other user-initiated failures are shown only in a five-second global toast even though the user is still inside a dialog or workflow that can render a persistent, actionable error state.
This makes transport, routing, authorization, and server errors look like legitimate empty states or successful actions. The motivating example is #36580: /mcp can fail while both MCP surfaces render “No MCP Servers.”
Audit baseline: origin/dev at cf7503687a2485621a690d18c4b0d1ff2060bc3e (2026-07-12). packages/tui/src has 109 sdk.client references across 24 files; only 34 request throwOnError: true.
Environment
- opencode version: V2
devatcf7503687a2485621a690d18c4b0d1ff2060bc3e - OS: N/A — source audit
- Terminal: N/A — source audit
- Shell: N/A — source audit
- Install/channel: V2 development branch
- Active plugins: N/A — source audit
Reproduction
- Cause any audited endpoint to return a transport error, non-2xx response, or body without
data. - Open the corresponding TUI surface or run the corresponding action.
- Observe one of the following:
- a legitimate empty state is rendered;
- the dialog closes as though the action succeeded;
- local UI state advances despite the request failing;
- no user-visible feedback appears;
- a transient toast appears and disappears while the failed workflow has no persistent error/retry state.
Expected Behavior
Every API call has an explicit failure policy based on user impact:
- Required screen/dialog data: render a persistent error state in that surface with the actual error and retry/back controls.
- User mutation: keep the workflow open, preserve input, show pending/error state next to the action, and only commit optimistic state after success or roll it back.
- Background synchronization: preserve the last known good value, expose degraded/stale state where it affects the UI, and log structured diagnostics. Do not substitute an empty collection.
- Best-effort side effect: discarding an error is allowed only when the side effect is truly optional and the rationale is documented.
- Global/session failure: use a persistent global or session-scoped error surface when the failure affects ongoing work. Toasts remain appropriate for brief confirmations and non-blocking notices.
Actual Behavior
P0 — destructive or workflow-critical mutations can fail silently
packages/tui/src/routes/session/question.tsx:48-79— question reply, reject, and single-answer reply are allvoidpromises. The prompt can disappear via events/UI flow with no local pending/error/retry state.packages/tui/src/routes/session/permission.tsx:165-186, 408-431— allow always, reject, and allow once are fire-and-forget. The UI changes stage before success and cannot show an inline failure.packages/tui/src/context/sync.tsx:190-199— auto-approved permission replies are fire-and-forget. Failure leaves the model blocked with no visible reason.packages/tui/src/component/prompt/index.tsx:1058-1090— shell and slash-command submissions are fire-and-forget. Unlike normal prompt submission, they have no error path and submission progress can remain misleading.packages/tui/src/component/prompt/index.tsx:413-418— session abort is fire-and-forget after the interrupt gesture.packages/tui/src/routes/session/index.tsx:562-577— compact/summarize closes the dialog immediately and ignores failure.packages/tui/src/routes/session/index.tsx:610-663— undo swallows abort failure, fires revert without a rejection handler, rewrites the prompt, and closes the dialog before success; redo similarly fires unrevert/revert and mutates prompt state without confirmation.packages/tui/src/routes/session/dialog-message.tsx:29-53— message revert is fire-and-forget while prompt state is rewritten and the dialog closes.packages/tui/src/component/dialog-session-rename.tsx:17-27— rename closes immediately after a fire-and-forget update.packages/tui/src/routes/session/index.tsx:1018-1030— “Background subagents” closes immediately after a fire-and-forget request.
Treatment: pending state in the active prompt/dialog/action, disable duplicate submission, retain user input, render the endpoint error inline, and provide retry/cancel. Do not close or mutate dependent local state until success.
P0 — failed reads are converted into valid empty states
packages/tui/src/context/sync.tsx:164-168— session list usesdata ?? []without checkingerror.packages/tui/src/context/sync.tsx:513-531— the non-blocking bootstrap batch converts command, LSP, MCP, MCP resource, formatter, session-status, and provider-auth failures/missing data to[]/{}. One rejection also preventsstatus = complete, but the user sees no endpoint-specific state. This is the direct class behind tui: MCP server dialogs show an empty list #36580.packages/tui/src/context/sync.tsx:594-650— session messages, todo, and diff use missing data as empty values. Onlysession.getthrows; partial hydration failures can erase/omit visible session state.packages/tui/src/context/project.tsx:38-67— path/project/directory responses are consumed without checkingerror; workspace list/status failures are caught toundefined, with failed status becoming an empty map.packages/tui/src/component/dialog-session-list.tsx:34-42— both browse and search list failures are deliberately converted toundefined; the dialog falls back to cached sync data, so stale results are indistinguishable from a successful load.packages/tui/src/component/dialog-tag.tsx:17-26— file-search error becomes an empty autocomplete list.packages/tui/src/component/prompt/autocomplete.tsx:316-359— file-search errors produce no options and no error state.packages/tui/src/component/dialog-skill.tsx:21-30andpackages/tui/src/component/dialog-console-org.tsx:32-41correctly preserve load errors for inline rendering; these should be the baseline pattern, not exceptions.
Treatment: typed load state (loading | ready | stale | error) per resource. Empty content is only valid after a successful response. Dialogs should use a locked inline error view with retry; background stores should retain last known good data and expose stale/error metadata.
P0 — MCP errors are completely invisible
packages/tui/src/context/sync.tsx:519—/mcpmissing data becomes{}.packages/tui/src/component/dialog-mcp.tsx:51-69— toggle/connect/disconnect and refresh failures only go toconsole.error; the dialog returns to a non-loading row with no explanation.packages/tui/src/component/dialog-status.tsx:53-56—{}is rendered as “No MCP Servers.”packages/tui/src/component/dialog-mcp.tsx:28-45—{}becomes an empty picker.
Treatment: preserve MCP load/error state in sync, render “Could not load MCP servers” with details/retry in both surfaces, and render per-row mutation errors without discarding the previous status.
P1 — workspace/project workflows swallow synchronization and safety-check errors
packages/tui/src/component/dialog-workspace-create.tsx:80-85— workspace sync-list and local workspace sync failures are discarded before opening selection.packages/tui/src/component/dialog-workspace-create.tsx:133-152— post-warp bootstrap and synthetic reminder prompt failures are discarded; the function still returns success.packages/tui/src/component/dialog-workspace-create.tsx:165-175— VCS status failure is treated as “no changes,” skipping the copy-changes safety question.packages/tui/src/component/dialog-workspace-list.tsx:84-92— post-delete bootstrap and initial sync-list failures are discarded.packages/tui/src/component/prompt/move.tsx:117-120— VCS status failure is treated as “no changes” before moving a session.packages/tui/src/component/prompt/move.tsx:139-152— failure to append the synthetic directory-change reminder is discarded after moving.packages/tui/src/component/dialog-move-session.tsx:224-260— when removal requires force, VCS status failure becomes an empty change list and the destructive confirmation continues without the evidence it intended to show.packages/tui/src/routes/session/index.tsx:294-304— workspace bootstrap failure while opening a session is caught with an empty block; the comment says the session should remain visible/non-interactive, but no degraded/non-interactive state explains why.
Treatment: never interpret a failed safety read as “no changes.” Show a persistent blocking error with retry/cancel. For completed primary mutations with failed follow-up sync/reminders, show a partial-success state that tells the user what succeeded and what needs retrying.
P1 — dialog mutations use transient toasts then close or lose context
packages/tui/src/component/dialog-session-list.tsx:101-169, 301-335— create/delete/recovery failures use global toasts. The active dialog already owns the action and should retain a row/dialog error. Some nested async callbacks can also reject outside a local boundary.packages/tui/src/component/dialog-workspace-list.tsx:58-77— delete failure uses a toast while the row has dedicated deletion state.packages/tui/src/component/dialog-workspace-create.tsx:52-69, 100-130— adapter-load and warp failures toast; adapter-load clears the dialog before loading, so failure leaves no persistent surface.packages/tui/src/component/dialog-move-session.tsx:224-274— project-copy deletion failures toast despite an existing stateful dialog and inline load-error pattern.packages/tui/src/component/prompt/workspace.tsx:27-46andpackages/tui/src/component/prompt/move.tsx:30-64, 117-160— create/move failures use toasts while the composer owns progress and destination state.packages/tui/src/component/dialog-provider.tsx:185-207, 265-283— OAuth authorize/callback errors toast and clear the dialog. The manual code path at321-345already demonstrates a better inline error, though it collapses all server errors to “Invalid code.”packages/tui/src/component/dialog-console-org.tsx:92-111— switch-org has no local error boundary; a rejection can escape the select callback.packages/tui/src/routes/session/index.tsx:468-497, 581-601— share/unshare failures toast and close the command dialog.packages/tui/src/component/prompt/index.tsx:999-1019, 1093-1118— create-session and send-prompt failures toast; prompt input/progress should carry a durable error and retry without implying submission succeeded.
Treatment: put the error in the owning dialog/composer row, preserve input/selection, and offer retry. Success confirmations may remain toasts.
P1 — fire-and-forget/read paths can reject without a boundary
packages/tui/src/context/sync.tsx:427-430— LSP refresh from an event has no rejection handler.packages/tui/src/app.tsx:501-538— both startup fork flows handleresult.dataabsence but not promise rejection; the only feedback is a generic toast for a non-throwing error result.packages/tui/src/routes/session/dialog-fork-from-timeline.tsx:22-68andpackages/tui/src/routes/session/dialog-message.tsx:77-103— fork assumesdata!and has no dialog error state.packages/tui/src/component/dialog-session-list.tsx:150-169— workspace removal in recovery has no catch around a callback that should resolve a boolean.packages/tui/src/component/dialog-workspace-list.tsx:89-93—project.workspace.sync()is fired without handling rejection.packages/tui/src/routes/session/permission.tsx,question.tsx, and the mutation sites listed above usevoidas an error policy.
Treatment: shared mutation/task helper that requires explicit pending/success/error handling; lint/test against floating SDK promises unless annotated as best-effort.
P1 — background/global failures are hidden or reduced to transient toasts
packages/tui/src/context/sdk.tsx:85-127— workspace sync startup and the entire SSE loop discard errors. The user has no disconnected/reconnecting state and no last error.packages/tui/src/context/data.tsx:551-564— nine V2 location bootstrap requests usePromise.allSettled, but failures only reachconsole.error; downstream menus can appear empty.packages/tui/src/context/kv.tsx:22-31, 54-61— preference read/write failures only reachconsole.error; users believe changes persisted.packages/tui/src/app.tsx:1018-1028— session execution errors are five-second global toasts. They should be attached to the affected session/turn and remain inspectable.packages/tui/src/app.tsx:1052-1067— update failure is a toast despite originating in a modal workflow, and the actual server error is discarded.
Treatment: persistent connection/degraded indicator; session-scoped error cards/status; persistent settings/persistence warning; modal update failure with actual details and retry.
P2 — error toasts lose useful details
packages/tui/src/ui/toast.tsx:60-78— one global toast slot means a later toast overwrites the previous error; all errors expire after five seconds by default; non-ErrorSDK error bodies become “An unknown error has occurred.”packages/tui/src/component/prompt/index.tsx:1010-1017— create-session error is logged to console but UI says only “Open console for more details.”packages/tui/src/component/dialog-provider.tsx:190-194, 270-277— rawJSON.stringify(error)or generic OAuth copy instead of normalized actionable details.packages/tui/src/routes/session/index.tsx:921-939, 990-1014— copy/export catches discard the actual error.packages/tui/src/app.tsx:1058-1066— update failure discards the endpoint error.
Treatment: normalize generated SDK errors through errorMessage, retain structured details for an expandable error view, and reserve toasts for success/info or genuinely non-blocking failures.
Recommended Design
- Introduce a small TUI async-state model for resource and mutation ownership rather than a generic global error bus:
- resource:
idle | loading | ready(data) | stale(data, error) | error(error); - mutation:
idle | pending | error(error)with retry and preserved input.
- resource:
- Add reusable dialog/select error rendering (
locked, title, normalized message, retry/back) based on the existing Skills and Console Org patterns. - Make required SDK calls use
{ throwOnError: true }; require call sites to either catch into explicit UI state or propagate to an owning boundary. - Keep last known good background data on refresh failure. Never use
data ?? []/{}to erase state unless the response succeeded and the schema permits absent data. - Add session/global degraded surfaces for SSE, bootstrap, and session execution failures.
- Add a lint rule or focused test that rejects floating
sdk.clientpromises and empty catches inpackages/tui/src, with a documentedbestEffort(...)escape hatch. - Add fault-injection tests for transport failures, non-2xx SDK results, and malformed/missing response data.
Acceptance Criteria
- Every
sdk.clientcall inpackages/tui/srchas an explicit, reviewed error policy. - No required read converts an SDK error/missing response into a legitimate empty state.
- No user mutation is fire-and-forget unless explicitly documented as best-effort.
- Dialog-owned failures render persistently in that dialog with retry/back and preserve input/selection.
- Composer-owned failures render persistently in the composer and preserve the unsent prompt/command.
- Session execution errors remain inspectable on the affected session/turn.
- Background refresh failures retain last known good data and expose stale/degraded state.
- SSE/workspace synchronization has a visible disconnected/reconnecting/error state.
- Error rendering preserves normalized server details; unknown structured SDK errors do not collapse to a generic message.
- Tests cover MCP status, session list/search, skills/org/project dialogs, question/permission replies, prompt/shell/command submission, rename/fork/revert/compact, workspace safety checks, and bootstrap partial failure.
- Intentionally ignored failures are wrapped in a named/documented best-effort helper and excluded from user-critical paths.
Intentionally Best-Effort / Out of Scope
These catches should not automatically become blocking UI errors, though they should be documented or logged where useful:
- opening an external URL after the UI already displays the URL (
ui/link.tsx, retry-action/docs links); - renderer palette detection and theme fallback;
- temporary-file cleanup after the primary operation has already failed;
- clipboard backend probing/fallback between platform implementations;
- optional sound/attention behavior;
- reading optional local editor metadata;
- current-checkout lookup used only to highlight one row in
dialog-move-session.tsx:58-67(already documented in code).
Independent Cross-check Additions
A second read-only audit confirmed the list above and found several failure modes that deserve explicit tracking:
packages/tui/src/component/prompt/index.tsx:1055-1118— prompt, shell, and command submission clears input, appends history, and may mark editor context sent without confirmed durable admission. This is stronger than a missing notification: user-authored content can appear sent while being lost. Preserve the exact prompt and attachments until admission succeeds.packages/tui/src/context/sdk.tsx:36-39, 85-116— the SSE retry loop only retries after normal stream completion. A request, stream, synchronization, or event-handler exception reaches the terminal empty catch and permanently ends event delivery. Isolate subscriber exceptions and catch failures inside the retry loop.packages/tui/src/context/sync.tsx:594-654— partial messages/todo/diff failures are cached byfullSyncedSessions, suppressing retries for the rest of the TUI process.packages/tui/src/component/dialog-provider.tsx:395-415— credential save and instance reload responses are unchecked, so the flow can report success or navigate after API failure.packages/tui/src/routes/session/index.tsx:588-600—unshare(...).then(success)can show false success because generated SDK API failures normally resolve as{ error }rather than reject.packages/tui/src/feature-plugins/system/diff-viewer.tsx:114-131— rejected diff resources default to an empty list, and the “No diff!” branch can shadow the later error branch.packages/tui/src/app.tsx:371-385andpackages/tui/src/plugin/slots.tsx:32-45— plugin-host startup and slot-render failures are console-only while the TUI continues as though plugins loaded normally.packages/tui/src/prompt/history.tsxandpackages/tui/src/prompt/stash.tsx— persistence failures are swallowed and writes are not consistently serialized. Stash failures affect user-authored drafts and need persistent dirty/error state.
The central implementation trap is the SDK's dual failure channel: API failures generally resolve in result.error, while transport/runtime failures reject. try/catch alone is not sufficient unless the call opts into { throwOnError: true }.
Additional Context
Related issue: #36580. Modal interaction work in #36302 overlaps on async dialog state, but this issue owns the broader API failure contract across bootstrap, composer, session, workspace, event, and plugin paths.
This issue is an audit/umbrella for the V2 TUI error-state contract. Fixes can land incrementally, but each child change should check off the corresponding call sites here rather than replacing one silent failure with an ephemeral toast.
Activity
mgajda commentedon Oct 18, 2026
Frequency data for the transport/connection slice, aggregated over 2026-09-24 → 2026-10-16.
Denominator: 790
level=ERRORlines in my log. 92 of those (11.6%) areerror=Abortedfrom usercancellation and are excluded as non-actionable.
The connect failures are 100 of 790 (12.7%), split across two stock AI SDK strings:
Cannot connect to API: Unable to connect. Is the computer able to access the url?— 55 (7.0%)Cannot connect to API: Was there a typo in the url or port?— 45 (5.7%)Plus
ProviderHeaderTimeoutError: Provider response headers timed out— 46 (5.8%), tracked in #26602and #56371.
The first two are worth calling out because they are generic SDK defaults, not messages opencode
chose. They assert a cause the client cannot actually know — "Is the computer able to access the
url?" is a guess, and "Was there a typo in the url or port?" is actively misleading when the real
cause is a proxy, DNS, or a refused connection. Both are emitted identically whether the host is
unreachable, the proxy is wrong, or the port is closed, and neither says whether retrying will help.
packages/core/src/aisdk.tsalready hasnetworkFailure(), which classifies these shapes(
ECONNREFUSED,ETIMEDOUT,"fetch failed",UND_ERR*) and walks nested causes to find thespecific code. It is currently module-private and used only for LLM errors — exporting it from a
shared location and formatting a single actionable message from it would cover this slice plus the
models.dev refresh in #56742, which has the identical raw
Transport errorshape.Context: #35325.