Skip to content

Commit 6027a2e

Browse files
bloveclaude
andauthored
feat: structured AgentError + classified error UX with Retry (#693)
* docs(chat): design spec for backend-failure error UX Structured AgentError (extends Error; kind/retryable/status/cause) + a shared toAgentError() classifier (5-class: connection/auth/server/interrupted/aborted), normalized in both adapters; neutral Agent.error re-typed to Signal<AgentError>; new neutral retry(); ChatErrorComponent renders legible per-kind copy + a conditional Retry. Fixes the cryptic 'HTTP 500:' surfacing + langgraph abort inconsistency the audit flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(chat): TDD implementation plan for backend-failure error UX Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(chat): AgentError + toAgentError 5-class classifier (connection/auth/server/interrupted/aborted) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(chat): re-type Agent.error as Signal<AgentError|undefined> + add neutral retry() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(langgraph): classify errors via toAgentError, abort→idle, neutral retry() - Thread `userAbortRequested` flag in the bridge; stop() sets it before aborting so the runStream() catch can distinguish user-stop (→ Idle, no error) from a mid-stream abort (→ interrupted AgentError) or fresh connect abort (→ toAgentError classification). - Thread `streamingStarted` flag in runStream() (set on first event); AbortError after streaming has begun → kind:'interrupted'/retryable:true; AbortError with no events yet falls through to toAgentError. - Normalize all non-abort catch errors through toAgentError from @threadplane/chat so agent.error() is always AgentError | undefined. - Add retry() to agent.fn.ts: no-op while loading, clears error$, then calls resubmitLast() — implements the neutral Agent contract method. - Tighten errorSig to Signal<AgentError | undefined> via a documented cast (BehaviorSubject stays unknown to satisfy StreamSubjects invariance). - MockLangGraphAgent.error re-typed to WritableSignal<AgentError|undefined>. - Update bridge + agent.fn specs: stop()→Idle assertion; four new error-UX tests (server error kind, user-stop→idle, retry() clears+resubmits, retry() no-op while loading); fix pre-existing empty-gen lint error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(langgraph): classify non-user aborts (connection/interrupted) + normalize all error$ sites via toAgentError Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ag-ui): classify errors via toAgentError + neutral retry() (re-run last input, no duplicate message) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(chat): ChatErrorComponent renders legible AgentError message + conditional Retry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(examples/chat): assert legible error message + Retry button + retry recovery Split the error-handling e2e into two focused tests: one that asserts the alert shows human-legible copy (matching /can't reach|connection|server| interrupted|try again/i and NOT /HTTP \d{3}/) with a visible Retry button, and a second that clicks Retry after unrouting and confirms a final assistant bubble appears. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(chat): tighten error status parsing to HTTP-shaped tokens + connection-before-text; dedup isAbortError/messages * docs(api): regenerate api-docs for AgentError/toAgentError/retry surface Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 05cd321 commit 6027a2e

35 files changed

Lines changed: 1368 additions & 113 deletions

apps/website/content/docs/ag-ui/api/api-docs.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@
421421
},
422422
{
423423
"name": "error",
424-
"type": "Signal<unknown>",
424+
"type": "Signal<AgentError<> | undefined>",
425425
"description": "",
426426
"optional": false
427427
},
@@ -455,6 +455,12 @@
455455
"description": "Discards the assistant message at the given index AND all messages after\nit, then re-runs the agent against the trimmed conversation tail. The\npreceding user message (at index - 1) is preserved and re-submitted as\nthe agent's input. No new user message is added to the history.\n\nThrows if the message at `index` is not 'assistant' role, or if the\nagent is currently loading another response.",
456456
"optional": false
457457
},
458+
{
459+
"name": "retry",
460+
"type": "() => Promise<void>",
461+
"description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.",
462+
"optional": false
463+
},
458464
{
459465
"name": "state",
460466
"type": "Signal<Record<string, unknown>>",

apps/website/content/docs/chat/api/api-docs.json

Lines changed: 133 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,65 @@
14261426
],
14271427
"methods": []
14281428
},
1429+
{
1430+
"name": "AgentError",
1431+
"kind": "class",
1432+
"description": "Structured, classified failure surfaced on `Agent.error`. Extends `Error`\n so existing `.message` / `instanceof Error` reads keep working.",
1433+
"params": [
1434+
{
1435+
"name": "init",
1436+
"type": "object",
1437+
"description": "",
1438+
"optional": false
1439+
}
1440+
],
1441+
"examples": [],
1442+
"properties": [
1443+
{
1444+
"name": "cause",
1445+
"type": "unknown",
1446+
"description": "",
1447+
"optional": false
1448+
},
1449+
{
1450+
"name": "kind",
1451+
"type": "AgentErrorKind",
1452+
"description": "",
1453+
"optional": false
1454+
},
1455+
{
1456+
"name": "message",
1457+
"type": "string",
1458+
"description": "",
1459+
"optional": false
1460+
},
1461+
{
1462+
"name": "name",
1463+
"type": "string",
1464+
"description": "",
1465+
"optional": false
1466+
},
1467+
{
1468+
"name": "retryable",
1469+
"type": "boolean",
1470+
"description": "connection | server | interrupted → true; auth | aborted | non-auth-4xx → false.",
1471+
"optional": false
1472+
},
1473+
{
1474+
"name": "stack",
1475+
"type": "string",
1476+
"description": "",
1477+
"optional": true
1478+
},
1479+
{
1480+
"name": "status",
1481+
"type": "number",
1482+
"description": "",
1483+
"optional": true
1484+
}
1485+
],
1486+
"methods": []
1487+
},
14291488
{
14301489
"name": "ChatApprovalCardComponent",
14311490
"kind": "class",
@@ -2118,12 +2177,6 @@
21182177
"type": "InputSignal<Agent>",
21192178
"description": "",
21202179
"optional": false
2121-
},
2122-
{
2123-
"name": "errorMessage",
2124-
"type": "Signal<string | null>",
2125-
"description": "",
2126-
"optional": false
21272180
}
21282181
],
21292182
"methods": []
@@ -5354,7 +5407,7 @@
53545407
},
53555408
{
53565409
"name": "error",
5357-
"type": "Signal<unknown>",
5410+
"type": "Signal<AgentError<> | undefined>",
53585411
"description": "",
53595412
"optional": false
53605413
},
@@ -5388,6 +5441,12 @@
53885441
"description": "Discards the assistant message at the given index AND all messages after\nit, then re-runs the agent against the trimmed conversation tail. The\npreceding user message (at index - 1) is preserved and re-submitted as\nthe agent's input. No new user message is added to the history.\n\nThrows if the message at `index` is not 'assistant' role, or if the\nagent is currently loading another response.",
53895442
"optional": false
53905443
},
5444+
{
5445+
"name": "retry",
5446+
"type": "() => Promise<void>",
5447+
"description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.",
5448+
"optional": false
5449+
},
53915450
{
53925451
"name": "state",
53935452
"type": "Signal<Record<string, unknown>>",
@@ -5648,7 +5707,7 @@
56485707
},
56495708
{
56505709
"name": "error",
5651-
"type": "Signal<unknown>",
5710+
"type": "Signal<AgentError<> | undefined>",
56525711
"description": "",
56535712
"optional": false
56545713
},
@@ -5694,6 +5753,12 @@
56945753
"description": "Discards the assistant message at the given index AND all messages after\nit, then re-runs the agent against the trimmed conversation tail. The\npreceding user message (at index - 1) is preserved and re-submitted as\nthe agent's input. No new user message is added to the history.\n\nThrows if the message at `index` is not 'assistant' role, or if the\nagent is currently loading another response.",
56955754
"optional": false
56965755
},
5756+
{
5757+
"name": "retry",
5758+
"type": "() => Promise<void>",
5759+
"description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.",
5760+
"optional": false
5761+
},
56975762
{
56985763
"name": "state",
56995764
"type": "Signal<Record<string, unknown>>",
@@ -6307,7 +6372,7 @@
63076372
},
63086373
{
63096374
"name": "error",
6310-
"type": "WritableSignal<unknown>",
6375+
"type": "WritableSignal<AgentError<> | undefined>",
63116376
"description": "",
63126377
"optional": false
63136378
},
@@ -6353,6 +6418,12 @@
63536418
"description": "Discards the assistant message at the given index AND all messages after\nit, then re-runs the agent against the trimmed conversation tail. The\npreceding user message (at index - 1) is preserved and re-submitted as\nthe agent's input. No new user message is added to the history.\n\nThrows if the message at `index` is not 'assistant' role, or if the\nagent is currently loading another response.",
63546419
"optional": false
63556420
},
6421+
{
6422+
"name": "retry",
6423+
"type": "() => Promise<void>",
6424+
"description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.",
6425+
"optional": false
6426+
},
63566427
{
63576428
"name": "state",
63586429
"type": "WritableSignal<Record<string, unknown>>",
@@ -6411,7 +6482,7 @@
64116482
"properties": [
64126483
{
64136484
"name": "error",
6414-
"type": "unknown",
6485+
"type": "AgentError<>",
64156486
"description": "",
64166487
"optional": true
64176488
},
@@ -6990,6 +7061,13 @@
69907061
"signature": "Readonly<Record<string, Type<unknown> | A2uiViewEntry>>",
69917062
"examples": []
69927063
},
7064+
{
7065+
"name": "AgentErrorKind",
7066+
"kind": "type",
7067+
"description": "",
7068+
"signature": "\"connection\" | \"auth\" | \"server\" | \"interrupted\" | \"aborted\"",
7069+
"examples": []
7070+
},
69937071
{
69947072
"name": "AgentEvent",
69957073
"kind": "type",
@@ -7144,6 +7222,13 @@
71447222
"signature": "Readonly<Record<string, Type<unknown> | RenderViewEntry>>",
71457223
"examples": []
71467224
},
7225+
{
7226+
"name": "AGENT_ERROR_MESSAGES",
7227+
"kind": "const",
7228+
"description": "Default human-facing copy per kind.",
7229+
"signature": "Record<AgentErrorKind, string>",
7230+
"examples": []
7231+
},
71477232
{
71487233
"name": "cacheplaneMarkdownViews",
71497234
"kind": "const",
@@ -7578,6 +7663,25 @@
75787663
},
75797664
"examples": []
75807665
},
7666+
{
7667+
"name": "isAbortError",
7668+
"kind": "function",
7669+
"description": "True when `raw` represents a user-requested abort. Shared by adapters + classifier.",
7670+
"signature": "isAbortError(raw: unknown): boolean",
7671+
"params": [
7672+
{
7673+
"name": "raw",
7674+
"type": "unknown",
7675+
"description": "",
7676+
"optional": false
7677+
}
7678+
],
7679+
"returns": {
7680+
"type": "boolean",
7681+
"description": ""
7682+
},
7683+
"examples": []
7684+
},
75817685
{
75827686
"name": "isAssistantMessage",
75837687
"kind": "function",
@@ -7881,6 +7985,25 @@
78817985
},
78827986
"examples": []
78837987
},
7988+
{
7989+
"name": "toAgentError",
7990+
"kind": "function",
7991+
"description": "Classify any raw error into a structured AgentError. Idempotent.",
7992+
"signature": "toAgentError(raw: unknown): AgentError<>",
7993+
"params": [
7994+
{
7995+
"name": "raw",
7996+
"type": "unknown",
7997+
"description": "",
7998+
"optional": false
7999+
}
8000+
],
8001+
"returns": {
8002+
"type": "AgentError<>",
8003+
"description": ""
8004+
},
8005+
"examples": []
8006+
},
78848007
{
78858008
"name": "toClientToolSpecs",
78868009
"kind": "function",

apps/website/content/docs/langgraph/api/api-docs.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,7 +1439,7 @@
14391439
},
14401440
{
14411441
"name": "error",
1442-
"type": "Signal<unknown>",
1442+
"type": "Signal<AgentError<> | undefined>",
14431443
"description": "",
14441444
"optional": false
14451445
},
@@ -1581,6 +1581,12 @@
15811581
"description": "Re-submit the last input to restart the stream.",
15821582
"optional": false
15831583
},
1584+
{
1585+
"name": "retry",
1586+
"type": "() => Promise<void>",
1587+
"description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.",
1588+
"optional": false
1589+
},
15841590
{
15851591
"name": "setBranch",
15861592
"type": "(branch: string) => void",
@@ -1861,7 +1867,7 @@
18611867
},
18621868
{
18631869
"name": "error",
1864-
"type": "WritableSignal<unknown>",
1870+
"type": "WritableSignal<AgentError<> | undefined>",
18651871
"description": "",
18661872
"optional": false
18671873
},
@@ -2003,6 +2009,12 @@
20032009
"description": "Re-submit the last input to restart the stream.",
20042010
"optional": false
20052011
},
2012+
{
2013+
"name": "retry",
2014+
"type": "() => Promise<void>",
2015+
"description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.",
2016+
"optional": false
2017+
},
20062018
{
20072019
"name": "setBranch",
20082020
"type": "(branch: string) => void",

0 commit comments

Comments
 (0)