fix(web-ui): humanize provider errors across chat surfaces (#403)#472
Merged
Conversation
The prior commit humanized provider errors on the main chat, BuilderChatPane,
and PreviewChatPane, but left SimpleIntakePane — the default (simple) builder
intake surface rendered on server load — showing the raw `${code}: ${message}`
string. A quota/rate-limit hit there surfaced the exact technical 429 that #403
was filed to fix, on the most common builder surface.
Route its stream error through humanizeProviderError with a builder-simple-scoped
generic fallback key (builder.simple.intake.errorProviderGeneric, added to en.json
and de.json with distinct wording), and log the raw error to the console for
debugging — matching the pattern of the three already-fixed surfaces.
The chat bubble was humanized but StreamRunner passed the raw provider
error string to store.finish() at both terminal call sites (HTTP-error and
catch). streamStore keeps that as record.error, which StreamToasts renders
verbatim — so the floating stream toast still showed the raw '429 ...'
string. Humanize the value before it reaches store.finish() at both sites,
using the same helper and t('errorProviderGeneric') fallback already in
scope; the 'aborted' path is left untouched. Also append the required
docs/CHANGELOG.md Unreleased entry for this bugfix.
The primary #403 path delivers the orchestrator failure as an in-band NDJSON error event on an already-streaming 200 response. StreamRunner humanized it for the chat bubble but then finished the stream record as 'done', so the background stream toast reported success for a failed turn. runOneTurn now captures the humanized message of an in-band error event seen while draining the stream (loop or trailing tail parse) and finishes the record as 'error' with that message; a clean turn still finishes as 'done'. Adds a store-outcome regression test that fails on the previous behaviour, and corrects the CHANGELOG entry to describe the toast fix.
extractProviderErrorMessage returned null for any string containing a brace pair when no JSON message could be mined, so every stream error is now routed through humanizeProviderError, a clean application message that merely contains braces was replaced by the translated generic notice. Gate the give-up on hadStatus: a JSON envelope with no surfaceable message stays null only when a status prefix marks it as a wrapped provider error; a status-less application message is returned unchanged, braces or not. Update the docstring to match and add regression tests.
The extractor keyed its give-up on a leading HTTP status and hunted for a
brace substring. A status-less rate-limit envelope
({"type":"error","error":{"type":"rate_limit_error"}}) — a real path in
streamingRetry — therefore leaked raw JSON to the chat surfaces.
Discriminate instead on whether the whole string (after stripping any
status prefix) parses as a JSON object: if so, mine error.message then a
top-level message, else fall back to the generic notice; otherwise return
the application message unchanged, braces or not. This also stops a
status-less message embedding a JSON fragment from being reduced to that
fragment's message field.
This was referenced Jul 9, 2026
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.
Problem
When a provider rejected a chat turn (quota exceeded, rate limit, billing), the chat surfaces rendered the raw error string verbatim — leading HTTP status and JSON response envelope included. Users saw transport noise like
400 {"type":"error","error":{"message":"Your credit balance is too low..."}}instead of the one actionable sentence.Change
Introduces
web-ui/app/_lib/providerErrorMessage.tswithextractProviderErrorMessage(raw)and thehumanizeProviderError(raw, fallback)wrapper, wired into every surface that renders a provider failure: the main chat bubble, the builder chat pane, the preview chat pane, and the default simple builder intake. Each shows the provider's human-readable sentence, falling back to a translated generic notice (newen.json/de.jsonkeys) when none can be surfaced.On the primary path the orchestrator failure arrives as an in-band error event on an already-streaming 200 response, so
res.okis true and nothing throws.StreamRunnerused to finish that turn asdone, which meant the background stream toast reported success for a failed turn. It now finishes as a failure carrying the humanized sentence. Covered byStreamRunner.test.tsx.Extractor semantics
The discriminator is a single question: after stripping any leading HTTP status prefix, does the whole remaining string parse as a JSON object?
null.error.message, then a top-levelmessage; return the first non-empty one, elsenullso the caller shows the generic fallback — never raw JSON.Earlier iterations hunted for a brace substring between the first
{and the last}, and keyed the give-up on a leading HTTP status. Both were wrong. A status-less rate-limit envelope —{"type":"error","error":{"type":"rate_limit_error"}}, whichmiddleware/test/orchestrator/streamingRetry.test.tsdocuments on a real path — leaked raw JSON to users, and an application message merely embedding a JSON fragment (Agent stopped: {"message":"waiting"}) could be reduced to just that field. Parsing the whole string closes both.Tests
providerErrorMessage.test.tscovers status-prefixed and status-less envelopes, plain-text quota errors, application messages with JSON and non-JSON braces, embedded-JSON application messages, brace-free messages, and empty input.StreamRunner.test.tsxdrives the in-band error path and asserts the store outcome, not the rendered string; reverting only the conditionalstore.finish()makes exactly those two tests fail.Gate green:
tsc --noEmitclean,vitest run206/206,i18n:checkOK (2735 keys, en + de).How this branch was produced
Authored and reviewed by the interactive issue loop in
scripts/issue-{cluster,implement}.workflow.mjs(#469), over six implement→review rounds. Every round ran an adversarial diff reviewer and, after it approved, an independent cross-vendor reviewer (codex, gpt-5.5). The first reviewer approved this branch in rounds 2, 3, 4 and 5; the cross-vendor reviewer rejected it each time, finding — in order — a missed default chat surface, a state bug where the toast reported success on failure, an extractor regression that destroyed clean messages containing braces, and a raw-JSON leak on the status-less rate-limit path. A human verified every finding against the branch before it was fed back. Both reviewers approve the final state.Known non-blocking notes for the reviewer:
feat/…while the issue is labelledbug; the commits are allfix(web-ui):and squash-merge uses the PR title./^\d{3}\s+/strips a leading three-digit token from any message, so a hypothetical404 errors were foundwould lose its prefix. No provider path produces that shape.Closes #403