Skip to content

AI assistant: descriptive error messages and live progress#162

Open
josephschorr wants to merge 2 commits into
mainfrom
ai-assistant-errors-and-progress
Open

AI assistant: descriptive error messages and live progress#162
josephschorr wants to merge 2 commits into
mainfrom
ai-assistant-errors-and-progress

Conversation

@josephschorr

Copy link
Copy Markdown
Member

Two improvements to the in-app AI assistant.

1. Descriptive, actionable error messages

A non-200 from /api/ai previously surfaced verbatim in the chat as terse, unactionable text — Failed — Invalid request body, Failed — Rate limit exceeded — telling the user neither what went wrong nor what to try.

All user-facing AI error copy now lives in one module, api/_lib/aiErrors.ts, which also absorbs the existing describeTurnError so mid-stream and pre-stream wording sit side by side:

Status code Message
503 disabled The AI assistant is currently turned off. Please check back later.
500 server_config The AI assistant is temporarily unavailable due to a server configuration issue. Please try again later.
400 bad_request Your message couldn't be processed. Try starting a new chat, then send it again.
429 rate_limit You're sending messages too quickly. Please wait a moment and try again.
405 method_not_allowed This endpoint only accepts POST requests.
  • The 500 copy deliberately leaks no internal detail — the operator-facing cause (a missing OPENROUTER_API_KEY) stays in a server-side log, and a test asserts it never appears in the response.
  • retryAfter stays structural rather than baked into the sentence, so the client's existing (retry in Xs) suffix doesn't render the number twice.
  • The browser adds a fallback only for responses that never reached the route at all (CDN/proxy 5xx, gateway timeouts), which carry no server-composed body. When the server did send copy, that copy wins.
  • The literal Failed — prefix is gone: the destructive styling and alert icon already signal failure, and the new copy reads as a complete sentence.

2. Live progress

The assistant showed a static Thinking… for an entire turn, and tool chips only appeared after each tool finished. Server-side work was invisible entirely — when a round trip calls only server tools no handoff is emitted, so the browser got nothing until the turn ended.

Two coordinated surfaces now show what's happening:

  • A live status line naming the current step — "Editing document", "Running check", "Reading design references" — falling back to the existing Thinking… / Working… when nothing specific is running.
  • Tool chips that spin on start and then resolve to their summary, instead of only materializing once done.

How it works

  • New status SSE event. The server announces each server tool before running it, via a progressLabel on the tool definition. It's a transient display signal only: it never enters the conversation sent to the model, and server steps deliberately leave no permanent chip. Older cached clients ignore the unknown event, so the API can deploy ahead of the browser.
  • Bracketed tool calls. The turn controller emits onToolStart / onToolEnd (correlated by tool call id) instead of reporting once after the fact. Start fires before argument validation, so every displayed call is guaranteed a matching end and no chip can spin forever. Clearing the status label runs in a finally around the stream loop, so an abort or mid-stream failure can't leave a label stuck.
  • Store. Adds a transient activity label (never persisted) and ToolActivity.status of running/ok/error, replacing the ok boolean.
  • Paint yield. Every client tool is synchronous, so the controller yields a macrotask between starting a tool and executing it. Without it, both store writes land in one task, React batches them, and the in-progress state never renders. Covered by a test that fails if the yield is removed.

Notes for review

  • The zustand persist version intentionally stays at 3. Bumping it runs migrate, which wipes the user's actual conversation for what is a cosmetic field change. The consequence is that chips persisted under the old { ok: boolean } shape have no status, so the chip treats only an explicit "error" as a failure and renders anything else in the muted "done" style. A legacy failed chip loses its red until it ages out of the persisted window — the tradeoff was preferred over discarding chat history.
  • All activity writes reuse the existing generation guard, so a reset() landing mid-turn can't resurrect a stale turn's label into a fresh conversation.

Testing

  • Unit: 231 passing
  • Browser: 42 passing
  • tsc --noEmit, vite build, oxlint on all touched files: clean

Not yet exercised against a live model — worth one manual pass in-app to confirm the status line and spinners read well at real latencies.

@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
playground Ready Ready Preview, Comment Jul 24, 2026 12:53am

Request Review

A non-200 from /api/ai previously surfaced verbatim in the chat as terse,
unactionable text ("Failed — Invalid request body", "Failed — Rate limit
exceeded"), which told the user neither what went wrong nor what to try.

Centralize all user-facing AI error copy in api/_lib/aiErrors.ts, which also
absorbs the existing describeTurnError so mid-stream and pre-stream wording
live side by side. Each pre-stream condition now maps to a code plus a
complete, actionable sentence:

  503 disabled           the assistant is turned off, check back later
  500 server_config      temporarily unavailable, try again later
  400 bad_request        start a new chat, then resend
  429 rate_limit         sending too quickly, wait a moment
  405 method_not_allowed POST only

The 500 copy deliberately leaks no internal detail; the operator-facing cause
(a missing OPENROUTER_API_KEY) stays in a server-side log. retryAfter stays
structural rather than baked into the sentence, so the client's existing
"(retry in Xs)" suffix doesn't render the number twice.

The browser adds a fallback only for responses that never reached the route
at all -- CDN/proxy 5xx, gateway timeouts -- which carry no server-composed
body. When the server did send copy, that copy wins.

Also drops the literal "Failed — " prefix from the chat bubble: the
destructive styling and alert icon already signal failure, and the new copy
reads as a complete sentence.
The assistant showed a static "Thinking…" for an entire turn, and tool
activity chips only appeared after each tool had already finished. Work that
ran server-side was invisible entirely: when a round trip calls only server
tools, no handoff is emitted, so the browser received nothing at all until
the turn completed.

Surface what the assistant is actually doing, on two coordinated surfaces:

- A live status line that names the current step -- "Editing document",
  "Running check", "Reading design references".
- Tool chips that appear with a spinner the moment a tool starts and then
  resolve to their summary, instead of only materializing once done.

Mechanically:

- New `status` SSE event. The server announces each server tool before
  running it, via a `progressLabel` on the tool definition. It is a transient
  display signal only -- it never enters the conversation sent to the model,
  and server steps deliberately leave no permanent chip. Older cached clients
  ignore the unknown event, so the API can deploy ahead of the browser.
- The turn controller now brackets every client tool with onToolStart /
  onToolEnd (correlated by tool call id) rather than reporting once, after
  the fact. Start is emitted before argument validation so every displayed
  call is guaranteed a matching end and no chip can spin forever. Clearing
  the status label runs in a finally around the stream loop, so an abort or
  a mid-stream failure cannot leave a label stuck.
- The store gains a transient `activity` label (never persisted) and
  ToolActivity.status of running/ok/error, replacing the ok boolean. The
  persist version intentionally stays at 3: bumping it would run migrate and
  wipe the user's conversation for what is a cosmetic field change. Chips
  persisted under the old shape have no status, so only an explicit "error"
  renders as a failure.
- Every client tool is synchronous, so the controller yields a macrotask
  between starting a tool and executing it. Without that, both store writes
  land in one task, React batches them, and the in-progress state never
  paints.

All activity writes reuse the existing generation guard, so a reset landing
mid-turn cannot resurrect a stale turn's label into a fresh conversation.
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