Skip to content

tui: make API failures explicit and actionable #36582

Open

Description

@opencode-agent

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 dev at cf7503687a2485621a690d18c4b0d1ff2060bc3e
  • 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

  1. Cause any audited endpoint to return a transport error, non-2xx response, or body without data.
  2. Open the corresponding TUI surface or run the corresponding action.
  3. 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 all void promises. 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 uses data ?? [] without checking error.
  • 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 prevents status = 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. Only session.get throws; partial hydration failures can erase/omit visible session state.
  • packages/tui/src/context/project.tsx:38-67 — path/project/directory responses are consumed without checking error; workspace list/status failures are caught to undefined, 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 to undefined; 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-30 and packages/tui/src/component/dialog-console-org.tsx:32-41 correctly 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 — /mcp missing data becomes {}.
  • packages/tui/src/component/dialog-mcp.tsx:51-69 — toggle/connect/disconnect and refresh failures only go to console.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-46 and packages/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 at 321-345 already 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 handle result.data absence 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-68 and packages/tui/src/routes/session/dialog-message.tsx:77-103 — fork assumes data! 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 use void as 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 use Promise.allSettled, but failures only reach console.error; downstream menus can appear empty.
  • packages/tui/src/context/kv.tsx:22-31, 54-61 — preference read/write failures only reach console.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-Error SDK 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 — raw JSON.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

  1. 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.
  2. Add reusable dialog/select error rendering (locked, title, normalized message, retry/back) based on the existing Skills and Console Org patterns.
  3. Make required SDK calls use { throwOnError: true }; require call sites to either catch into explicit UI state or propagate to an owning boundary.
  4. 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.
  5. Add session/global degraded surfaces for SSE, bootstrap, and session execution failures.
  6. Add a lint rule or focused test that rejects floating sdk.client promises and empty catches in packages/tui/src, with a documented bestEffort(...) escape hatch.
  7. Add fault-injection tests for transport failures, non-2xx SDK results, and malformed/missing response data.

Acceptance Criteria

  • Every sdk.client call in packages/tui/src has 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 by fullSyncedSessions, 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-385 and packages/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.tsx and packages/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

  1. mgajda commented on Oct 18, 2026

    @mgajda

    Frequency data for the transport/connection slice, aggregated over 2026-09-24 → 2026-10-16.

    Denominator: 790 level=ERROR lines in my log. 92 of those (11.6%) are error=Aborted from user
    cancellation 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 #26602
    and #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.ts already has networkFailure(), which classifies these shapes
    (ECONNREFUSED, ETIMEDOUT, "fetch failed", UND_ERR*) and walks nested causes to find the
    specific 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 error shape.

    Context: #35325.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

2.0bugSomething isn't workingtui

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions