From 5105c60ba724615ab5f4272ab0af562eaca19015 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 13:19:36 -0700 Subject: [PATCH 01/11] 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; 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 --- .../2026-06-18-backend-error-ux-design.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-18-backend-error-ux-design.md diff --git a/docs/superpowers/specs/2026-06-18-backend-error-ux-design.md b/docs/superpowers/specs/2026-06-18-backend-error-ux-design.md new file mode 100644 index 00000000..a1f63b03 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-backend-error-ux-design.md @@ -0,0 +1,168 @@ +# Backend-Failure Error UX Design + +**Status:** Approved (brainstorm) — ready for implementation plan +**Date:** 2026-06-18 +**Scope:** Error path across `@threadplane/chat` (neutral contract + chat error UI) and the `@threadplane/langgraph` + `@threadplane/ag-ui` adapters. + +## Problem + +A backend/stream failure is caught raw and passed through untouched to the user. The audit traced it: + +- The cryptic **"HTTP 500:"** the user sees is the LangGraph SDK's own `Error.message`. `libs/langgraph/.../stream-manager.bridge.ts` does `subjects.error$.next(err)` with no inspection → `Agent.error` (typed `Signal`) → `ChatErrorComponent` renders `error.message` / `String(error)` in a `role="alert"` banner. +- **No classification.** Connection-refused, 4xx (auth/bad-request), 5xx (server), stream-died-mid-response, and user-abort all collapse into one opaque string. +- **Abort is handled inconsistently.** AG-UI treats user-stop as graceful idle; LangGraph surfaces it as an *error*. +- **No retry affordance.** `reload()`/`regenerate()` exist as methods but there is no UI; users don't know they can retry. +- **No structured error contract** in the public API. + +## Goals + +1. A structured, public `AgentError` so consumers (and the chat UI) can reason about *why* a run failed. +2. Legible, cause-specific messages instead of raw SDK strings. +3. A consistent, batteries-included retry affordance for transient failures. +4. Consistent abort semantics across adapters (user-stop is never an error). + +Non-goals (YAGNI): a new `events$` error variant; rate-limit/timeout/validation sub-classes beyond the agreed 5-class taxonomy. + +## Architecture + +The shared error **contract + classifier** live in `@threadplane/chat` (the neutral layer). Both adapters classify at the source (where the raw error + HTTP status are still available) and set the structured value onto the agent's `error` signal. The chat error component reads the structured value and renders legible copy + a conditional retry. + +### Component 1 — `AgentError` (`libs/chat/src/lib/agent/agent-error.ts`, new) + +```ts +export type AgentErrorKind = + | 'connection' // offline / DNS / connection refused / fetch failed + | 'auth' // 401 / 403 + | 'server' // 5xx + | 'interrupted' // stream closed mid-response + | 'aborted'; // user pressed stop (graceful; normally never surfaced) + +export class AgentError extends Error { + readonly kind: AgentErrorKind; + /** connection | server | interrupted → true; auth | aborted → false */ + readonly retryable: boolean; + /** HTTP status when known. */ + readonly status?: number; + /** The original raw error, preserved for debugging/telemetry. */ + override readonly cause: unknown; + + constructor(init: { + kind: AgentErrorKind; + message: string; + retryable: boolean; + status?: number; + cause?: unknown; + }) { + super(init.message); + this.name = 'AgentError'; + this.kind = init.kind; + this.retryable = init.retryable; + this.status = init.status; + this.cause = init.cause; + } +} +``` + +`extends Error` keeps existing `.message` / `instanceof Error` reads working (chat-error's `extractErrorMessage`, telemetry's error-name extraction). + +Default per-kind messages (a `const` map in the same file): +- `connection`: "Can't reach the server. Check your connection and try again." +- `auth`: "Authentication failed. Check your API key or credentials." +- `server`: "The server ran into an error. You can try again." +- `interrupted`: "The response was interrupted. Try again." +- `aborted`: "Stopped." (used only if an aborted error is ever surfaced; normally abort settles to idle) + +### Component 2 — `toAgentError(raw, ctx?)` (`libs/chat/src/lib/agent/to-agent-error.ts`, new) + +The single classifier. Pure, idempotent, no Angular deps. + +```ts +export function toAgentError(raw: unknown): AgentError; +``` + +Classification order: +1. **Already `AgentError`** → return as-is (idempotent). +2. **Abort** — `raw` is an `Error` whose `name === 'AbortError'` or `/abort/i.test(message)` → `kind: 'aborted'`, `retryable: false`. +3. **HTTP status** — extract from `raw.status` / `raw.cause?.status` if present, else parse a leading `HTTP ` or a bare 3-digit code from `message`: + - `401 | 403` → `auth` (retryable false). + - `>= 500` → `server` (retryable true), `status` set. + - other 4xx (400/404/429/…) → `server` (retryable **false**), `status` set, message `"The request was rejected (HTTP )."`. Within the 5-class taxonomy `server` is the closest bucket for a non-auth HTTP error; `retryable: false` is what actually drives the UI (no Retry button, since retrying won't help a client-side request error). +4. **Connection** — fetch/network markers (`TypeError: Failed to fetch`, `ECONNREFUSED`, `ENOTFOUND`, `NetworkError`, no `status`) → `kind: 'connection'`, `retryable: true`. +5. **Interrupted** — stream-specific markers (premature close / `ERR_STREAM` / aborted-by-server mid-stream where a run had already started) → `kind: 'interrupted'`, `retryable: true`. +6. **Fallback** — unknown shape → `kind: 'server'`, `retryable: true`, message = the cleaned original message or "Something went wrong. You can try again." `cause` always set to `raw`. + +### Component 3 — adapter normalization + +**LangGraph** (`libs/langgraph/src/lib/internals/stream-manager.bridge.ts`): +- In the `runStream()` catch: if the error is an abort (matches the same abort predicate) AND an abort was requested, settle to **idle** (`status$.next(Idle)`), do **not** set `error$`. Otherwise `error$.next(toAgentError(err))`. +- This fixes the inconsistency where LangGraph currently shows user-stop as an error. Factor the abort predicate so both adapters share it (export a small `isAbortError(raw)` from the chat layer, reused by `toAgentError` and the bridge). + +**AG-UI** (`libs/ag-ui/src/lib/to-agent.ts`): +- `onRunFailed`/`RUN_ERROR`: keep the existing abort→idle path; for real failures `store.error.set(toAgentError(error))`. + +### Component 4 — contract changes (`libs/chat/src/lib/agent/agent.ts` + both adapters) + +```ts +export interface Agent> { + // ... + error: Signal; // was Signal + // ... + /** Re-run the last submitted input after a failure. No-op if there is + * nothing to retry. Clears `error` and sets loading. */ + retry: () => Promise; +} +``` + +- LangGraph `retry()` → delegates to the existing `resubmitLast()` (what `reload()` already calls), after clearing `error$`. +- AG-UI `retry()` → re-run the last input it sent (the adapter already tracks the last `RunAgentInput`); clear `error`. +- `AgentError` is re-exported from `@threadplane/chat` public-api; `toAgentError` and `AgentErrorKind` too (consumers writing custom backends/error UIs). + +### Component 5 — `ChatErrorComponent` (`libs/chat/src/lib/primitives/chat-error/chat-error.component.ts`) + +- Reads `agent().error()` (now `AgentError | undefined`). +- Renders the structured `message` in the existing `role="alert"` banner (no raw SDK strings). +- Shows a **Retry** button only when `error()?.retryable`, calling `agent().retry()`. The button is hidden for `auth` (and any non-retryable) errors, where retrying won't help. +- Keep `extractErrorMessage` exported and working (it already handles `Error` → `.message`), so a plain `Error` still renders. + +## Data flow (after) + +``` +backend failure + → adapter catch (status/cause available) + → isAbortError? → settle idle (no error) [both adapters now] + → else error signal := toAgentError(raw) [AgentError] + → Agent.error: Signal + → ChatErrorComponent: legible message + Retry (if retryable) → agent.retry() +``` + +## Error handling & edge cases + +- **Retry while loading:** `retry()` is a no-op if a run is already in flight (guard on status), mirroring `regenerate()`'s loading guard. +- **Nothing to retry:** if no prior input was sent, `retry()` resolves without action. +- **Idempotent classifier:** `toAgentError(toAgentError(x))` === first result (kind preserved). +- **Abort mid-stream vs server-interrupt:** only a *user-requested* abort settles to idle; a server/transport close that the user did not request is `interrupted` (retryable). +- **Custom backends:** can throw/set a plain `Error`; `toAgentError` upgrades it (fallback kind `server`, retryable). They may also construct an `AgentError` directly for precise control. + +## Testing strategy + +- **Unit (`to-agent-error.spec.ts`):** one case per kind from a representative raw error (`HTTP 500: ...` → server+status 500; `{name:'AbortError'}` → aborted; `TypeError: Failed to fetch` → connection; a 401 → auth non-retryable; a mid-stream close → interrupted; unknown → server fallback), plus idempotency and `cause` preservation. +- **Adapter unit tests:** LangGraph bridge routes abort→idle and real error→`AgentError`; AG-UI `onRunFailed`→`AgentError`; `retry()` resubmits last input and clears error (both adapters). +- **Type-spec (strict):** `Agent.error` is `Signal`; `AgentError extends Error`; `retry` present. +- **e2e (`examples/chat/angular/e2e/error-handling.spec.ts`, upgraded):** assert the banner shows a *legible* message (not a raw SDK string), a **Retry** button appears for the (retryable) stream failure, clicking it recovers — replacing the current generic `/fail|error/i` assertion. +- Existing chat/langgraph/ag-ui unit suites stay green; build one example to confirm source compiles. + +## Files touched + +- `libs/chat/src/lib/agent/agent-error.ts` *(new)* — `AgentError`, `AgentErrorKind`, default-message map. +- `libs/chat/src/lib/agent/to-agent-error.ts` *(new)* — `toAgentError`, `isAbortError`. +- `libs/chat/src/lib/agent/agent.ts` — `error: Signal`, `retry()`. +- `libs/chat/src/lib/agent/index.ts` + `libs/chat/src/public-api.ts` — export `AgentError`, `AgentErrorKind`, `toAgentError`. +- `libs/chat/src/lib/primitives/chat-error/chat-error.component.ts` — structured message + conditional Retry. +- `libs/langgraph/src/lib/internals/stream-manager.bridge.ts` + `agent.fn.ts` — normalize + abort→idle + `retry()`. +- `libs/ag-ui/src/lib/to-agent.ts` — normalize + `retry()`. +- `*.spec.ts` for the above + the upgraded `examples/chat` e2e. + +## Risks + +- **Re-typing `Agent.error`** touches the shared contract, but `AgentError extends Error` keeps `.message`/`instanceof` consumers working; the audit found only `ChatErrorComponent` reads it meaningfully, so the ripple is small. Both adapters must set an `AgentError` (or undefined) — enforced by the contract type. +- **`retry()` added to the contract** — every adapter must implement it; the two first-party adapters do. Custom-backend authors get a compile error until they add it (acceptable, pre-1.0, and the method is small). From d4ee2bc26bace982b1952cef73c9191fcf4e79c2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 13:23:02 -0700 Subject: [PATCH 02/11] docs(chat): TDD implementation plan for backend-failure error UX Co-Authored-By: Claude Fable 5 --- .../plans/2026-06-18-backend-error-ux.md | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-backend-error-ux.md diff --git a/docs/superpowers/plans/2026-06-18-backend-error-ux.md b/docs/superpowers/plans/2026-06-18-backend-error-ux.md new file mode 100644 index 00000000..f3041837 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-backend-error-ux.md @@ -0,0 +1,356 @@ +# Backend-Failure Error UX Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Replace the opaque "HTTP 500:" surfacing with a structured `AgentError` (classified, retryable), normalized in both adapters, surfaced by the chat error UI as legible copy + a conditional Retry; fix the LangGraph user-abort-as-error inconsistency. + +**Architecture:** Contract + classifier live in `@threadplane/chat`; `@threadplane/langgraph` and `@threadplane/ag-ui` classify at the source and set `Agent.error: Signal`; `ChatErrorComponent` reads it and wires Retry to a new neutral `Agent.retry()`. + +**Tech Stack:** Angular 21 (signals), vitest, Nx. No backwards-compatibility constraint (pre-1.0). + +**Reference spec:** `docs/superpowers/specs/2026-06-18-backend-error-ux-design.md`. + +--- + +## Task 1: `AgentError` + `toAgentError` classifier (pure, TDD) + +**Files:** +- Create: `libs/chat/src/lib/agent/agent-error.ts` +- Create: `libs/chat/src/lib/agent/to-agent-error.ts` +- Create: `libs/chat/src/lib/agent/to-agent-error.spec.ts` + +- [ ] **Step 1: Write the failing test** — `to-agent-error.spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { AgentError } from './agent-error'; +import { toAgentError, isAbortError } from './to-agent-error'; + +describe('toAgentError', () => { + it('classifies HTTP 500 message as server + retryable + status', () => { + const e = toAgentError(new Error('HTTP 500: Internal Server Error')); + expect(e).toBeInstanceOf(AgentError); + expect(e.kind).toBe('server'); expect(e.retryable).toBe(true); expect(e.status).toBe(500); + }); + it('classifies 401 as auth + not retryable', () => { + const e = toAgentError(new Error('HTTP 401: Unauthorized')); + expect(e.kind).toBe('auth'); expect(e.retryable).toBe(false); expect(e.status).toBe(401); + }); + it('classifies non-auth 4xx as server + not retryable', () => { + const e = toAgentError(new Error('HTTP 404: Not Found')); + expect(e.kind).toBe('server'); expect(e.retryable).toBe(false); expect(e.status).toBe(404); + }); + it('classifies fetch failure as connection + retryable', () => { + const e = toAgentError(new TypeError('Failed to fetch')); + expect(e.kind).toBe('connection'); expect(e.retryable).toBe(true); + }); + it('classifies AbortError as aborted + not retryable', () => { + const ab = new Error('The operation was aborted'); ab.name = 'AbortError'; + const e = toAgentError(ab); + expect(e.kind).toBe('aborted'); expect(e.retryable).toBe(false); + expect(isAbortError(ab)).toBe(true); + }); + it('preserves cause and is idempotent', () => { + const raw = new Error('HTTP 500: boom'); + const once = toAgentError(raw); + expect(once.cause).toBe(raw); + expect(toAgentError(once)).toBe(once); + }); + it('falls back to server + retryable for unknown shapes', () => { + const e = toAgentError({ weird: true }); + expect(e.kind).toBe('server'); expect(e.retryable).toBe(true); + }); + it('reads a structured status off the error/cause', () => { + const e = toAgentError({ status: 503, message: 'Service Unavailable' }); + expect(e.kind).toBe('server'); expect(e.status).toBe(503); expect(e.retryable).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run — expect FAIL.** `npx nx test chat --skip-nx-cache -- to-agent-error` (modules don't exist). + +- [ ] **Step 3: Create `agent-error.ts`:** +```ts +// SPDX-License-Identifier: MIT +export type AgentErrorKind = 'connection' | 'auth' | 'server' | 'interrupted' | 'aborted'; + +/** Structured, classified failure surfaced on `Agent.error`. Extends `Error` + * so existing `.message` / `instanceof Error` reads keep working. */ +export class AgentError extends Error { + readonly kind: AgentErrorKind; + /** connection | server | interrupted → true; auth | aborted → false (+ non-auth 4xx → false). */ + readonly retryable: boolean; + readonly status?: number; + override readonly cause: unknown; + + constructor(init: { kind: AgentErrorKind; message: string; retryable: boolean; status?: number; cause?: unknown }) { + super(init.message); + this.name = 'AgentError'; + this.kind = init.kind; + this.retryable = init.retryable; + this.status = init.status; + this.cause = init.cause; + } +} + +/** Default human-facing copy per kind. */ +export const AGENT_ERROR_MESSAGES: Record = { + connection: "Can't reach the server. Check your connection and try again.", + auth: 'Authentication failed. Check your API key or credentials.', + server: 'The server ran into an error. You can try again.', + interrupted: 'The response was interrupted. Try again.', + aborted: 'Stopped.', +}; +``` + +- [ ] **Step 4: Create `to-agent-error.ts`:** +```ts +// SPDX-License-Identifier: MIT +import { AgentError, AGENT_ERROR_MESSAGES, type AgentErrorKind } from './agent-error'; + +/** True when `raw` represents a user-requested abort (DOMException/Error named + * AbortError, or an abort-ish message). Shared by the adapters + the classifier. */ +export function isAbortError(raw: unknown): boolean { + return raw instanceof Error && (raw.name === 'AbortError' || /\babort/i.test(raw.message)); +} + +function readStatus(raw: unknown): number | undefined { + const obj = raw as { status?: unknown; cause?: { status?: unknown } } | null; + const direct = typeof obj?.status === 'number' ? obj.status : undefined; + const viaCause = typeof obj?.cause?.status === 'number' ? obj!.cause!.status : undefined; + if (direct ?? viaCause) return (direct ?? viaCause) as number; + const msg = raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : ''; + const m = /\b(\d{3})\b/.exec(msg); + return m ? Number(m[1]) : undefined; +} + +function isConnectionError(raw: unknown): boolean { + if (!(raw instanceof Error)) return false; + return /failed to fetch|networkerror|econnrefused|enotfound|network request failed|load failed/i.test( + `${raw.name} ${raw.message}`, + ); +} + +function make(kind: AgentErrorKind, retryable: boolean, raw: unknown, status?: number, message?: string): AgentError { + return new AgentError({ kind, retryable, status, cause: raw, message: message ?? AGENT_ERROR_MESSAGES[kind] }); +} + +/** Classify any raw error into a structured {@link AgentError}. Idempotent. */ +export function toAgentError(raw: unknown): AgentError { + if (raw instanceof AgentError) return raw; + if (isAbortError(raw)) return make('aborted', false, raw); + + const status = readStatus(raw); + if (status !== undefined) { + if (status === 401 || status === 403) return make('auth', false, raw, status); + if (status >= 500) return make('server', true, raw, status); + if (status >= 400) return make('server', false, raw, status, `The request was rejected (HTTP ${status}).`); + } + if (isConnectionError(raw)) return make('connection', true, raw); + + // Fallback: unknown server-side failure, allow retry. + const msg = raw instanceof Error && raw.message ? raw.message : 'Something went wrong. You can try again.'; + return make('server', true, raw, status, msg); +} +``` +> Note on `interrupted`: a stream that closes mid-response is classified by the **adapter** (which knows a run had started) — it constructs `new AgentError({ kind: 'interrupted', retryable: true, ... })` directly. `toAgentError` covers the generic raw-error cases; the adapter handles the stream-lifecycle-specific `interrupted` case in Task 3/4. + +- [ ] **Step 5: Run — expect PASS.** `npx nx test chat --skip-nx-cache -- to-agent-error`. + +- [ ] **Step 6: Commit.** +```bash +git add libs/chat/src/lib/agent/agent-error.ts libs/chat/src/lib/agent/to-agent-error.ts libs/chat/src/lib/agent/to-agent-error.spec.ts +git commit -m "feat(chat): AgentError + toAgentError 5-class classifier (connection/auth/server/interrupted/aborted)" +``` + +--- + +## Task 2: Contract — `error: Signal` + `retry()` + exports + +**Files:** +- Modify: `libs/chat/src/lib/agent/agent.ts` +- Modify: `libs/chat/src/lib/agent/index.ts` +- Modify: `libs/chat/src/public-api.ts` +- Create: `libs/chat/src/lib/agent/agent-error.type-spec.ts` + +- [ ] **Step 1: Re-type the contract.** In `agent.ts`, import `AgentError`, change `error: Signal` → `error: Signal`, and add to the Actions section: +```ts + /** Re-run the last submitted input after a failure. No-op if a run is already + * in flight or there is nothing to retry. Clears `error` and sets loading. */ + retry: () => Promise; +``` + +- [ ] **Step 2: Export from barrel + public-api.** In `libs/chat/src/lib/agent/index.ts` add: +```ts +export { AgentError, AGENT_ERROR_MESSAGES } from './agent-error'; +export type { AgentErrorKind } from './agent-error'; +export { toAgentError, isAbortError } from './to-agent-error'; +``` +In `libs/chat/src/public-api.ts`, ensure these flow out (add a value export line `export { AgentError, AGENT_ERROR_MESSAGES, toAgentError, isAbortError } from './lib/agent';` and `export type { AgentErrorKind } from './lib/agent';`). + +- [ ] **Step 3: Type-spec** — `agent-error.type-spec.ts` (uses the existing chat type-test harness `../../testing/type-assert`): +```ts +// SPDX-License-Identifier: MIT +import type { Signal } from '@angular/core'; +import type { Equal, Expect } from '../../testing/type-assert'; +import type { Agent } from './agent'; +import { AgentError } from './agent-error'; + +type _errTyped = Expect>>; +type _retry = Expect Promise>>; +const _isErr: Error = new AgentError({ kind: 'server', message: 'x', retryable: true }); +``` + +- [ ] **Step 4: Build chat + run type-tests — expect adapter type errors are deferred.** Run `npx nx type-tests chat --skip-nx-cache`. The chat lib itself should compile (the contract change is type-only); `npx nx build chat` may fail only if a chat-internal implementation sets `error` to a non-AgentError — fix those sites to use `toAgentError(...)` or `undefined`. The adapters (separate projects) are updated in Tasks 3-4. + +- [ ] **Step 5: Commit.** +```bash +git add libs/chat/src/lib/agent/agent.ts libs/chat/src/lib/agent/index.ts libs/chat/src/public-api.ts libs/chat/src/lib/agent/agent-error.type-spec.ts +git commit -m "feat(chat): re-type Agent.error as Signal + add neutral retry()" +``` + +--- + +## Task 3: LangGraph adapter — normalize + abort→idle + `retry()` + +**Files:** +- Modify: `libs/langgraph/src/lib/internals/stream-manager.bridge.ts` +- Modify: `libs/langgraph/src/lib/agent.fn.ts` + +- [ ] **Step 1: Normalize + abort→idle in the bridge.** In `stream-manager.bridge.ts` `runStream()` catch (~line 435), replace `subjects.error$.next(err)` with: +```ts +import { toAgentError, isAbortError, AgentError } from '@threadplane/chat'; +// ... +} catch (err) { + if (isAbortError(err) && /* an abort was requested */ abortRequested) { + subjects.status$.next(ResourceStatus.Idle); // user stop → graceful, not an error + } else { + // A stream that dies after a run started (not a fresh connect failure) → interrupted. + const e = (startedStreaming && isAbortError(err)) + ? new AgentError({ kind: 'interrupted', message: 'The response was interrupted. Try again.', retryable: true, cause: err }) + : toAgentError(err); + subjects.error$.next(e); + subjects.status$.next(ResourceStatus.Error); + } +} +``` +Use the bridge's existing abort-tracking flag (find the field that records a user-requested stop; if none exists, thread one from `stop()`). `startedStreaming` = whether any value/message arrived this run (the bridge already tracks first-value; reuse it). Keep the existing telemetry call. + +- [ ] **Step 2: `retry()` in the agent surface.** In `agent.fn.ts`, the returned object currently has `reload: () => manager.resubmitLast()`. Add: +```ts + retry: async () => { + if (statusSig() === 'running' /* or the loading signal */) return; + error$.next(undefined); + await manager.resubmitLast(); + }, +``` +Place it alongside `reload`. Ensure `error$` is reachable here (it is — it's the BehaviorSubject feeding `errorSig`). Use the correct loading/status guard already in scope. + +- [ ] **Step 3: Tests.** Add/extend a bridge or agent spec asserting: (a) a user-abort settles status to idle and leaves `error` undefined; (b) a thrown `HTTP 500` sets `error` to an `AgentError` with kind `server`; (c) `retry()` clears error and calls `resubmitLast` (spy). Follow the existing langgraph spec patterns (e.g. `agent.fn.spec.ts`). + +- [ ] **Step 4: Verify.** `npx nx run-many -t test lint build --projects=langgraph --skip-nx-cache` — green. + +- [ ] **Step 5: Commit.** +```bash +git add libs/langgraph/src/lib/internals/stream-manager.bridge.ts libs/langgraph/src/lib/agent.fn.ts libs/langgraph/src/lib/*.spec.ts +git commit -m "feat(langgraph): classify errors via toAgentError, abort→idle, neutral retry()" +``` + +--- + +## Task 4: AG-UI adapter — normalize + `retry()` + +**Files:** +- Modify: `libs/ag-ui/src/lib/to-agent.ts` + +- [ ] **Step 1: Normalize in `onRunFailed`.** Replace `store.error.set(error)` with `store.error.set(toAgentError(error))` (import `toAgentError` from `@threadplane/chat`). Keep the existing `settleIfAborted` path (abort → idle, no error) — that already matches the desired behavior. + +- [ ] **Step 2: Track last input + `retry()`.** In `submit()` (~line 262), record the input that starts a run (e.g. `let lastInput: AgentSubmitInput | undefined`). Add a `retry` method to the returned `AgUiAgent`: +```ts + retry: async () => { + if (store.isLoading()) return; + if (lastInput === undefined) return; + store.error.set(undefined); + await /* the same path submit() uses to (re)run */ runLast(lastInput); + }, +``` +Re-run via the same `source.runAgent(...)` path `submit()` uses (factor a small helper if needed). Do not append a duplicate user message on retry — re-run the existing input/messages. + +- [ ] **Step 3: Tests.** Extend `to-agent.spec.ts`: a `RUN_ERROR`/`onRunFailed` sets `error` to an `AgentError` (kind from message); abort still settles idle with `error` undefined; `retry()` clears error and re-runs the last input. + +- [ ] **Step 4: Verify.** `npx nx run-many -t test lint build --projects=ag-ui --skip-nx-cache` — green; also `npx nx build chat` cross-check. + +- [ ] **Step 5: Commit.** +```bash +git add libs/ag-ui/src/lib/to-agent.ts libs/ag-ui/src/lib/*.spec.ts +git commit -m "feat(ag-ui): classify errors via toAgentError + neutral retry()" +``` + +--- + +## Task 5: `ChatErrorComponent` — legible message + conditional Retry + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-error/chat-error.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts` +- Modify: `libs/chat/src/lib/styles/chat-error.styles.ts` (Retry button styling) + +- [ ] **Step 1: Update the component.** Read `agent().error()` (now `AgentError | undefined`). Keep `extractErrorMessage` (it handles `Error.message`). Add a computed `retryable = computed(() => this.agent().error()?.retryable ?? false)` and render a Retry button when true: +```ts +template: ` + @if (agent().error(); as err) { + + } +`, +``` +Add a `.chat-error__retry` style to `chat-error.styles.ts` consistent with the existing button styling in the lib. + +- [ ] **Step 2: Update the spec.** Extend `chat-error.component.spec.ts`: renders `err.message` for an `AgentError`; shows a Retry button when `retryable` true and hides it when false; clicking Retry calls `agent.retry()`. Use a mock agent whose `error` signal returns an `AgentError` (use `mockAgent` from `@threadplane/chat` testing if it supports setting error, else a minimal stub). + +- [ ] **Step 3: Verify.** `npx nx run-many -t test lint build --projects=chat --skip-nx-cache` — green. + +- [ ] **Step 4: Commit.** +```bash +git add libs/chat/src/lib/primitives/chat-error +git commit -m "feat(chat): ChatErrorComponent renders legible AgentError message + conditional Retry" +``` + +--- + +## Task 6: e2e — upgrade `examples/chat` error-handling + +**Files:** +- Modify: `examples/chat/angular/e2e/error-handling.spec.ts` + +- [ ] **Step 1: Strengthen assertions.** Keep the route-abort setup (fail-fast retries already opt-in via localStorage). After the failure, assert the banner shows a *legible* message (e.g. matches `/can't reach|server|connection|interrupted|try again/i`, NOT a bare `HTTP \d{3}` SDK string) and that a **Retry** button with accessible name `/retry/i` is visible. Then click Retry (after `page.unroute`) and assert recovery (assistant bubble appears) — in addition to / instead of the existing "next send recovers" path. + +- [ ] **Step 2: Run.** Free ports first (`4200/4201/2024`); `npx nx e2e examples-chat-angular --skip-nx-cache -- --grep "error handling"`. Expect PASS. (Re-run once in isolation if a streaming flake appears — known-flaky suite.) + +- [ ] **Step 3: Commit.** +```bash +git add examples/chat/angular/e2e/error-handling.spec.ts +git commit -m "test(examples/chat): assert legible error message + Retry button + retry recovery" +``` + +--- + +## Task 7: Full verification + PR + +- [ ] **Step 1: Full gate.** `npx nx run-many -t test lint build type-tests --projects=chat,langgraph,ag-ui --skip-nx-cache` — all green. Build one example: `npx nx build examples-chat-angular`. +- [ ] **Step 2: Final whole-implementation code review** (most-capable reviewer): classifier correctness (each kind, idempotency, status parsing edge cases), abort-vs-interrupted distinction soundness in both adapters, `retry()` no-ops, no internal-type leak, `AgentError extends Error` keeps `.message` consumers working. +- [ ] **Step 3: Open PR** against main, enable auto-merge (`gh pr merge --squash --auto`). If `BEHIND` (post-#684 branch protection requires up-to-date), update from main and re-verify. Regenerate `api-docs` (`npm run generate-api-docs`) and commit if the chat/langgraph/ag-ui api-docs change (new `AgentError`/`retry` exports), to preempt the api-docs bot. +- [ ] **Step 4: Finish** via `superpowers:finishing-a-development-branch`. + +--- + +## Self-Review (against the spec) + +- **Coverage:** AgentError + classifier (Task 1) ✓; contract re-type + retry + exports (Task 2) ✓; langgraph normalize/abort→idle/retry (Task 3) ✓; ag-ui normalize/retry (Task 4) ✓; ChatErrorComponent message + Retry (Task 5) ✓; e2e (Task 6) ✓; verify+PR (Task 7) ✓. +- **Type consistency:** `AgentError`, `AgentErrorKind`, `toAgentError`, `isAbortError`, `AGENT_ERROR_MESSAGES`, `Agent.error: Signal`, `Agent.retry()` used consistently across tasks. +- **No placeholders:** classifier + AgentError have full code; adapter steps reference real files/line-areas (bridge catch ~435, agent.fn reload ~529, ag-ui submit ~262) and name the exact edits. The `interrupted` adapter-vs-classifier split is called out explicitly. From e7f0c14d29402083d4ca1fc7e8547da828d8e11d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 13:25:40 -0700 Subject: [PATCH 03/11] feat(chat): AgentError + toAgentError 5-class classifier (connection/auth/server/interrupted/aborted) Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/lib/agent/agent-error.ts | 30 ++++++++++++ .../chat/src/lib/agent/to-agent-error.spec.ts | 44 ++++++++++++++++++ libs/chat/src/lib/agent/to-agent-error.ts | 46 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 libs/chat/src/lib/agent/agent-error.ts create mode 100644 libs/chat/src/lib/agent/to-agent-error.spec.ts create mode 100644 libs/chat/src/lib/agent/to-agent-error.ts diff --git a/libs/chat/src/lib/agent/agent-error.ts b/libs/chat/src/lib/agent/agent-error.ts new file mode 100644 index 00000000..aeb7cb29 --- /dev/null +++ b/libs/chat/src/lib/agent/agent-error.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +export type AgentErrorKind = 'connection' | 'auth' | 'server' | 'interrupted' | 'aborted'; + +/** Structured, classified failure surfaced on `Agent.error`. Extends `Error` + * so existing `.message` / `instanceof Error` reads keep working. */ +export class AgentError extends Error { + readonly kind: AgentErrorKind; + /** connection | server | interrupted → true; auth | aborted | non-auth-4xx → false. */ + readonly retryable: boolean; + readonly status?: number; + override readonly cause: unknown; + + constructor(init: { kind: AgentErrorKind; message: string; retryable: boolean; status?: number; cause?: unknown }) { + super(init.message); + this.name = 'AgentError'; + this.kind = init.kind; + this.retryable = init.retryable; + this.status = init.status; + this.cause = init.cause; + } +} + +/** Default human-facing copy per kind. */ +export const AGENT_ERROR_MESSAGES: Record = { + connection: "Can't reach the server. Check your connection and try again.", + auth: 'Authentication failed. Check your API key or credentials.', + server: 'The server ran into an error. You can try again.', + interrupted: 'The response was interrupted. Try again.', + aborted: 'Stopped.', +}; diff --git a/libs/chat/src/lib/agent/to-agent-error.spec.ts b/libs/chat/src/lib/agent/to-agent-error.spec.ts new file mode 100644 index 00000000..8532fbaf --- /dev/null +++ b/libs/chat/src/lib/agent/to-agent-error.spec.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { AgentError } from './agent-error'; +import { toAgentError, isAbortError } from './to-agent-error'; + +describe('toAgentError', () => { + it('classifies HTTP 500 message as server + retryable + status', () => { + const e = toAgentError(new Error('HTTP 500: Internal Server Error')); + expect(e).toBeInstanceOf(AgentError); + expect(e.kind).toBe('server'); expect(e.retryable).toBe(true); expect(e.status).toBe(500); + }); + it('classifies 401 as auth + not retryable', () => { + const e = toAgentError(new Error('HTTP 401: Unauthorized')); + expect(e.kind).toBe('auth'); expect(e.retryable).toBe(false); expect(e.status).toBe(401); + }); + it('classifies non-auth 4xx as server + not retryable', () => { + const e = toAgentError(new Error('HTTP 404: Not Found')); + expect(e.kind).toBe('server'); expect(e.retryable).toBe(false); expect(e.status).toBe(404); + }); + it('classifies fetch failure as connection + retryable', () => { + const e = toAgentError(new TypeError('Failed to fetch')); + expect(e.kind).toBe('connection'); expect(e.retryable).toBe(true); + }); + it('classifies AbortError as aborted + not retryable', () => { + const ab = new Error('The operation was aborted'); ab.name = 'AbortError'; + const e = toAgentError(ab); + expect(e.kind).toBe('aborted'); expect(e.retryable).toBe(false); + expect(isAbortError(ab)).toBe(true); + }); + it('preserves cause and is idempotent', () => { + const raw = new Error('HTTP 500: boom'); + const once = toAgentError(raw); + expect(once.cause).toBe(raw); + expect(toAgentError(once)).toBe(once); + }); + it('falls back to server + retryable for unknown shapes', () => { + const e = toAgentError({ weird: true }); + expect(e.kind).toBe('server'); expect(e.retryable).toBe(true); + }); + it('reads a structured status off the error/cause', () => { + const e = toAgentError({ status: 503, message: 'Service Unavailable' }); + expect(e.kind).toBe('server'); expect(e.status).toBe(503); expect(e.retryable).toBe(true); + }); +}); diff --git a/libs/chat/src/lib/agent/to-agent-error.ts b/libs/chat/src/lib/agent/to-agent-error.ts new file mode 100644 index 00000000..84202955 --- /dev/null +++ b/libs/chat/src/lib/agent/to-agent-error.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +import { AgentError, AGENT_ERROR_MESSAGES, type AgentErrorKind } from './agent-error'; + +/** True when `raw` represents a user-requested abort. Shared by adapters + classifier. */ +export function isAbortError(raw: unknown): boolean { + return raw instanceof Error && (raw.name === 'AbortError' || /\babort/i.test(raw.message)); +} + +function readStatus(raw: unknown): number | undefined { + const obj = raw as { status?: unknown; cause?: { status?: unknown } } | null; + const direct = typeof obj?.status === 'number' ? obj.status : undefined; + const viaCause = typeof obj?.cause?.status === 'number' ? obj!.cause!.status : undefined; + if (direct !== undefined) return direct; + if (viaCause !== undefined) return viaCause; + const msg = raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : ''; + const m = /\b(\d{3})\b/.exec(msg); + return m ? Number(m[1]) : undefined; +} + +function isConnectionError(raw: unknown): boolean { + if (!(raw instanceof Error)) return false; + return /failed to fetch|networkerror|econnrefused|enotfound|network request failed|load failed/i.test( + `${raw.name} ${raw.message}`, + ); +} + +function make(kind: AgentErrorKind, retryable: boolean, raw: unknown, status?: number, message?: string): AgentError { + return new AgentError({ kind, retryable, status, cause: raw, message: message ?? AGENT_ERROR_MESSAGES[kind] }); +} + +/** Classify any raw error into a structured {@link AgentError}. Idempotent. */ +export function toAgentError(raw: unknown): AgentError { + if (raw instanceof AgentError) return raw; + if (isAbortError(raw)) return make('aborted', false, raw); + + const status = readStatus(raw); + if (status !== undefined) { + if (status === 401 || status === 403) return make('auth', false, raw, status); + if (status >= 500) return make('server', true, raw, status); + if (status >= 400) return make('server', false, raw, status, `The request was rejected (HTTP ${status}).`); + } + if (isConnectionError(raw)) return make('connection', true, raw); + + const msg = raw instanceof Error && raw.message ? raw.message : 'Something went wrong. You can try again.'; + return make('server', true, raw, status, msg); +} From cc97250d6394eace84ee45180b99f080aed2365c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 13:56:09 -0700 Subject: [PATCH 04/11] feat(chat): re-type Agent.error as Signal + add neutral retry() Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/project.json | 6 ++++++ .../src/lib/agent/agent-error.type-spec.ts | 10 ++++++++++ libs/chat/src/lib/agent/agent.spec.ts | 6 ++++-- libs/chat/src/lib/agent/agent.ts | 7 ++++++- libs/chat/src/lib/agent/index.ts | 3 +++ .../client-tools/client-tool-executor.spec.ts | 3 ++- .../client-tools-coordinator.spec.ts | 3 ++- libs/chat/src/lib/testing/mock-agent.ts | 8 +++++--- libs/chat/src/public-api.ts | 5 +++++ libs/chat/src/testing/type-assert.ts | 18 ++++++++++++++++++ libs/chat/tsconfig.type-tests.json | 15 +++++++++++++++ 11 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 libs/chat/src/lib/agent/agent-error.type-spec.ts create mode 100644 libs/chat/src/testing/type-assert.ts create mode 100644 libs/chat/tsconfig.type-tests.json diff --git a/libs/chat/project.json b/libs/chat/project.json index e04ed3cf..ae4a6dac 100644 --- a/libs/chat/project.json +++ b/libs/chat/project.json @@ -47,6 +47,12 @@ "options": { "configFile": "libs/chat/vite.config.mts" } + }, + "type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsc --project libs/chat/tsconfig.type-tests.json --noEmit" + } } } } diff --git a/libs/chat/src/lib/agent/agent-error.type-spec.ts b/libs/chat/src/lib/agent/agent-error.type-spec.ts new file mode 100644 index 00000000..39567af3 --- /dev/null +++ b/libs/chat/src/lib/agent/agent-error.type-spec.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +import type { Signal } from '@angular/core'; +import type { Equal, Expect } from '../../testing/type-assert'; +import type { Agent } from './agent'; +import { AgentError } from './agent-error'; + +type _errTyped = Expect>>; +type _retry = Expect Promise>>; +const _isErr: Error = new AgentError({ kind: 'server', message: 'x', retryable: true }); +export { _isErr }; diff --git a/libs/chat/src/lib/agent/agent.spec.ts b/libs/chat/src/lib/agent/agent.spec.ts index 5725dcb1..397a2677 100644 --- a/libs/chat/src/lib/agent/agent.spec.ts +++ b/libs/chat/src/lib/agent/agent.spec.ts @@ -8,11 +8,12 @@ describe('Agent interface', () => { messages: signal([]), status: signal('idle'), isLoading: signal(false), - error: signal(null), + error: signal(undefined), toolCalls: signal([]), state: signal({}), submit: async () => Promise.resolve(), stop: async () => Promise.resolve(), + retry: async () => Promise.resolve(), }; expect(agent.status()).toBe('idle'); }); @@ -22,13 +23,14 @@ describe('Agent interface', () => { messages: signal([]), status: signal('idle'), isLoading: signal(false), - error: signal(null), + error: signal(undefined), toolCalls: signal([]), state: signal({}), interrupt: signal(undefined), subagents: signal(new Map()), submit: async () => Promise.resolve(), stop: async () => Promise.resolve(), + retry: async () => Promise.resolve(), }; expect(agent.interrupt?.()).toBeUndefined(); }); diff --git a/libs/chat/src/lib/agent/agent.ts b/libs/chat/src/lib/agent/agent.ts index bd2a9e33..d57402cc 100644 --- a/libs/chat/src/lib/agent/agent.ts +++ b/libs/chat/src/lib/agent/agent.ts @@ -9,6 +9,7 @@ import type { Subagent } from './subagent'; import type { AgentEvent } from './agent-event'; import type { AgentSubmitInput, AgentSubmitOptions } from './agent-submit'; import type { ClientToolsCapability } from '../client-tools/client-tools-capability'; +import type { AgentError } from './agent-error'; /** * Runtime-neutral contract chat primitives consume. @@ -28,7 +29,7 @@ export interface Agent { messages: Signal; status: Signal; isLoading: Signal; - error: Signal; + error: Signal; toolCalls: Signal; state: Signal>; @@ -36,6 +37,10 @@ export interface Agent { submit: (input: AgentSubmitInput, opts?: AgentSubmitOptions) => Promise; stop: () => Promise; + /** Re-run the last submitted input after a failure. No-op if a run is already + * in flight or there is nothing to retry. Clears `error` and sets loading. */ + retry: () => Promise; + /** * Discards the assistant message at the given index AND all messages after * it, then re-runs the agent against the trimmed conversation tail. The diff --git a/libs/chat/src/lib/agent/index.ts b/libs/chat/src/lib/agent/index.ts index 81f528f5..9dfa924d 100644 --- a/libs/chat/src/lib/agent/index.ts +++ b/libs/chat/src/lib/agent/index.ts @@ -1,5 +1,8 @@ // SPDX-License-Identifier: MIT export type { Agent } from './agent'; +export { AgentError, AGENT_ERROR_MESSAGES } from './agent-error'; +export type { AgentErrorKind } from './agent-error'; +export { toAgentError, isAbortError } from './to-agent-error'; export type { Citation } from './citation'; export type { Message, Role } from './message'; export { isUserMessage, isAssistantMessage, isToolMessage, isSystemMessage } from './message'; diff --git a/libs/chat/src/lib/client-tools/client-tool-executor.spec.ts b/libs/chat/src/lib/client-tools/client-tool-executor.spec.ts index c3984a81..d6556746 100644 --- a/libs/chat/src/lib/client-tools/client-tool-executor.spec.ts +++ b/libs/chat/src/lib/client-tools/client-tool-executor.spec.ts @@ -39,12 +39,13 @@ function makeFakeAgent(capability: ClientToolsCapability): Agent { messages: signal([]), status: signal('idle'), isLoading: signal(false), - error: signal(null), + error: signal(undefined), toolCalls: signal([]), state: signal({}), events$: { subscribe: () => ({ unsubscribe: () => undefined }) } as never, submit: vi.fn(), stop: vi.fn(), + retry: vi.fn(), regenerate: vi.fn(), clientTools: capability, }; diff --git a/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts b/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts index ddfc2aa3..daf6100e 100644 --- a/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts +++ b/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts @@ -41,12 +41,13 @@ function makeFakeAgent(capability: ClientToolsCapability | undefined): Agent { messages: signal([]), status: signal('idle'), isLoading: signal(false), - error: signal(null), + error: signal(undefined), toolCalls: signal([]), state: signal({}), events$: { subscribe: () => ({ unsubscribe: () => undefined }) } as never, submit: vi.fn(), stop: vi.fn(), + retry: vi.fn(), regenerate: vi.fn(), clientTools: capability, }; diff --git a/libs/chat/src/lib/testing/mock-agent.ts b/libs/chat/src/lib/testing/mock-agent.ts index d8dd083e..84d684a0 100644 --- a/libs/chat/src/lib/testing/mock-agent.ts +++ b/libs/chat/src/lib/testing/mock-agent.ts @@ -13,12 +13,13 @@ import type { AgentEvent, AgentCheckpoint, } from '../agent'; +import type { AgentError } from '../agent/agent-error'; export interface MockAgent extends Agent { messages: WritableSignal; status: WritableSignal; isLoading: WritableSignal; - error: WritableSignal; + error: WritableSignal; toolCalls: WritableSignal; state: WritableSignal>; interrupt?: WritableSignal; @@ -53,7 +54,7 @@ export interface MockAgentOptions { messages?: Message[]; status?: AgentStatus; isLoading?: boolean; - error?: unknown; + error?: AgentError; toolCalls?: ToolCall[]; state?: Record; withInterrupt?: boolean; @@ -66,7 +67,7 @@ export function mockAgent(opts: MockAgentOptions = {}): MockAgent { const messages = signal(opts.messages ?? []); const status = signal(opts.status ?? 'idle'); const isLoading = signal(opts.isLoading ?? false); - const error = signal(opts.error ?? null); + const error = signal(opts.error ?? undefined); const toolCalls = signal(opts.toolCalls ?? []); const state = signal>(opts.state ?? {}); @@ -95,6 +96,7 @@ export function mockAgent(opts: MockAgentOptions = {}): MockAgent { events$: opts.events$ ?? EMPTY, submit: async (input, submitOpts) => { submitCalls.push({ input, opts: submitOpts }); }, stop: async () => { stopCount++; }, + retry: async () => { return; }, regenerate: async (assistantMessageIndex: number) => { // Truncate messages [N..end] and record the call as a synthetic submit so // tests can assert regenerate behavior via the same submitCalls log. diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 2ca4e56a..cef437d6 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -34,7 +34,12 @@ export { isAssistantMessage, isToolMessage, isSystemMessage, + AgentError, + AGENT_ERROR_MESSAGES, + toAgentError, + isAbortError, } from './lib/agent'; +export type { AgentErrorKind } from './lib/agent'; // Primitives export { ChatMessageListComponent, getMessageType } from './lib/primitives/chat-message-list/chat-message-list.component'; diff --git a/libs/chat/src/testing/type-assert.ts b/libs/chat/src/testing/type-assert.ts new file mode 100644 index 00000000..c7f9e1ab --- /dev/null +++ b/libs/chat/src/testing/type-assert.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +/** + * Compile-time type-assertion helpers. + * + * Usage: + * type _check = Expect>; + * + * The assertion is enforced purely at compile time — no runtime code is emitted. + */ + +/** Resolves to `true` only when A and B are mutually assignable (i.e. identical). */ +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) + ? true + : false; + +/** Causes a compile error when T is not `true`. */ +export type Expect = T; diff --git a/libs/chat/tsconfig.type-tests.json b/libs/chat/tsconfig.type-tests.json new file mode 100644 index 00000000..68ecd35d --- /dev/null +++ b/libs/chat/tsconfig.type-tests.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/type-tests", + "noEmit": true, + "composite": false, + "declarationMap": false, + "emitDeclarationOnly": false, + "strict": true, + "noUnusedLocals": false, + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["src/**/*.type-spec.ts", "src/testing/type-assert.ts"] +} From ba7c5f6c3fe7c8072a5b7a9ce52715fd47177992 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 14:10:55 -0700 Subject: [PATCH 05/11] =?UTF-8?q?feat(langgraph):=20classify=20errors=20vi?= =?UTF-8?q?a=20toAgentError,=20abort=E2=86=92idle,=20neutral=20retry()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 via a documented cast (BehaviorSubject stays unknown to satisfy StreamSubjects invariance). - MockLangGraphAgent.error re-typed to WritableSignal. - 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 --- libs/langgraph/src/lib/agent.fn.spec.ts | 77 +++++++++++++++++++ libs/langgraph/src/lib/agent.fn.ts | 12 ++- .../internals/stream-manager.bridge.spec.ts | 7 +- .../lib/internals/stream-manager.bridge.ts | 29 ++++++- .../lib/testing/mock-langgraph-agent.spec.ts | 2 +- .../src/lib/testing/mock-langgraph-agent.ts | 3 +- 6 files changed, 121 insertions(+), 9 deletions(-) diff --git a/libs/langgraph/src/lib/agent.fn.spec.ts b/libs/langgraph/src/lib/agent.fn.spec.ts index 89651759..1a1575f8 100644 --- a/libs/langgraph/src/lib/agent.fn.spec.ts +++ b/libs/langgraph/src/lib/agent.fn.spec.ts @@ -9,6 +9,7 @@ import type { ThreadState } from '@langchain/langgraph-sdk'; import { createLangGraphClient } from './client/create-langgraph-client'; import { LANGGRAPH_CLIENT_OPTIONS } from './client/client-options'; import { AGENT_CONFIG } from './agent.provider'; +import { AgentError } from '@threadplane/chat'; vi.mock('./client/create-langgraph-client', async (importOriginal) => { const actual = await importOriginal(); @@ -902,6 +903,81 @@ describe('agent', () => { await expect(ref.regenerate(0)).rejects.toThrow(/No user message/); }); }); + + // ── Error-UX: normalize, abort→idle, retry() ───────────────────────────── + + it('error() is AgentError with kind="server" on an HTTP 500 failure', async () => { + const transport = new MockAgentTransport(); + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', transport }) + ); + ref.submit({ message: 'hello' }); + transport.emitError(new Error('HTTP 500: boom')); + await new Promise(r => setTimeout(r, 20)); + + expect(ref.status()).toBe('error'); + const err = ref.error(); + expect(err).toBeInstanceOf(AgentError); + expect((err as AgentError).kind).toBe('server'); + expect((err as AgentError).retryable).toBe(true); + }); + + it('user stop() → status becomes idle and error() stays undefined', async () => { + const transport = new MockAgentTransport(); + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', transport }) + ); + ref.submit({ message: 'hello' }); + // stop() is user-initiated — should NOT produce an error + await ref.stop(); + await new Promise(r => setTimeout(r, 20)); + + expect(ref.status()).toBe('idle'); + expect(ref.error()).toBeUndefined(); + }); + + it('retry() clears error and calls resubmitLast', async () => { + const transport = new MockAgentTransport(); + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', transport }) + ); + + // Drive to an error state + const submitted = ref.submit({ message: 'hello' }); + transport.emitError(new Error('HTTP 503: service unavailable')); + await submitted.catch(() => undefined); + await new Promise(r => setTimeout(r, 20)); + + expect(ref.status()).toBe('error'); + expect(ref.error()).toBeInstanceOf(AgentError); + + // Invoke retry(); the error should clear and a new run should start + const retryPromise = ref.retry(); + // error is cleared synchronously at the start of retry() + expect(ref.error()).toBeUndefined(); + // isLoading should become true once resubmitLast kicks off runStream + expect(ref.isLoading()).toBe(true); + + // Clean up the new run + transport.close(); + await retryPromise; + }); + + it('retry() is a no-op while a run is already in flight', async () => { + const transport = new MockAgentTransport(); + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', transport }) + ); + ref.submit({ message: 'hello' }); + expect(ref.isLoading()).toBe(true); + + // retry() while loading should not start a second run or throw + await ref.retry(); + // Still one active stream; transport only received one stream call + expect(transport.streams).toHaveLength(1); + + await ref.stop(); + }); }); describe('agent — LANGGRAPH_CLIENT_OPTIONS resolution (no mock transport)', () => { @@ -918,6 +994,7 @@ describe('agent — LANGGRAPH_CLIENT_OPTIONS resolution (no mock transport)', () search: vi.fn().mockResolvedValue([]), }, runs: { + // eslint-disable-next-line @typescript-eslint/no-empty-function stream: vi.fn().mockReturnValue((async function* () {})()), }, }; diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 92c3bf07..bb94509d 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -36,6 +36,7 @@ import type { BagTemplate, InferBag } from '@langchain/langgraph-sdk'; import type { AgentEvent, AgentCheckpoint, + AgentError, AgentInterrupt, AgentStatus, Message, @@ -324,7 +325,10 @@ export function agent< // CD anyway. const rawMessages = toSignal(messages$, { initialValue: [] as BaseMessage[] }); const statusSig = toSignal(status$, { initialValue: ResourceStatus.Idle }); - const errorSig = toSignal(error$, { initialValue: undefined as unknown }); + // Cast justified: error$ accepts only AgentError | undefined (bridge catch normalizes all errors via + // toAgentError before calling next(); resetDerivedThreadState passes undefined). The BehaviorSubject + // is typed unknown to satisfy StreamSubjects invariance at the subjects-bag assignment. + const errorSig = toSignal(error$, { initialValue: undefined }) as Signal; const hasValueSig = toSignal(hasValue$, { initialValue: false }); const interruptSig = toSignal(interrupt$, { initialValue: undefined }); const interruptsSig= toSignal(interrupts$, { initialValue: [] }); @@ -448,6 +452,12 @@ export function agent< }, stop: () => manager.stop(), + retry: async () => { + if (isLoading()) return; // no-op while a run is in flight + error$.next(undefined); // clear the error before re-running + await manager.resubmitLast(); + }, + clientTools: clientToolsCap, regenerate: async (assistantMessageIndex: number): Promise => { diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts index 0063db73..53f88cb0 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts @@ -763,7 +763,7 @@ describe('createStreamManagerBridge', () => { destroy$.next(); }); - it('stop() aborts the active stream', async () => { + it('stop() aborts the active stream and sets status to Idle (user-stop is not an error)', async () => { const transport = new MockAgentTransport(); const subjects = makeSubjects(); const destroy$ = new Subject(); @@ -775,7 +775,10 @@ describe('createStreamManagerBridge', () => { }); bridge.submit({}); await bridge.stop(); - expect(subjects.status$.value).toBe(ResourceStatus.Resolved); + // User-initiated stop → Idle (not Resolved, not Error) + expect(subjects.status$.value).toBe(ResourceStatus.Idle); + // No error published + expect(subjects.error$.value).toBeUndefined(); destroy$.next(); }); diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index 2cedcf13..83929321 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -20,6 +20,7 @@ import type { AgentRuntimeTelemetryProperties, AgentRuntimeTelemetrySink, } from '@threadplane/chat'; +import { AgentError, toAgentError, isAbortError } from '@threadplane/chat'; import { SubagentTracker, TrackedSubagent, @@ -112,6 +113,8 @@ export function createStreamManagerBridge(); const queuedRuns: AgentQueueEntry[] = []; let drainingQueue = false; @@ -377,6 +380,7 @@ export function createStreamManagerBridge { abortController?.abort(); abortController = new AbortController(); + userAbortRequested = false; const startedAt = Date.now(); captureRuntimeRequestTelemetry(requestType); captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties); @@ -389,6 +393,11 @@ export function createStreamManagerBridge { + userAbortRequested = true; abortController?.abort(); await clearQueue(); - subjects.status$.next(ResourceStatus.Resolved); + // Note: status is set to Idle by the runStream() catch when it sees + // isAbortError && userAbortRequested. The explicit set here handles + // the case where stop() is called when no stream is active (so the + // catch never fires) or when clearQueue() raised an error. + if (subjects.status$.value !== ResourceStatus.Idle) { + subjects.status$.next(ResourceStatus.Idle); + } }, switchThread: (id) => { diff --git a/libs/langgraph/src/lib/testing/mock-langgraph-agent.spec.ts b/libs/langgraph/src/lib/testing/mock-langgraph-agent.spec.ts index 6bcfe212..bd66a154 100644 --- a/libs/langgraph/src/lib/testing/mock-langgraph-agent.spec.ts +++ b/libs/langgraph/src/lib/testing/mock-langgraph-agent.spec.ts @@ -10,7 +10,7 @@ describe('mockLangGraphAgent', () => { expect(ref.langGraphMessages()).toEqual([]); expect(ref.status()).toBe('idle'); expect(ref.isLoading()).toBe(false); - expect(ref.error()).toBeNull(); + expect(ref.error()).toBeUndefined(); expect(ref.hasValue()).toBe(false); expect(ref.isThreadLoading()).toBe(false); expect(ref.interrupt()).toBeUndefined(); diff --git a/libs/langgraph/src/lib/testing/mock-langgraph-agent.ts b/libs/langgraph/src/lib/testing/mock-langgraph-agent.ts index 21f62c29..bad894ed 100644 --- a/libs/langgraph/src/lib/testing/mock-langgraph-agent.ts +++ b/libs/langgraph/src/lib/testing/mock-langgraph-agent.ts @@ -16,6 +16,7 @@ import { mockAgent } from '@threadplane/chat'; import type { MockAgent, MockAgentOptions, + AgentError, AgentInterrupt, AgentCheckpoint, AgentStatus, @@ -43,7 +44,7 @@ export interface MockLangGraphAgent extends LangGraphAgent { messages: WritableSignal; status: WritableSignal; isLoading: WritableSignal; - error: WritableSignal; + error: WritableSignal; toolCalls: WritableSignal; interrupt: WritableSignal; subagents: WritableSignal>; From a3860c6427c006c2a7581d7354770eab46aea6bc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 14:22:30 -0700 Subject: [PATCH 06/11] fix(langgraph): classify non-user aborts (connection/interrupted) + normalize all error$ sites via toAgentError Co-Authored-By: Claude Sonnet 4.6 --- .../internals/stream-manager.bridge.spec.ts | 34 +++++++++++++++++++ .../lib/internals/stream-manager.bridge.ts | 26 +++++++++----- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts index 53f88cb0..08339188 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts @@ -4,6 +4,7 @@ import { createStreamManagerBridge } from './stream-manager.bridge'; import { MockAgentTransport } from '../transport/mock-stream.transport'; import { ResourceStatus, AgentTransport, StreamSubjects, CustomStreamEvent, StreamEvent } from '../agent.types'; import type { AgentRuntimeTelemetryPayload } from '@threadplane/chat'; +import { AgentError } from '@threadplane/chat'; import type { ThreadState } from '@langchain/langgraph-sdk'; import { of } from 'rxjs'; import { readFileSync } from 'node:fs'; @@ -782,6 +783,39 @@ describe('createStreamManagerBridge', () => { destroy$.next(); }); + it('classifies a non-user AbortError thrown BEFORE streaming as connection (kind:connection, retryable:true)', async () => { + // Simulate an SDK that surfaces a connect-phase failure as an AbortError-like + // error (name === 'AbortError') even though the user never called stop(). + // The bridge must NOT classify this as 'aborted' (user stop) — it must + // classify it as 'connection' because streamingStarted is false. + const connectFailure = new Error('Connection timed out'); + connectFailure.name = 'AbortError'; + + const transport: AgentTransport = { + async *stream() { + yield* []; // required by require-yield; yields nothing, so streamingStarted stays false + throw connectFailure; // throws before any event is processed + }, + }; + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of('thread-1'), + destroy$: destroy$.asObservable(), + }); + + await bridge.submit({}); + + expect(subjects.status$.value).toBe(ResourceStatus.Error); + const err = subjects.error$.value; + expect(err).toBeInstanceOf(AgentError); + expect((err as AgentError).kind).toBe('connection'); + expect((err as AgentError).retryable).toBe(true); + destroy$.next(); + }); + it('routes custom events to custom$ subject', async () => { const transport = new MockAgentTransport(); const subjects = makeSubjects(); diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index 83929321..f06086a1 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -155,7 +155,7 @@ export function createStreamManagerBridge subjects.error$.next(err)); + void cancelQueueEntries(takeQueuedRuns()).catch(err => subjects.error$.next(toAgentError(err))); publishQueue(); subjects.custom$.next([]); subjects.isThreadLoading$.next(false); @@ -240,7 +240,7 @@ export function createStreamManagerBridge Date: Thu, 18 Jun 2026 14:29:51 -0700 Subject: [PATCH 07/11] feat(ag-ui): classify errors via toAgentError + neutral retry() (re-run last input, no duplicate message) Co-Authored-By: Claude Sonnet 4.6 --- libs/ag-ui/src/lib/client-tools.spec.ts | 3 +- libs/ag-ui/src/lib/reducer.spec.ts | 17 ++-- libs/ag-ui/src/lib/reducer.ts | 10 ++- .../src/lib/to-agent.conformance.spec.ts | 3 +- libs/ag-ui/src/lib/to-agent.spec.ts | 87 +++++++++++++++++-- libs/ag-ui/src/lib/to-agent.ts | 62 +++++++++---- 6 files changed, 145 insertions(+), 37 deletions(-) diff --git a/libs/ag-ui/src/lib/client-tools.spec.ts b/libs/ag-ui/src/lib/client-tools.spec.ts index 2bd7ac37..9739f318 100644 --- a/libs/ag-ui/src/lib/client-tools.spec.ts +++ b/libs/ag-ui/src/lib/client-tools.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { signal } from '@angular/core'; import { Subject } from 'rxjs'; +import { type AgentError } from '@threadplane/chat'; import type { AgentEvent, AgentStatus, Message, ToolCall } from '@threadplane/chat'; import type { ReducerStore, CustomStreamEvent } from './reducer'; import { createClientToolsCapability } from './client-tools'; @@ -12,7 +13,7 @@ function makeStore(): ReducerStore { messages: signal([]), status: signal('idle'), isLoading: signal(false), - error: signal(null), + error: signal(undefined), toolCalls: signal([]), state: signal>({}), interrupt: signal(undefined), diff --git a/libs/ag-ui/src/lib/reducer.spec.ts b/libs/ag-ui/src/lib/reducer.spec.ts index f94e1b2f..cf1b5624 100644 --- a/libs/ag-ui/src/lib/reducer.spec.ts +++ b/libs/ag-ui/src/lib/reducer.spec.ts @@ -2,9 +2,7 @@ import { describe, it, expect } from 'vitest'; import { signal } from '@angular/core'; import { Subject } from 'rxjs'; -import type { - Message, AgentStatus, ToolCall, AgentEvent, -} from '@threadplane/chat'; +import { AgentError, type AgentStatus, type Message, type ToolCall, type AgentEvent } from '@threadplane/chat'; import { reduceEvent, type ReducerStore, type CustomStreamEvent, type ActivityEntry } from './reducer'; function makeStore(): ReducerStore { @@ -12,7 +10,7 @@ function makeStore(): ReducerStore { messages: signal([]), status: signal('idle'), isLoading: signal(false), - error: signal(null), + error: signal(undefined), toolCalls: signal([]), state: signal>({}), interrupt: signal(undefined), @@ -25,11 +23,12 @@ function makeStore(): ReducerStore { describe('reduceEvent', () => { it('RUN_STARTED sets status running, isLoading true, clears error', () => { const store = makeStore(); - store.error.set('previous'); + // Seed a previous AgentError so the clear can be observed. + store.error.set(new AgentError({ kind: 'server', message: 'previous', retryable: true })); reduceEvent({ type: 'RUN_STARTED' } as any, store); expect(store.status()).toBe('running'); expect(store.isLoading()).toBe(true); - expect(store.error()).toBeNull(); + expect(store.error()).toBeUndefined(); }); it('RUN_FINISHED sets status idle, isLoading false', () => { @@ -41,11 +40,13 @@ describe('reduceEvent', () => { expect(store.isLoading()).toBe(false); }); - it('RUN_ERROR sets status error, captures message', () => { + it('RUN_ERROR sets status error, normalizes to AgentError', () => { const store = makeStore(); reduceEvent({ type: 'RUN_ERROR', message: 'boom' } as any, store); expect(store.status()).toBe('error'); - expect(store.error()).toBe('boom'); + const err = store.error(); + expect(err).toBeInstanceOf(AgentError); + expect(err?.message).toContain('boom'); }); it('TEXT_MESSAGE_START appends an empty assistant message', () => { diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts index 795ed0bc..6dc2cadc 100644 --- a/libs/ag-ui/src/lib/reducer.ts +++ b/libs/ag-ui/src/lib/reducer.ts @@ -5,6 +5,7 @@ // file has no runtime dependency on the EventType enum import. import { signal, type WritableSignal } from '@angular/core'; import type { Subject } from 'rxjs'; +import { toAgentError, type AgentError } from '@threadplane/chat'; import type { Message, AgentStatus, ToolCall, AgentEvent, AgentInterrupt, } from '@threadplane/chat'; @@ -56,7 +57,7 @@ export interface ReducerStore { messages: WritableSignal; status: WritableSignal; isLoading: WritableSignal; - error: WritableSignal; + error: WritableSignal; toolCalls: WritableSignal; state: WritableSignal>; interrupt: WritableSignal; @@ -101,7 +102,7 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { case 'RUN_STARTED': { store.status.set('running'); store.isLoading.set(true); - store.error.set(null); + store.error.set(undefined); store.interrupt.set(undefined); store.customEvents.set([]); store.activities.set(new Map()); @@ -115,7 +116,10 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { case 'RUN_ERROR': { store.status.set('error'); store.isLoading.set(false); - store.error.set((event as { message?: unknown }).message ?? event); + const runErrorMsg = (event as { message?: unknown }).message; + store.error.set(toAgentError( + typeof runErrorMsg === 'string' ? new Error(runErrorMsg) : (runErrorMsg ?? event), + )); return; } case 'TEXT_MESSAGE_START': { diff --git a/libs/ag-ui/src/lib/to-agent.conformance.spec.ts b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts index cc26734c..d3ff7401 100644 --- a/libs/ag-ui/src/lib/to-agent.conformance.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts @@ -53,6 +53,7 @@ import { import { reduceEvent } from './reducer'; import { signal } from '@angular/core'; import { Subject } from 'rxjs'; +import { type AgentError } from '@threadplane/chat'; import type { Message, AgentStatus, ToolCall, AgentEvent } from '@threadplane/chat'; function abstractToAgUi(event: AbstractEvent, messageId: string): any { @@ -72,7 +73,7 @@ describe('AG-UI reducer — reasoning-fixture conformance', () => { messages: signal([]), status: signal('idle'), isLoading: signal(false), - error: signal(null), + error: signal(undefined), toolCalls: signal([]), state: signal>({}), events$: new Subject(), diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index ef2bd3c7..a82b5d2f 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Observable, Subject } from 'rxjs'; import type { AbstractAgent, BaseEvent } from '@ag-ui/client'; import type { RunAgentInput } from '@ag-ui/core'; -import type { AgentRuntimeTelemetryPayload } from '@threadplane/chat'; +import { AgentError, type AgentRuntimeTelemetryPayload } from '@threadplane/chat'; import { toAgent } from './to-agent'; /** @@ -197,7 +197,7 @@ describe('toAgent', () => { stub.failRun(new Error('something went wrong')); expect(a.status()).toBe('error'); expect(a.isLoading()).toBe(false); - expect(a.error()).toBeInstanceOf(Error); + expect(a.error()).toBeInstanceOf(AgentError); }); it('does not append user message when input.message is undefined', async () => { @@ -293,7 +293,7 @@ describe('toAgent', () => { await pending; expect(agent.status()).toBe('idle'); - expect(agent.error()).toBeNull(); + expect(agent.error()).toBeUndefined(); expect(agent.isLoading()).toBe(false); }); @@ -317,7 +317,7 @@ describe('toAgent', () => { await pending; expect(agent.status()).toBe('idle'); - expect(agent.error()).toBeNull(); + expect(agent.error()).toBeUndefined(); expect(agent.isLoading()).toBe(false); }); @@ -343,7 +343,7 @@ describe('toAgent', () => { // Duplicate delivery must NOT flip status back to error expect(agent.status()).toBe('idle'); - expect(agent.error()).toBeNull(); + expect(agent.error()).toBeUndefined(); expect(agent.isLoading()).toBe(false); }); @@ -388,7 +388,7 @@ describe('toAgent', () => { // Run A settled: idle, no error. expect(agent.status()).toBe('idle'); - expect(agent.error()).toBeNull(); + expect(agent.error()).toBeUndefined(); // There must be an assistant message at index 1 (user[0], assistant[1]). expect(agent.messages()).toHaveLength(2); @@ -416,7 +416,7 @@ describe('toAgent', () => { // CRITICAL: must NOT be wedged in streaming/running/isLoading. expect(agent.status()).toBe('idle'); - expect(agent.error()).toBeNull(); + expect(agent.error()).toBeUndefined(); expect(agent.isLoading()).toBe(false); }); }); @@ -499,6 +499,79 @@ describe('toAgent', () => { } }); }); + + describe('error normalization + retry()', () => { + it('onRunFailed with a non-abort error sets error() to AgentError with kind server for HTTP 500', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + const serverError = new Error('HTTP 500 Internal Server Error'); + stub.failRun(serverError); + expect(a.status()).toBe('error'); + const err = a.error(); + expect(err).toBeInstanceOf(AgentError); + expect(err?.kind).toBe('server'); + }); + + it('user-abort settles idle with error() undefined (not an AgentError)', async () => { + const source = new StubAgent(); + let resolveRun!: () => void; + source.runAgent.mockImplementation( + () => new Promise((res) => { + resolveRun = () => res({ result: undefined, newMessages: [] }); + }), + ); + const agent = toAgent(source as never); + const pending = agent.submit({ message: 'test' }); + await agent.stop!(); + source.failRun(new Error('BodyStreamBuffer was aborted')); + resolveRun(); + await pending; + expect(agent.status()).toBe('idle'); + expect(agent.error()).toBeUndefined(); + }); + + it('retry() clears error and re-runs via source.runAgent without adding a new user message', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + + // Initial submit appends a user message and runs. + await a.submit({ message: 'hello' }); + const countAfterSubmit = a.messages().length; + expect(stub.runAgent).toHaveBeenCalledTimes(1); + + // Simulate a failure. + stub.failRun(new Error('HTTP 503 Service Unavailable')); + expect(a.error()).toBeInstanceOf(AgentError); + + // Retry: should clear error and call runAgent again, no new user message. + await a.retry(); + expect(a.error()).toBeUndefined(); + expect(stub.runAgent).toHaveBeenCalledTimes(2); + // Message count must be unchanged — no duplicate user message appended. + expect(a.messages().length).toBe(countAfterSubmit); + // addMessage must NOT have been called again during retry. + expect(stub.addMessage).toHaveBeenCalledTimes(1); + }); + + it('retry() is a no-op when loading', () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + // Simulate a run in progress. + stub.emit({ type: 'RUN_STARTED' } as BaseEvent); + expect(a.isLoading()).toBe(true); + void a.retry(); + // runAgent should NOT have been called (nothing submitted, and loading guard). + expect(stub.runAgent).not.toHaveBeenCalled(); + }); + + it('retry() is a no-op when no prior input exists', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + // No submit() has been called — lastInput is undefined. + await a.retry(); + expect(stub.runAgent).not.toHaveBeenCalled(); + }); + }); }); describe('subagents projection (F5)', () => { diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index 6d03fe56..5aa15ae6 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -2,6 +2,7 @@ import { computed, signal, type Signal } from '@angular/core'; import { Subject } from 'rxjs'; import type { AbstractAgent } from '@ag-ui/client'; +import { toAgentError, type AgentError } from '@threadplane/chat'; import type { Agent, Message, AgentStatus, ToolCall, AgentEvent, AgentInterrupt, @@ -81,7 +82,7 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag messages: signal([]), status: signal('idle'), isLoading: signal(false), - error: signal(null), + error: signal(undefined), toolCalls: signal([]), state: signal>({}), interrupt: signal(undefined), @@ -104,6 +105,10 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag // submit() so the next run starts clean. let abortSettled = false; + // Tracks the last AgentSubmitInput so retry() can re-run it without + // duplicating the user message. Set at the top of submit()'s message path. + let lastInput: AgentSubmitInput | undefined; + function isAbortError(error: unknown): boolean { return error instanceof Error && (error.name === 'AbortError' || /abort/i.test(error.message)); @@ -192,6 +197,27 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag if (activeRun === run) activeRun = null; } + /** + * Fires the current message list against the source agent (no append). + * Both submit() and retry() share this path; submit() appends the user + * message first, retry() skips the append and calls this directly. + */ + async function runCurrentMessages(): Promise { + const run = startRunTelemetry('submit'); + const tools = clientToolsCap.catalogAsAgUiTools(); + try { + await source.runAgent(tools.length > 0 ? { tools } : undefined); + finishRunTelemetry(run); + } catch (err) { + if (!settleIfAborted(err)) { + store.status.set('error'); + store.isLoading.set(false); + store.error.set(toAgentError(err)); + failRunTelemetry(err, run); + } + } + } + // Tap all events from the source agent via the AgentSubscriber API. // This subscription lives for the lifetime of `source`. source.subscribe({ @@ -209,7 +235,7 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag if (settleIfAborted(error)) return; store.status.set('error'); store.isLoading.set(false); - store.error.set(error); + store.error.set(toAgentError(error)); failRunTelemetry(error); }, }); @@ -283,7 +309,7 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag if (!settleIfAborted(err)) { store.status.set('error'); store.isLoading.set(false); - store.error.set(err); + store.error.set(toAgentError(err)); failRunTelemetry(err, run); } } @@ -301,19 +327,21 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag source.addMessage(userMsg as Parameters[0]); } - const run = startRunTelemetry('submit'); - const tools = clientToolsCap.catalogAsAgUiTools(); - try { - await source.runAgent(tools.length > 0 ? { tools } : undefined); - finishRunTelemetry(run); - } catch (err) { - if (!settleIfAborted(err)) { - store.status.set('error'); - store.isLoading.set(false); - store.error.set(err); - failRunTelemetry(err, run); - } - } + // Record the input so retry() can re-run it without re-appending the + // user message (the message is already in the list by this point). + lastInput = input; + + await runCurrentMessages(); + }, + + retry: async () => { + if (store.isLoading()) return; // no-op while a run is in flight + if (lastInput === undefined) return; // nothing to retry + store.error.set(undefined); + // Re-run the same message list against the source without appending a + // duplicate user message — the message is already in store.messages and + // source's internal list from the original submit(). + await runCurrentMessages(); }, stop: async () => { @@ -369,7 +397,7 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag if (!settleIfAborted(err)) { store.status.set('error'); store.isLoading.set(false); - store.error.set(err); + store.error.set(toAgentError(err)); failRunTelemetry(err, run); } } From bee8d3bf4908de24d54c87a05d9e3843b910c4f5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 14:33:40 -0700 Subject: [PATCH 08/11] feat(chat): ChatErrorComponent renders legible AgentError message + conditional Retry Co-Authored-By: Claude Sonnet 4.6 --- .../chat-error/chat-error.component.spec.ts | 85 ++++++++++++------- .../chat-error/chat-error.component.ts | 10 ++- 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts b/libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts index 0ba0eee5..a42012d0 100644 --- a/libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts @@ -1,8 +1,11 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect } from 'vitest'; -import { signal, computed } from '@angular/core'; -import { extractErrorMessage } from './chat-error.component'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { extractErrorMessage, ChatErrorComponent } from './chat-error.component'; import { mockAgent } from '../../testing/mock-agent'; +import { AgentError } from '../../agent/agent-error'; +import type { MockAgent } from '../../testing/mock-agent'; describe('extractErrorMessage()', () => { it('returns null for null error', () => { @@ -26,43 +29,67 @@ describe('extractErrorMessage()', () => { }); }); -describe('ChatErrorComponent — errorMessage computed', () => { - it('errorMessage is null when agent.error is null', () => { - const agent = mockAgent({ error: null }); - const agent$ = signal(agent); +@Component({ + standalone: true, + imports: [ChatErrorComponent], + template: ``, +}) +class HostComponent { + agent: MockAgent = mockAgent(); +} + +describe('ChatErrorComponent — rendering', () => { + let host: HostComponent; + let fixture: ReturnType>; + + beforeEach(() => { + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + }); - const errorMessage = computed(() => extractErrorMessage(agent$().error())); + it('renders err.message text for a retryable AgentError', () => { + const err = new AgentError({ kind: 'server', message: 'The server ran into an error. You can try again.', retryable: true }); + host.agent = mockAgent({ status: 'error', error: err }); + fixture.detectChanges(); - expect(errorMessage()).toBeNull(); + const el: HTMLElement = fixture.nativeElement; + const msg = el.querySelector('.chat-error__msg'); + expect(msg?.textContent?.trim()).toBe('The server ran into an error. You can try again.'); }); - it('errorMessage reflects Error object message', () => { - const agent = mockAgent({ status: 'error', error: new Error('boom') }); - const agent$ = signal(agent); - - const errorMessage = computed(() => extractErrorMessage(agent$().error())); + it('shows a Retry button when retryable is true', () => { + const err = new AgentError({ kind: 'server', message: 'The server ran into an error. You can try again.', retryable: true }); + host.agent = mockAgent({ status: 'error', error: err }); + fixture.detectChanges(); - expect(errorMessage()).toBe('boom'); + const el: HTMLElement = fixture.nativeElement; + const btn = el.querySelector('button.chat-error__retry'); + expect(btn).not.toBeNull(); + expect(btn?.textContent?.trim().toLowerCase()).toMatch(/retry/i); }); - it('errorMessage reflects string error', () => { - const agent = mockAgent({ error: 'timeout' }); - const agent$ = signal(agent); - - const errorMessage = computed(() => extractErrorMessage(agent$().error())); + it('hides the Retry button when retryable is false', () => { + const err = new AgentError({ kind: 'auth', message: 'Authentication failed. Check your API key or credentials.', retryable: false }); + host.agent = mockAgent({ status: 'error', error: err }); + fixture.detectChanges(); - expect(errorMessage()).toBe('timeout'); + const el: HTMLElement = fixture.nativeElement; + const btn = el.querySelector('button.chat-error__retry'); + expect(btn).toBeNull(); }); - it('errorMessage updates reactively when agent changes', () => { - const noErrorAgent = mockAgent({ error: null }); - const errorAgent = mockAgent({ status: 'error', error: new Error('failed') }); - const agent$ = signal(noErrorAgent); + it('clicking Retry calls agent.retry()', async () => { + const err = new AgentError({ kind: 'server', message: 'The server ran into an error. You can try again.', retryable: true }); + const agent = mockAgent({ status: 'error', error: err }); + const retrySpy = vi.spyOn(agent, 'retry'); + host.agent = agent; + fixture.detectChanges(); - const errorMessage = computed(() => extractErrorMessage(agent$().error())); + const el: HTMLElement = fixture.nativeElement; + const btn = el.querySelector('button.chat-error__retry'); + expect(btn).not.toBeNull(); + btn!.click(); - expect(errorMessage()).toBeNull(); - agent$.set(errorAgent); - expect(errorMessage()).toBe('failed'); + expect(retrySpy).toHaveBeenCalledTimes(1); }); }); diff --git a/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts b/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts index 69a377ec..5c80a5fa 100644 --- a/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts +++ b/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts @@ -1,6 +1,6 @@ // libs/chat/src/lib/primitives/chat-error/chat-error.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; import type { Agent } from '../../agent'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_ERROR_STYLES } from '../../styles/chat-error.styles'; @@ -18,17 +18,19 @@ export function extractErrorMessage(error: unknown): string | null { changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, CHAT_ERROR_STYLES], template: ` - @if (errorMessage(); as msg) { + @if (agent().error(); as err) { } `, }) export class ChatErrorComponent { readonly agent = input.required(); - readonly errorMessage = computed(() => extractErrorMessage(this.agent().error())); } From f2ce176d2cbd446f6dc2829b75c7a7ec9d27b17c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 14:36:33 -0700 Subject: [PATCH 09/11] 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 --- .../chat/angular/e2e/error-handling.spec.ts | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/examples/chat/angular/e2e/error-handling.spec.ts b/examples/chat/angular/e2e/error-handling.spec.ts index 7a770ed6..f2c387eb 100644 --- a/examples/chat/angular/e2e/error-handling.spec.ts +++ b/examples/chat/angular/e2e/error-handling.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; import { messageInput, openDemo, sendButton, waitForFinalAssistant } from './test-helpers'; -test('error handling: failed stream surfaces an alert and the next send recovers', async ({ +test('error handling: failed stream surfaces a legible error message and Retry button', async ({ page, }) => { // The LangGraph SDK retries a failed stream connect with exponential backoff @@ -21,12 +21,45 @@ test('error handling: failed stream surfaces an alert and the next send recovers await messageInput(page).fill('say hi briefly'); await sendButton(page).click(); - await expect(page.getByRole('alert')).toContainText(/fail|error/i, { timeout: 15_000 }); - await page.unroute('**/runs/stream'); - await expect(messageInput(page)).toBeEnabled(); + const alert = page.getByRole('alert'); + await expect(alert).toBeVisible({ timeout: 15_000 }); + + // The message should be human-legible copy from AGENT_ERROR_MESSAGES — + // not a raw SDK string like "HTTP 500" or "Failed to fetch". + await expect(alert).toContainText(/can't reach|connection|server|interrupted|try again/i); + await expect(alert).not.toContainText(/HTTP \d{3}/); + + // The Retry button must be present inside the alert (retryable: true). + const retryButton = alert.getByRole('button', { name: /retry/i }); + await expect(retryButton).toBeVisible(); +}); + +test('error handling: Retry button re-runs the last input and recovers', async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem('THREADPLANE_E2E_MAX_RETRIES', '0'); + }); + + await openDemo(page, '/embed'); + + await page.route('**/runs/stream', async (route) => { + await route.abort('failed'); + }); + await messageInput(page).fill('say hi briefly'); await sendButton(page).click(); + + const alert = page.getByRole('alert'); + await expect(alert).toBeVisible({ timeout: 15_000 }); + const retryButton = alert.getByRole('button', { name: /retry/i }); + await expect(retryButton).toBeVisible(); + + // Unblock the network, then click Retry — it re-submits the last input. + await page.unroute('**/runs/stream'); + await retryButton.click(); + const bubble = await waitForFinalAssistant(page); await expect(bubble).toContainText(/hi/i); }); From 7232b95a8ba62b500f2c3b0b784f2ef17bfd5bb0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 14:44:30 -0700 Subject: [PATCH 10/11] fix(chat): tighten error status parsing to HTTP-shaped tokens + connection-before-text; dedup isAbortError/messages --- libs/ag-ui/src/lib/to-agent.ts | 7 +-- .../chat/src/lib/agent/to-agent-error.spec.ts | 23 +++++++ libs/chat/src/lib/agent/to-agent-error.ts | 61 +++++++++++++++---- .../lib/internals/stream-manager.bridge.ts | 6 +- 4 files changed, 77 insertions(+), 20 deletions(-) diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index 5aa15ae6..d88c22b0 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -2,7 +2,7 @@ import { computed, signal, type Signal } from '@angular/core'; import { Subject } from 'rxjs'; import type { AbstractAgent } from '@ag-ui/client'; -import { toAgentError, type AgentError } from '@threadplane/chat'; +import { toAgentError, isAbortError, type AgentError } from '@threadplane/chat'; import type { Agent, Message, AgentStatus, ToolCall, AgentEvent, AgentInterrupt, @@ -109,11 +109,6 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag // duplicating the user message. Set at the top of submit()'s message path. let lastInput: AgentSubmitInput | undefined; - function isAbortError(error: unknown): boolean { - return error instanceof Error - && (error.name === 'AbortError' || /abort/i.test(error.message)); - } - /** Settles the store as idle for stop()-induced failures; returns true if handled. */ function settleIfAborted(error: unknown): boolean { // If we already settled this abort (duplicate delivery — e.g. RUN_ERROR diff --git a/libs/chat/src/lib/agent/to-agent-error.spec.ts b/libs/chat/src/lib/agent/to-agent-error.spec.ts index 8532fbaf..7de1e1db 100644 --- a/libs/chat/src/lib/agent/to-agent-error.spec.ts +++ b/libs/chat/src/lib/agent/to-agent-error.spec.ts @@ -41,4 +41,27 @@ describe('toAgentError', () => { const e = toAgentError({ status: 503, message: 'Service Unavailable' }); expect(e.kind).toBe('server'); expect(e.status).toBe(503); expect(e.retryable).toBe(true); }); + + // NEW: bare 3-digit tokens in model names must NOT yield a bogus status + it('does NOT extract status from a bare 3-digit model version string', () => { + const e = toAgentError(new Error('model gpt-500 is not available')); + expect(e.kind).toBe('server'); + expect(e.retryable).toBe(true); + expect(e.status).toBeUndefined(); + }); + + // NEW: connection detection must fire BEFORE loose text parsing + it('classifies "Failed to fetch (502 upstream)" as connection, not server', () => { + const e = toAgentError(new TypeError('Failed to fetch (502 upstream)')); + expect(e.kind).toBe('connection'); + expect(e.retryable).toBe(true); + }); + + // NEW: structured cause.status path + it('reads structured status via cause.status', () => { + const e = toAgentError({ cause: { status: 403 } }); + expect(e.kind).toBe('auth'); + expect(e.status).toBe(403); + expect(e.retryable).toBe(false); + }); }); diff --git a/libs/chat/src/lib/agent/to-agent-error.ts b/libs/chat/src/lib/agent/to-agent-error.ts index 84202955..e3f71c80 100644 --- a/libs/chat/src/lib/agent/to-agent-error.ts +++ b/libs/chat/src/lib/agent/to-agent-error.ts @@ -6,15 +6,17 @@ export function isAbortError(raw: unknown): boolean { return raw instanceof Error && (raw.name === 'AbortError' || /\babort/i.test(raw.message)); } -function readStatus(raw: unknown): number | undefined { +/** + * Reads a numeric status from `raw.status` or `raw.cause.status` only. + * No text parsing — structured fields only. + */ +function structuredStatus(raw: unknown): number | undefined { const obj = raw as { status?: unknown; cause?: { status?: unknown } } | null; const direct = typeof obj?.status === 'number' ? obj.status : undefined; const viaCause = typeof obj?.cause?.status === 'number' ? obj!.cause!.status : undefined; if (direct !== undefined) return direct; if (viaCause !== undefined) return viaCause; - const msg = raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : ''; - const m = /\b(\d{3})\b/.exec(msg); - return m ? Number(m[1]) : undefined; + return undefined; } function isConnectionError(raw: unknown): boolean { @@ -24,23 +26,60 @@ function isConnectionError(raw: unknown): boolean { ); } +/** + * Extracts an HTTP status code from a message string, but ONLY when the token + * is unambiguously HTTP-shaped: + * - `HTTP/502`, `HTTP 404`, `HTTP404` + * - `status: 503`, `status=503`, `code: 404` + * + * Bare 3-digit numbers (e.g. model version strings like "gpt-500") are NOT matched. + */ +function httpStatusFromMessage(raw: unknown): number | undefined { + const msg = + raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : ''; + if (!msg) return undefined; + + // "HTTP 500", "HTTP/500", "HTTP500" + const httpToken = /\bHTTP[ /]?(\d{3})\b/i.exec(msg); + if (httpToken) return Number(httpToken[1]); + + // "status: 503", "status=503", "status 503" (up to 4 non-digit chars between keyword and digits) + // Also matches "code: 404", "code=404", etc. + const prefixed = /\b(?:status|code)\b\D{0,4}(\d{3})\b/i.exec(msg); + if (prefixed) return Number(prefixed[1]); + + return undefined; +} + function make(kind: AgentErrorKind, retryable: boolean, raw: unknown, status?: number, message?: string): AgentError { return new AgentError({ kind, retryable, status, cause: raw, message: message ?? AGENT_ERROR_MESSAGES[kind] }); } +function classifyByStatus(status: number, raw: unknown): AgentError { + if (status === 401 || status === 403) return make('auth', false, raw, status); + if (status >= 500) return make('server', true, raw, status); + if (status >= 400) return make('server', false, raw, status, `The request was rejected (HTTP ${status}).`); + // Stray 2xx/3xx from a status field — treat as unknown transient failure, no status. + return make('server', true, raw, undefined, 'Something went wrong. You can try again.'); +} + /** Classify any raw error into a structured {@link AgentError}. Idempotent. */ export function toAgentError(raw: unknown): AgentError { if (raw instanceof AgentError) return raw; if (isAbortError(raw)) return make('aborted', false, raw); - const status = readStatus(raw); - if (status !== undefined) { - if (status === 401 || status === 403) return make('auth', false, raw, status); - if (status >= 500) return make('server', true, raw, status); - if (status >= 400) return make('server', false, raw, status, `The request was rejected (HTTP ${status}).`); - } + // 1. Structured status (authoritative): raw.status or raw.cause.status. + const structured = structuredStatus(raw); + if (structured !== undefined) return classifyByStatus(structured, raw); + + // 2. Network/connection markers are definitive — before any loose text parsing. if (isConnectionError(raw)) return make('connection', true, raw); + // 3. Best-effort: only an HTTP-shaped status token in the message counts. + const httpStatus = httpStatusFromMessage(raw); + if (httpStatus !== undefined) return classifyByStatus(httpStatus, raw); + + // 4. Fallback: unknown failure, assume transient. const msg = raw instanceof Error && raw.message ? raw.message : 'Something went wrong. You can try again.'; - return make('server', true, raw, status, msg); + return make('server', true, raw, undefined, msg); } diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index f06086a1..e73b5151 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -20,7 +20,7 @@ import type { AgentRuntimeTelemetryProperties, AgentRuntimeTelemetrySink, } from '@threadplane/chat'; -import { AgentError, toAgentError, isAbortError } from '@threadplane/chat'; +import { AgentError, AGENT_ERROR_MESSAGES, toAgentError, isAbortError } from '@threadplane/chat'; import { SubagentTracker, TrackedSubagent, @@ -450,8 +450,8 @@ export function createStreamManagerBridge Date: Thu, 18 Jun 2026 14:45:21 -0700 Subject: [PATCH 11/11] docs(api): regenerate api-docs for AgentError/toAgentError/retry surface Co-Authored-By: Claude Fable 5 --- .../content/docs/ag-ui/api/api-docs.json | 8 +- .../content/docs/chat/api/api-docs.json | 143 ++++++++++++++++-- .../content/docs/langgraph/api/api-docs.json | 16 +- 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index cf9ae198..a4d772c6 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -421,7 +421,7 @@ }, { "name": "error", - "type": "Signal", + "type": "Signal | undefined>", "description": "", "optional": false }, @@ -455,6 +455,12 @@ "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.", "optional": false }, + { + "name": "retry", + "type": "() => Promise", + "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.", + "optional": false + }, { "name": "state", "type": "Signal>", diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 19ef10b7..a3ae29be 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1426,6 +1426,65 @@ ], "methods": [] }, + { + "name": "AgentError", + "kind": "class", + "description": "Structured, classified failure surfaced on `Agent.error`. Extends `Error`\n so existing `.message` / `instanceof Error` reads keep working.", + "params": [ + { + "name": "init", + "type": "object", + "description": "", + "optional": false + } + ], + "examples": [], + "properties": [ + { + "name": "cause", + "type": "unknown", + "description": "", + "optional": false + }, + { + "name": "kind", + "type": "AgentErrorKind", + "description": "", + "optional": false + }, + { + "name": "message", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "name", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "retryable", + "type": "boolean", + "description": "connection | server | interrupted → true; auth | aborted | non-auth-4xx → false.", + "optional": false + }, + { + "name": "stack", + "type": "string", + "description": "", + "optional": true + }, + { + "name": "status", + "type": "number", + "description": "", + "optional": true + } + ], + "methods": [] + }, { "name": "ChatApprovalCardComponent", "kind": "class", @@ -2118,12 +2177,6 @@ "type": "InputSignal", "description": "", "optional": false - }, - { - "name": "errorMessage", - "type": "Signal", - "description": "", - "optional": false } ], "methods": [] @@ -5320,7 +5373,7 @@ }, { "name": "error", - "type": "Signal", + "type": "Signal | undefined>", "description": "", "optional": false }, @@ -5354,6 +5407,12 @@ "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.", "optional": false }, + { + "name": "retry", + "type": "() => Promise", + "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.", + "optional": false + }, { "name": "state", "type": "Signal>", @@ -5614,7 +5673,7 @@ }, { "name": "error", - "type": "Signal", + "type": "Signal | undefined>", "description": "", "optional": false }, @@ -5660,6 +5719,12 @@ "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.", "optional": false }, + { + "name": "retry", + "type": "() => Promise", + "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.", + "optional": false + }, { "name": "state", "type": "Signal>", @@ -6273,7 +6338,7 @@ }, { "name": "error", - "type": "WritableSignal", + "type": "WritableSignal | undefined>", "description": "", "optional": false }, @@ -6319,6 +6384,12 @@ "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.", "optional": false }, + { + "name": "retry", + "type": "() => Promise", + "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.", + "optional": false + }, { "name": "state", "type": "WritableSignal>", @@ -6377,7 +6448,7 @@ "properties": [ { "name": "error", - "type": "unknown", + "type": "AgentError<>", "description": "", "optional": true }, @@ -6950,6 +7021,13 @@ "signature": "Readonly | A2uiViewEntry>>", "examples": [] }, + { + "name": "AgentErrorKind", + "kind": "type", + "description": "", + "signature": "\"connection\" | \"auth\" | \"server\" | \"interrupted\" | \"aborted\"", + "examples": [] + }, { "name": "AgentEvent", "kind": "type", @@ -7104,6 +7182,13 @@ "signature": "Readonly | RenderViewEntry>>", "examples": [] }, + { + "name": "AGENT_ERROR_MESSAGES", + "kind": "const", + "description": "Default human-facing copy per kind.", + "signature": "Record", + "examples": [] + }, { "name": "cacheplaneMarkdownViews", "kind": "const", @@ -7538,6 +7623,25 @@ }, "examples": [] }, + { + "name": "isAbortError", + "kind": "function", + "description": "True when `raw` represents a user-requested abort. Shared by adapters + classifier.", + "signature": "isAbortError(raw: unknown): boolean", + "params": [ + { + "name": "raw", + "type": "unknown", + "description": "", + "optional": false + } + ], + "returns": { + "type": "boolean", + "description": "" + }, + "examples": [] + }, { "name": "isAssistantMessage", "kind": "function", @@ -7841,6 +7945,25 @@ }, "examples": [] }, + { + "name": "toAgentError", + "kind": "function", + "description": "Classify any raw error into a structured AgentError. Idempotent.", + "signature": "toAgentError(raw: unknown): AgentError<>", + "params": [ + { + "name": "raw", + "type": "unknown", + "description": "", + "optional": false + } + ], + "returns": { + "type": "AgentError<>", + "description": "" + }, + "examples": [] + }, { "name": "toClientToolSpecs", "kind": "function", diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index 80edbb2e..927195a4 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -1439,7 +1439,7 @@ }, { "name": "error", - "type": "Signal", + "type": "Signal | undefined>", "description": "", "optional": false }, @@ -1581,6 +1581,12 @@ "description": "Re-submit the last input to restart the stream.", "optional": false }, + { + "name": "retry", + "type": "() => Promise", + "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.", + "optional": false + }, { "name": "setBranch", "type": "(branch: string) => void", @@ -1861,7 +1867,7 @@ }, { "name": "error", - "type": "WritableSignal", + "type": "WritableSignal | undefined>", "description": "", "optional": false }, @@ -2003,6 +2009,12 @@ "description": "Re-submit the last input to restart the stream.", "optional": false }, + { + "name": "retry", + "type": "() => Promise", + "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.", + "optional": false + }, { "name": "setBranch", "type": "(branch: string) => void",