Skip to content

OPE-276 Show structured session errors - #354

Merged
ivanfenenko merged 6 commits into
mainfrom
feature/ope-276-structured-errors
Oct 10, 2026
Merged

ivanfenenko merged 6 commits into
mainfrom
feature/ope-276-structured-errors

Conversation

@ivanfenenko

@ivanfenenko ivanfenenko commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Decode v2 structured errors (status and response.body) and keep the failure visible beside assistant text on Android and iOS.
  • Show the message and HTTP status as a quiet mark: a small dot and a weak caption. The dot uses the error color. permission.rejected stays neutral. The provider body stays in the detail sheet titled "Provider response". Abort and interrupt stay silent.
  • Project failed compaction and idle (outcome: failed) so they survive reload. A live failure that already has a timeline row does not also raise the snackbar.
  • Read v2 retry at and error.message. The home-list label stays Retrying · N. The reason appears on the in-chat working label.
  • Map known HTTP tags (pairing expired, missing path, session busy, unknown ref). A tool call that did not run shows "Not run: malformed tool call", and a tool error stays visible when output exists.
  • Actions the app can perform open from that sheet. Pick a model opens the model picker. Compact runs the existing compact command. Copy of a provider body stays on the monospace text. Copy with no body copies the caption. A provider.auth failure keeps the mark and has no button, because there is no in-app reconnect.
  • Rate-limit and quota show a status on the composer: the message, "attempt N, retrying in Xs", and the SessionStatus.retry.action label. A link opens in the browser. Tapping the status opens the provider response when a body exists. While that countdown is showing, the transcript mark stays hidden. Content-filter hides the status. Compaction failures sit on the compaction card.
  • SessionBusyError ("Session is busy") queues one follow-up resend. Active session status keeps retry attempt, next time, message, error type, and action label/link.

Checks

  • Android: :app:testDebugUnitTest for SessionFailureTest, MessageDomainAdapterTest, SessionDomainAdapterTest, EventDomainAdapterTest, and V2LiveTurnTimelineTest (181 tests, succeeded).
  • iOS: SessionFailureTests, MessageDomainAdapterTests, EventDomainAdapterTests, V2LiveTurnTimelineTests, SessionStatusLiveStoreTests, and DefaultSessionCacheTests (197 tests, succeeded).
  • After dropping the reconnect action: Android SessionFailureTest and MessageDomainAdapterTest succeeded, and iOS SessionFailureTests plus MessageDomainAdapterTests (68 tests) succeeded.
  • Composer status: Android SessionFailureTest, SessionFailureDetailTargetTest, and MessageDomainAdapterTest succeeded. iOS SessionFailureTests, SessionFailureDetailTargetTests, MessageDomainAdapterTests, and V2LiveTurnTimelineTests (95 tests) succeeded.

Linear

  • OPE-276

Risks / Follow-ups

  • A retry action link opens in the browser.
  • The home list still parses the attempt from the exact prefix Retrying · N.
  • Provider bodies are truncated to 4000 characters.
  • The countdown survives a reload only when the server sends next and the action again. The session cache stores the attempt only.
  • A busy session queues one resend. A second busy response still shows the error.
  • session.next.* aliases stay in the event adapter.

@linear-code

linear-code Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
OPE-276 Errors: decode and show structured OpenCode errors (session, message, tool, HTTP)

When a run fails, both apps show at most a one-line message, usually in a snackbar. They drop the server's error type, HTTP status and provider body. Read-only check on Oct 9 of upstream v2 229b422 (2.0.26+), dev 3884062 and app main f9d5d13 (Android and iOS).

Follows OPE-272, which had "Show the provider body in session errors" as one item.

Upstream error model (v2)

Session.StructuredError (packages/schema/src/session-error.ts): { type: string, message: string, status?: int 100–599, response?: { body: string } }.

  • status: #39976 (Aug 1).
  • response.body, the raw provider error body: #52136 (Sep 29).

type values (type is a plain string, not an enum):

  • From packages/core/src/session/to-session-error.ts:
    • Provider errors: provider.rate-limit, provider.auth, provider.quota, provider.content-filter, provider.transport, provider.internal, provider.invalid-output, provider.invalid-request, provider.unsupported-operation, provider.no-route (also used for model not selected or unavailable and model config errors), provider.unknown, provider.timeout.
    • Other: permission.rejected, tool.execution, unknown.
  • Also used: aborted (session/runner/step.ts, runner/llm.ts, execution/restart.ts), tool.input-json, tool.input-incomplete, tool.result-missing, tool.unknown (runner/publish-llm-event.ts, runner/step.ts), compaction.failed, compaction.interrupted, compaction.unavailable, provider.error (session/compaction.ts).
  • provider.* errors fill status and response.body when the provider returned them. ChatGPT token-sharing error codes in the body are rewritten into friendly messages.
  • Context overflow has no type of its own. When nothing has streamed yet and auto-compaction is allowed, the server compacts and retries (runner/step.ts, Outcome.Compacted). Otherwise the provider error is published as-is.

Where the error appears:

  • Assistant message: error? and retry?: {attempt, at, error}.
  • Compaction message (failed): error (required).
  • Idle message: outcome: succeeded|failed|interrupted plus error?.
    • error? was added by #53826 (40cc0f3, Oct 9), but packages/protocol/openapi.json only gained it with 1aae67e (Oct 9). An app generated from the spec before that won't see it.
    • The TUI row change was reverted in #54061 (acd8855). ExecutionError in packages/tui/src/routes/session/index.tsx still renders the error.
  • Tool state status: "error": error: StructuredError.
  • Events:
    • session.execution.failed {sessionID, error}
    • session.step.failed {…, error, finish?}
    • session.tool.failed {…, error, executed}
    • session.retry.scheduled {sessionID, assistantMessageID, attempt, at, error}
    • session.compaction.failed {reason, error}
  • Malformed tool calls are now closed out as tool errors with executed: false: #53685 (c9e1ed7, Oct 7).
  • SessionStatus.retry = {attempt, message, next, action?: {reason, provider, title, message, label, link?}}.

HTTP errors (packages/protocol/openapi.json): typed bodies { _tag, message, …extra }:

  • 401 UnauthorizedError, 403 ForbiddenError.
  • 404: SessionNotFoundError, MessageNotFoundError, FileNotFoundError{path}, LocationNotFoundError{location} (on 96 operations; a missing project folder), ProjectNotFoundError, ProviderNotFoundError.
  • 400 InvalidRequestError{kind, field}.
  • 409 SessionBusyError{sessionID}, ConflictError, FormAlreadySettledError.
  • 413 InstructionEntryValueTooLargeError.
  • 500 UnknownError{ref}, RpcInternalError.
  • 501 VcsInitNotSupportedError, 503 ServiceUnavailableError.
  • Example: GET /api/fs/list 404 = FileNotFoundError | LocationNotFoundError.

v1 (dev, packages/sdk/openapi.json):

  • session.error {sessionID, error} and AssistantMessage.error are {name, data}, one of:
    • ProviderAuthError{providerID, message}, UnknownError{message, ref}, MessageOutputLengthError, MessageAbortedError.
    • StructuredOutputError{retries}, ContextOverflowError{responseBody}, ContentFilterError.
    • APIError{message, statusCode, isRetryable, responseHeaders, responseBody, …}.
  • Tool error state is a plain string.

How upstream clients show errors:

  • Web/desktop (packages/session-ui/src/timeline/projection.ts):
    • An error row after the turn, built from the last assistant error, a failed compaction, or a failed idle message.
    • Errors whose type contains abort or interrupt are hidden (isInterrupted).
    • unwrapErrorMessage pulls type: message out of JSON-in-message.
    • I found no client that displays response.body yet.
  • Retry: packages/session-ui/src/components/session-retry.tsx shows a red card with the message cut to 80 characters (full text in a tooltip) and a live countdown ("attempt N, retrying in Xs").
  • packages/app/src/session/usage-exceeded-dialogs.tsx opens upsell or usage dialogs from SessionStatus.retry.action and a ChatGPT usage-limit message.
  • TUI: Error: <message> rows (routes/session/index.tsx).

App today

DTOs:

  • v2: Android android/app/src/main/java/dev/opencode/mobile/data/remote/dto/V2MessageDtos.kt and iOS ios/OpenCode Mobile/OpenCode Mobile/Data/Remote/V2MessageDTOs.swift.
    • V2UnknownErrorDto / V2UnknownErrorDTO = {type?, message?}. status and response.body are dropped.
    • Used for the message error and tool state.error.
    • Assistant retry isn't decoded.
  • v1: Android MessageDtos.kt MessageErrorDataDto{message, statusCode, responseBody}. statusCode is decoded but never used. isRetryable, providerID and retries aren't decoded. Tool error is a string.

History projection (Android domain/message/remote/MessageDomainAdapter.kt, iOS Domain/Message/Remote/MessageDomainAdapter.swift):

  • Assistant error message: only used as the reply text when the reply has no text (fallbackText / errorText). A reply that streamed some text and then failed shows no error at all. On v1 the fallback order is data.message, then responseBody, then name. Interrupted replies get a neutral stop reason (MessageStopReason), which is correct.
  • idle messages: projectV2 / messages(fromV2:) ignore them (the else/default branch). Idle.error is never shown after a reload.
  • Failed compaction: projectV2Compaction ignores dto.error, so it only shows a compaction row.
  • Tool errors: ToolPart.error = state.error.message. The chip shows ✕ tool: message (compactToolLabel). aborted → isInterrupted. tool.input-json and the other new tool.* types aren't distinguished, and executed isn't decoded.

Live events (Android domain/event/EventDomainAdapter.kt ~L184–253, iOS Domain/Event/EventDomainAdapter.swift ~L182–220):

  • session.execution.failed, session.step.failed and session.compaction.failed are all rewritten as v1-style session.error.
  • SessionInfo (Android ui/sessionchat/region/SessionInfo.kt, iOS UI/SessionChat/Region/SessionInfo.swift) turns that into errorMessage, a transient snackbar ("Session work failed" when no text). User aborts are skipped.
  • No type-specific handling and no inline timeline row.
  • Unverified: one failure can emit both step.failed and execution.failed, giving two session.error events; snackbar keying may hide the duplicate.
  • Bug: v2 retry info is lost on both platforms. session.retry.scheduled → statusEvent(…, "retry") looks for next|nextAt|nextAtMs and a top-level message, but v2 sends at and error.message. Retrying keeps attempt but loses the countdown and the reason. (Android retryNextAt / retryMessage ~L934–952; iOS statusEvent ~L811–832.)

HTTP errors:

  • Android MessageDomainAdapter.mapError maps ResponseException → MessageError.Http(status, throwable.message), the Ktor text. The timeline shows throwable.message ?: "Request failed". Transport and decoding errors get fixed generic strings.
  • ServerErrorMessage (Android domain/session/ServerErrorMessage.kt, iOS Domain/Session/ServerErrorMessage.swift) already parses {_tag, message}, but from() only handles SessionError.Http. Its other callers weren't checked.
  • iOS mapError maps MessagesAPIError.httpStatus(code, message); I didn't trace where message comes from.
  • No _tag-specific handling (LocationNotFoundError, SessionBusyError, UnauthorizedError, etc.).

To do

  • ☐ DTOs (both platforms): add status: Int? and response: {body: String}? to V2UnknownErrorDto / V2UnknownErrorDTO (rename to V2StructuredErrorDto?).
    • Decode assistant retry {attempt, at, error}.
    • Decode tool executed.
    • Keep type as a string with a known-prefix mapper, not a closed enum.
  • ☐ Domain model: one shared SessionFailure(type, message, status, providerBody, source = step|execution|compaction|tool|http) used by v1 and v2. Map v1 {name, data} into it (APIError.statusCode/responseBody/isRetryable, ContextOverflowError.responseBody, ProviderAuthError.providerID).
  • ☐ Inline error row in the timeline: show it for a failed assistant even when it has text, for a failed compaction, and for idle with outcome: failed + error.
    • Show the message (unwrapped like upstream unwrapErrorMessage), the HTTP status, and an expandable "Provider response" with response.body, monospace and copyable.
    • Keep the snackbar only for live events that have no row yet, and dedupe step and execution failures.
  • ☐ Project idle messages in projectV2 / messages(fromV2:) so Idle.error survives a reload. Needs server ≥ 1aae67e for the spec, but the field is already present at runtime since #53826.
  • ☐ Per-type handling:
    • aborted / *interrupt*: silent (as today).
    • provider.auth: "Reconnect provider" action that opens provider settings.
    • provider.rate-limit / provider.quota: show the retry countdown from retry.at / SessionStatus.next, and the SessionStatus.retry.action link or label when present.
    • Context overflow (provider.invalid-request with an overflow body, or v1 ContextOverflowError): suggest compacting, with a Compact button. Guess: there's no dedicated v2 type, so match on the body or message.
    • provider.no-route: "Pick a model" action.
    • provider.content-filter: show the message, no retry.
    • permission.rejected: neutral, not red.
    • compaction.*: inline on the compaction row.
    • unknown: message plus copy.
  • ☐ Tool errors: show error.message in the expanded tool view, not just the chip label. Show tool.input-json, tool.input-incomplete and executed: false as "Not run: malformed tool call", visually distinct from a failed run.
  • ☐ Fix v2 retry mapping on both platforms: read at and error.message (and keep attempt) in statusEvent / retryNextAt / retryMessage. Show a retry card with a countdown like upstream session-retry.tsx.
  • ☐ HTTP: run every non-2xx body through ServerErrorMessage (_tag + message), including MessageError.Http and iOS MessagesAPIError.httpStatus.
    • UnauthorizedError 401 → "Pairing expired, pair again" (ties into the pairing flow).
    • LocationNotFoundError / FileNotFoundError 404 → "Folder or file not found" with the path.
    • SessionBusyError 409 → "Session is busy" (queue instead).
    • UnknownError.ref → show the ref so it can be found in server logs.
    • Keep InvalidRequestError generic, as today.
  • ☐ Tests: fixtures for each type and each v1 name, an idle error, a retry event, and typed HTTP bodies, on both platforms.

Open questions

  • Should the provider body be shown by default or behind "Details"? It can be large or contain request IDs. Truncate it?
  • Does SessionStatus (with retry.action) reach the app on v2 through /api/session/active or an event? Not verified.
  • Upstream web hides interrupt/abort but shows everything else in red. Should permission.rejected match?
  • Do the session.next.* aliases in EventDomainAdapter still exist upstream, or can they go?

Review in Linear

@ivanfenenko
ivanfenenko marked this pull request as ready for review October 9, 2026 19:42
@ivanfenenko

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 56e5b06. Configure here.

@ivanfenenko

Copy link
Copy Markdown
Collaborator Author

Evidence from the unit fixtures

No device or Paparazzi capture. :screenshot does not compile the session screen: its component symlinks point at files that moved, and the theme sources depend on app domain types. A redrawn mock would not be this UI. The rows below are what the tests assert.

Failure stays beside the reply. MessageDomainAdapterTest.projectV2_keepsAssistantErrorBesideReplyText

Assistant text Partial answer, error provider.rate-limit, wrapped message {"type":"message","message":"Slow down"}, status 429, body {"error":"overloaded"}.

  • Reply text stays Partial answer.
  • Caption is HTTP 429 · Slow down.
  • The body is kept for the existing sheet titled Provider response (monospace, Copy).

Caption actions. SessionFailureTest

Error Action
provider.auth Reconnect provider. Opens the model picker.
provider.no-route Pick a model.
ContextOverflowError, or provider.invalid-request whose message or body contains context and overflow, too long, or length Compact.
unknown, provider.unknown Copy.
provider.rate-limit, provider.quota, provider.content-filter, permission.rejected, compaction.* No caption button.

permission.rejected uses weak text. aborted and interrupt types produce no failure line. A failed compaction (Could not compact) and a failed idle (Sign in again, action Reconnect provider) are kept. An idle line that repeats the assistant failure is dropped. projectV2_hidesAbortAndProjectsFailedIdleAndCompaction.

Retry card. EventDomainAdapterTest and SessionFailureTest.contentFilterHidesTheRetryCard

session.retry.scheduled with at, error.type provider.quota, error.message Out of quota, and action {label: Upgrade, link: https://example.com} becomes a card above the timeline: message trimmed to 80 characters, attempt N, retrying in Xs, and Upgrade opening that link. provider.content-filter hides the card.

HTTP tags. ServerErrorMessageTest.knownTags_useFixedCopy

Body Shown text
UnauthorizedError Pairing expired, pair again
LocationNotFoundError location /repo Folder or file not found · /repo
SessionBusyError Session is busy. One follow-up send is queued.
UnknownError ref ref-9 Something went wrong (ref-9)
InvalidRequestError Unchanged generic message.

Tool. executed: false with tool.input-json is marked malformed and keeps bad json as the tool error. The chip line is Not run: malformed tool call.

iOS locks the same cases in SessionFailureTests, MessageDomainAdapterTests, and EventDomainAdapterTests. Local runs: Android 181 tests, iOS 197 tests, both passed.

@ivanfenenko

Copy link
Copy Markdown
Collaborator Author

Paparazzi renders of the production failure composables on the OpenCode theme: SessionRetryCountdown, SessionFailureCaption, and SessionProviderResponseBody. These are not a device capture and not a redraw. The clock is frozen so the countdown reads attempt 2, retrying in 12s.

The "Provider response" heading is test chrome. The sheet itself is the existing bottom sheet; the snapshot is the body that sheet shows.

Light:

Light failure states

Dark:

Dark failure states

Fixture mapping is in the earlier comment: #354 (comment)

Recorded with :screenshot:recordPaparazziDebug (Pixel 5, light and dark OpenCodeTheme) from 8f5117c6. Snapshots stay gitignored.

@ivanfenenko

Copy link
Copy Markdown
Collaborator Author

A provider.auth failure keeps the caption and no longer shows Reconnect provider. The app has no in-app reconnect, so that button is gone (c224e127).

The earlier renders still show that button. These Paparazzi frames of the same production composables do not:

Light failure states without reconnect

Dark failure states without reconnect

@ivanfenenko

Copy link
Copy Markdown
Collaborator Author

Composer status frames of the failure composables. Each failed reply is a quiet mark: a dot and the caption. Permission rejected stays neutral. The provider body keeps Copy. The row at the bottom is the composer status: "Slow down", "attempt 2, retrying in 12s", and Upgrade. In the app that row sits on the composer. Compact, Pick a model, and Copy open from the sheet when the mark is tapped.

Earlier frames showed caption buttons. These replace them.

Light

Light

Dark

Dark

Show reconnect, pick-a-model, compact, and copy on the failure caption.
Rate-limit and quota use a countdown card with the retry action link.
A busy session queues one follow-up send.
Point screenshot component symlinks at chrome and input, and compile
layout plus the failure composables. A screenshot-only diagnostic stub
keeps the theme off the real logger.
A provider auth failure keeps its caption. The app has no in-app reconnect, so that button is gone.
A partial live info no longer drops a REST-seeded failure caption. A compaction
error with no row still toasts, and a later ended update clears the old caption.
@ivanfenenko
ivanfenenko force-pushed the feature/ope-276-structured-errors branch from 5a864ed to c0a9668 Compare October 10, 2026 08:45
@ivanfenenko
ivanfenenko merged commit 6439d63 into main Oct 10, 2026
@ivanfenenko
ivanfenenko deleted the feature/ope-276-structured-errors branch October 10, 2026 08:57
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.

1 participant