Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b101ef7
docs: design unexpected stream closure detection and recovery
blove Sep 17, 2026
750b427
docs: plan stream interruption detection and recovery
blove Sep 17, 2026
91eda73
feat(chat): AgentError carries a recovery action and detail
blove Sep 17, 2026
e569b47
docs: match the Task 2 test to the agent spec's existing style
blove Sep 17, 2026
788a109
feat(chat): add an optional checkStatus action to the Agent contract
blove Sep 17, 2026
16244c3
docs: guard the Agent contract change in the type-tests target
blove Sep 17, 2026
8926be4
docs(chat): say where a checkStatus answer surfaces, and what adapter…
blove Sep 17, 2026
8a3e6af
docs: give the Task 3 detail line a wrapping container
blove Sep 17, 2026
0b28db6
feat(chat): chat-error renders retry, check status, or an explanation
blove Sep 17, 2026
b7cac19
docs: AG-UI offers a status check only for resume attempts
blove Sep 17, 2026
ada7094
fix(chat): call checkStatus through the agent so it keeps its receiver
blove Sep 17, 2026
7c597ba
docs: detail is required whenever recovery is check or none
blove Sep 17, 2026
1e51d49
fix(ag-ui): a stream that closes without terminal evidence is an inte…
blove Sep 17, 2026
0cc7128
test(ag-ui): cover the terminal-event escape hatch and prove the stop…
blove Sep 17, 2026
b769390
docs: record the resume run-id consequence, and sharpen the Task 5 cl…
blove Sep 17, 2026
e0145ce
fix(ag-ui): a declined RUN_ERROR is not a clean success, and an inter…
blove Sep 17, 2026
aabaff1
feat(ag-ui): classify interruption recovery from what was dispatched
blove Sep 17, 2026
311cb98
docs(ag-ui): sharpen the interruption detail copy and the classifier'…
blove Sep 17, 2026
2a2280a
docs: a configured reconciler means persistence.reconcile, not persis…
blove Sep 17, 2026
85fbd04
docs: carry the revised interruption copy into the LangGraph sketch
blove Sep 17, 2026
5dc589e
refactor(ag-ui): share the recovery detail copy and close the request…
blove Sep 17, 2026
b309b23
test(chat): guard the recovery message and detail tables against drift
blove Sep 17, 2026
dc0c49c
feat(ag-ui): reconcile an unexpectedly closed run once, and expose ch…
blove Sep 18, 2026
7541871
docs: AG-UI staleness is enforced by the reconciling gate, not a comp…
blove Sep 18, 2026
4a46e45
refactor(ag-ui): share the reconciliation core and name the closed-ru…
blove Sep 18, 2026
e768bc5
fix(langgraph): a chunkless close is an interruption, not a completed…
blove Sep 18, 2026
d2eb2d5
fix(langgraph): a terminal event proves completion, chunklessness doe…
blove Sep 18, 2026
51f1ab4
docs: a terminal event proves LangGraph completion, chunklessness doe…
blove Sep 18, 2026
cf06140
docs(langgraph): name what each completion-evidence clause covers, an…
blove Sep 18, 2026
47d59dd
docs: correct the evidence table's second row
blove Sep 18, 2026
4b8fa01
feat(langgraph): read the refreshed history to settle a closed stream
blove Sep 18, 2026
99dee85
fix(langgraph): never offer Retry for a request the server began answ…
blove Sep 18, 2026
28cfb5e
fix(langgraph): publish an interruption from the settled outcome, not…
blove Sep 18, 2026
382f0bb
docs: interruption recovery offers retry only when nothing was dispat…
blove Sep 18, 2026
91e2429
docs: record that LangGraph never offers Retry, and absorb Tasks 9 an…
blove Sep 18, 2026
fcfc81d
fix(chat): keep the recovery action on the message row, like Retry
blove Sep 18, 2026
326d090
Merge remote-tracking branch 'origin/main' into blove/stream-interrup…
blove Sep 18, 2026
62bb14e
docs(langgraph): say what the retryable guard prevents in a backoff loop
blove Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/website/content/docs/ag-ui/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@
"kind": "interface",
"description": "The neutral Agent contract, widened with the AG-UI adapter's\n`customEvents` signal (the chat composition feature-detects it to enable\nlive a2ui streaming), the browser client-tools capability, and concrete\nACTIVITY-backed `subagents`.\nMirrors langgraph's LangGraphAgent extension where the protocol surfaces\noverlap.",
"properties": [
{
"name": "checkStatus",
"type": "() => Promise<void>",
"description": "Optional read-only reconciliation of an uncertain run outcome, offered when\n`error().recovery === 'check'`. Asks the backend what happened.\n\nThe answer arrives on the signals, not in the return value: a run the\nbackend reports as finished clears `error` and returns `status` to idle,\nand any messages it committed appear on `messages`. An outcome that stays\nunknown leaves the existing error in place, so the caller can offer the\ncheck again later.\n\nAdapters implementing this must not resubmit the operation and must not\nappend a message; a dropped stream is not proof that the server did\nnothing. They should reject while a request is in flight, and must discard\na result that resolves after a newer request has started.",
"optional": true
},
{
"name": "clientTools",
"type": "ClientToolsCapability",
Expand Down Expand Up @@ -538,7 +544,7 @@
{
"name": "reconcileInterrupt",
"signature": "reconcileInterrupt(): Promise<void>",
"description": "Recover an uncertain attempt using the configured authoritative reconciler.",
"description": "Recover an uncertain attempt using the configured authoritative reconciler,\nadopting whatever it reports — including an `acknowledged` answer that only\nproves the resume was started. The imperative escape hatch; prefer the base\ncontract's `checkStatus` for the control offered to a user when\n`error().recovery === 'check'`, which settles only on a conclusive answer.",
"params": []
},
{
Expand Down
3 changes: 2 additions & 1 deletion apps/website/content/docs/ag-ui/api/inject-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ These fields are stable across runtime adapters and are what chat components con
| `messages()` | `Message[]` | Chat messages with `role`, `content`, optional `toolCallIds`, citations, and reasoning. |
| `status()` | `'idle' \| 'running' \| 'error'` | UI lifecycle status. |
| `isLoading()` | `boolean` | Convenience signal for active streaming. |
| `error()` | `AgentError \| undefined` | Latest runtime error, when present. |
| `error()` | `AgentError \| undefined` | Latest runtime error, when present. An `interrupted` error also carries `recovery` and `detail`. |
| `toolCalls()` | `ToolCall[]` | Tool calls projected into the chat contract. |
| `state()` | `Record<string, unknown>` | Latest agent state projected as a plain object. |
| `interrupt()` | `AgentInterrupt \| undefined` | Current interrupt, when the backend pauses for human input. |
Expand All @@ -67,6 +67,7 @@ These fields are stable across runtime adapters and are what chat components con
| `stop()` | `Promise<void>` | Abort the active run. |
| `retry()` | `Promise<void>` | Re-run the last submitted input after a failure. No-op while a run is in flight or when there is nothing to retry. |
| `regenerate(index)` | `Promise<void>` | Remove the assistant message at `index` and rerun from the preceding user message. |
| `checkStatus?()` | `Promise<void>` | Read-only reconciliation of an uncertain run outcome, present only when `persistence.reconcile` is configured. Offered when `error().recovery` is `check`; resubmits nothing and appends no message. |

## AG-UI-specific surface

Expand Down
39 changes: 32 additions & 7 deletions apps/website/content/docs/ag-ui/guides/deployment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ Whatever sits between the browser and the agent server has to pass the SSE body
The `<chat>` composition renders failures for you.
A hand-built UI reads the same two signals the composition does.
`status()` flips to `'error'`, and `error()` holds an `AgentError` with a classified `kind`, a `retryable` flag, an optional HTTP `status`, and the original failure on `cause`.
An `interrupted` error adds `recovery` and `detail`, which name the one action the adapter can prove is safe.

```ts
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
Expand All @@ -259,7 +260,12 @@ import { injectAgent } from '@threadplane/ag-ui';
@if (hasError()) {
<div class="error-banner">
<p>{{ errorMessage() }}</p>
@if (canRetry()) {
@if (agent.error()?.detail; as detail) {
<p>{{ detail }}</p>
}
@if (canCheck()) {
<button (click)="checkStatus()">Check status</button>
} @else if (canRetry()) {
<button (click)="retry()">Try again</button>
}
</div>
Expand All @@ -270,6 +276,9 @@ export class ChatComponent {
protected readonly agent = injectAgent();

protected readonly hasError = computed(() => this.agent.status() === 'error');
protected readonly canCheck = computed(
() => this.agent.error()?.recovery === 'check' && this.agent.checkStatus !== undefined,
);
protected readonly canRetry = computed(
() => this.agent.error()?.retryable === true,
);
Expand All @@ -282,28 +291,34 @@ export class ChatComponent {
return 'Your session was rejected. Sign in again and retry.';
case 'connection':
return 'The agent is unreachable. Check your connection and try again.';
case 'interrupted':
return 'The response was cut off. Try again.';
default:
// 'interrupted' included: the adapter already set the message that
// matches the recovery it chose, and `detail` continues that sentence.
return failure.message;
}
});

protected retry(): void {
void this.agent.retry();
}

protected checkStatus(): void {
void this.agent.checkStatus?.();
}
}
```

Branching on `kind` covers the cases that need different copy without reading HTTP status codes.
`failure.status` is there when you want the exact code, and `failure.cause` carries the original error for your logs.
Do not hard-code copy for `interrupted`.
Three different outcomes share that kind, and the adapter's own message is the one that matches the recovery it settled on.

| `kind` | When | `retryable` |
|--------|------|-------------|
| `connection` | Offline, DNS failure, the endpoint refused the connection | Yes |
| `auth` | The endpoint or agent server answered `401` or `403` | No |
| `server` | A `5xx` from the agent server, or a `4xx` other than auth | `5xx` only |
| `interrupted` | The SSE stream closed before the run finished | Yes |
| `interrupted` | The SSE stream closed before the run finished | Only when `recovery` is `retry` |
| `aborted` | The user pressed stop | Not surfaced as an error |

### Retry with exponential backoff
Expand Down Expand Up @@ -342,12 +357,22 @@ The `retryable` check is what keeps a rejected session from being retried three

AG-UI has no server-side run to rejoin.
The stream is the run.
When it closes early, the adapter surfaces an `interrupted` error, and `retry()` sends the same input again as a fresh request.
When it closes without terminal evidence, the adapter surfaces an `interrupted` error rather than reporting a completed turn, and keeps whatever content and approval state had arrived.

What it offers next depends on what it can prove.
`retry()` is offered only for an ordinary turn whose stream produced no event at all: nothing reached the server, so re-sending the same input as a fresh request cannot duplicate work.
A resume attempt may already have committed server-side work, so it is never retried here.
When one is truncated and `persistence.reconcile` is configured, the adapter reports `recovery: 'check'` instead, makes a single read-only reconciliation of its own, and exposes `checkStatus()` so the user can ask again.
The reconciler speaks only about interrupt sessions, which is why it is not offered for an ordinary turn.
With nothing able to verify the outcome, the recovery is `none` and the error explains that the result could not be confirmed.

Your agent server decides what that means.
A retry that does go out is a fresh request, and your agent server decides what that means.
A server that persists its thread by `threadId` resumes from its last checkpoint.
A stateless one starts the turn over.

A connection that stays open but stops producing events is a different problem.
The adapter does not classify it; a timeout of your own is what catches that.

Pending interrupts are the one piece of client state worth restoring across a reload, and the adapter supports that through the `persistence` option on `provideAgent()`.
The store is yours to implement, and `agent.ready` resolves once the restored record has been applied.
The [Interrupts guide](/docs/ag-ui/guides/interrupts#failures-retry-and-restoration) covers the store contract and the reconciliation step.
Expand Down Expand Up @@ -471,7 +496,7 @@ Add the app's origin to the agent server's allow list for local development, and
Confirm every proxy, CDN, and load balancer on the path passes SSE through as it arrives.
</Step>
<Step title="Handle errors gracefully">
Branch on `error().kind` for legible copy, and show a retry button only when `error().retryable` is true.
Branch on `error().kind` for legible copy, show a retry button only when `error().retryable` is true, and offer a status check when `error().recovery` is `check` and the agent exposes `checkStatus`.
</Step>
<Step title="Persist thread IDs">
Pass a stable `threadId` so a server that checkpoints by thread can resume a conversation, and wire `persistence` if pending interrupts must survive a reload.
Expand Down
4 changes: 4 additions & 0 deletions apps/website/content/docs/ag-ui/guides/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ Pass the index of the assistant message you want to replace, not the user messag

`retry()` re-runs the last submitted input. It is a silent no-op in two cases: a run is already in flight, or nothing has been submitted on this agent yet. Neither case throws and neither sets `error`, so a retry button that appears to do nothing usually means one of the two. Check `isLoading()` before calling it, and note that a fresh agent has nothing to retry until the first `submit()`.

## No Retry button after a dropped stream

This is deliberate. A stream that closed without terminal evidence settles as an `interrupted` error, and the adapter offers Retry only when it can prove the request was never dispatched — anything the server may already have begun answering would be duplicated by a replay. Read `error().recovery`: `check` means a read-only status check is available through `checkStatus()`, and `none` means the outcome cannot be verified, in which case `error().detail` is the sentence to show. Partial content and approval state are preserved in every case.

## History or time-travel does not work

History and time-travel are not implemented by the AG-UI adapter today.
Expand Down
Loading
Loading