Skip to content

feat: detect unexpectedly closed streams and offer only safe recovery - #1103

Merged
blove merged 38 commits into
mainfrom
blove/stream-interruptions-recovery-e6e202
Sep 18, 2026
Merged

blove merged 38 commits into
mainfrom
blove/stream-interruptions-recovery-e6e202

Conversation

@blove

@blove blove commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

An agent stream that closes without terminal evidence is now reported as an interruption instead of a completed turn, and the recovery offered depends on what the adapter can prove.

Why

AG-UI settled an evidence-free close as success whenever the run owned no assistant messages, then returned to idle with the error cleared. A truncated HTTP 200 stream carrying only RUN_STARTED was indistinguishable from a clean finish: the composer re-enabled and nothing was shown. LangGraph inferred success whenever no assistant chunk had arrived, so a stream that died before its first chunk was reported as a completed turn.

Both now settle as interrupted, with partial content preserved.

Recovery is classified, not assumed

Retry appears only when the adapter can prove the request was never dispatched. Where the backend supports a read-only check, the adapter makes one automatic check and offers Check status. Where nothing can verify the outcome, the error explains the uncertainty and offers no action. Nothing is ever resubmitted automatically.

The two adapters reach that through different seams, and the documentation states the rule per adapter:

  • AG-UI offers Retry when no event was observed on a replayable request type. It offers Check status only for a truncated resume attempt with a configured reconciler, because the reconciler's whole vocabulary is the interrupt session and it has nothing authoritative to say about an ordinary turn.
  • LangGraph never offers Retry for an interruption, and that is correct rather than a gap: a connection that fails to open throws and is classified as connection, which is retryable. By the time an outcome is interrupted there, the stream had opened. It reads back the thread history it already refreshes on close, so a pending interrupt settles as paused and new messages settle as success.

Breaking change

retryable for an interrupted error is now true only when recovery is retry. Previously every interruption was retryable. An interruption whose request may already have reached the server is not, because replaying it could duplicate a tool call or a booking. This is a patch release at 0.0.x per repository convention.

Also fixed along the way

  • A non-user abort after streaming started produced an interrupted error with retryable: true, which rendered a Retry button for a request the server had already begun answering. That was the exact unsafe replay this work exists to prevent.
  • A RUN_ERROR the reducer declined to attribute was being reported as a clean success.
  • An interrupted close was reported to telemetry as stream_ended, so operators counting stream_errored saw zero truncated streams.
  • AGENT_RECOVERY_MESSAGES and AgentRecovery were exported only from an inner barrel, so the adapter's import resolved to undefined at runtime.

Verification

Libraries: chat 1197, AG-UI 393, LangGraph 477, all passing. Builds, type-tests and lint green with zero errors.

Every branch added was mutation-tested, with a grep confirming the mutation was applied before the suite ran. Surviving mutants were each resolved rather than noted: two AG-UI guards are kept as documented unreachable defence behind a stronger gate, and three LangGraph survivors turned out to be test defects.

The error banner's layout was checked in a browser across three widths, which jsdom cannot do. That found the recovery button dropping to a third row while Retry stayed inline; fixed.

Not run: the live production smoke suite. It exercises the deployed site, which does not carry this branch, so it would prove the deployment is healthy rather than that this change is safe. CI exercises this branch end to end.

Out of scope

Connections that stay open but stop producing events. Detecting those needs timeout rules that would have to tell a slow model from a dead stream, and getting them wrong turns normal latency into a false failure.

Design: docs/superpowers/specs/2026-09-17-stream-interruption-recovery-design.md

🤖 Generated with Claude Code

blove and others added 30 commits September 17, 2026 09:37
Records the approved design for reporting an unexpectedly closed agent
stream as an interruption and offering only the recovery action the
adapter can justify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the task-by-task implementation plan, and corrects the design's
reconciliation section: InterruptPersistence.reconcile() takes no
arguments, throws on an unauthoritative answer, and returns the updated
record, so the outcome is read from the session phase rather than from
the raw four-value status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
agent.spec.ts builds agents as bare object literals typed as Agent and
does not import the mock helper. The plan now follows that pattern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vitest does not type-check, so a runtime spec cannot catch an interface
regression. Task 2 now adds the assertion to agent-error.type-spec.ts and
proves it fails when the member is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s owe

The result arrives on the signals, not in the return value, and the four
promises in the comment are obligations on adapters rather than anything
the interface enforces. The spec block now points at the type-spec that
actually guards the contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chat-error container is a non-wrapping flex row, so a full-width
detail line needs flex-wrap on the parent to land on its own row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reading interrupt-persistence.ts showed the design over-promised. The
reconciler's whole vocabulary is the interrupt session, its statuses are
each tied by validation to a session phase and a correlated resume
attempt, and reconcile() returns null without calling the backend when no
record has been persisted. It has nothing authoritative to say about an
ordinary turn, so offering Check status there would either ask an
unanswerable question or make no call at all.

An ordinary submit that was dispatched and then truncated now gets a
plain explanation instead, as does a client-tool continuation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When recovery is check but the agent exposes no checkStatus, the detail
sentence is the only thing left to show. Omitting it there leaves a bare
error with no path forward. The browser verification step also now covers
the container's new wrapping behaviour, which jsdom cannot check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rruption

Also export AGENT_RECOVERY_MESSAGES and AgentRecovery from the chat public
API barrel; they were only exported from lib/agent, so the adapter's import
resolved to undefined at runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… case ran

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…assifier

A backend that does not echo the run id it was given on resume now has its
terminal event dropped and the run reported as an interruption. That is
intended, but it is a real behavioural change worth stating.

Task 5 now keys Retry off the request type rather than a looser condition,
so a client-tool continuation carrying tool results is never replayed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rupted close is an errored stream

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s comments

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tence

The reconcile callback is optional on AgUiInterruptPersistence, so a caller
may configure a store without one. Keying the status check off persistence
alone would offer a check that throws the moment it ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The detail strings were rewritten to read as continuations of the message
they sit beneath. Task 8's sketch still pinned the old wording, so
following it would have reintroduced the copy we just replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-type union

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two entries for a recovery value render as one banner, the detail
continuing the message. A value present in one table and missing from the
other would ship a half-written sentence. Verified load-bearing: removing
the none detail fails the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eckStatus

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arison

Implementation showed the automatic check holds the reconciling signal, and
every entry point that could start a newer run throws while it is set. A
newer request is refused rather than racing, so activeRun cannot change
across the check. The comparison survives mutation because it is
unreachable defence. Task 9 now covers the LangGraph twin only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n check

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… run

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s not

A state-only turn completes by emitting its terminal snapshot and never
speaking, so requiring a chunk-qualified terminal event reclassified those
runs as interruptions. Read rootTerminalEvidence too: it is set by a root
terminal event with a payload and cleared by any later assistant chunk,
which is exactly the state-only completion signal.

This reverts the test and harness edits from the previous commit, including
the public MockAgentTransport change. Those sequences were never broken --
they carry root terminal evidence and always resolved as success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s not

Deleting the chunk clause outright would have reclassified a legitimate
state-only turn as an interruption. A graph may complete a turn that
mutates state and never speaks, and this repo's graphs carry application
state in the graph. rootTerminalEvidence is exactly that signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d guard both

The comment described the predicate we replaced and left the real division
underivable: rootTerminalEvidence is set after chunks too, so "follows chunks
or arrives alone" does not separate the clauses. State them directly instead.

The new block explained the rule without exercising clause one — its
payload-bearing cases set both flags. Add the payloadless-marker-after-a-chunk
case, which is clause one's exclusive domain, so the rule is guarded where it
is read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In that row no terminal event ever arrived, so the root flag was never set
and nothing was cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove and others added 6 commits September 17, 2026 17:53
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ering

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… stale evidence

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ched

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d 10

LangGraph separates a request that never left from one the server began
answering at a different seam than AG-UI: a connection that fails to open
throws and is classified as connection, which is retryable. By the time an
outcome is interrupted there, the stream had opened.

Tasks 9 and 10 were carried out inside the tasks they belonged to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Browser verification showed Check status dropping to a third row while
Retry stayed inline, because the detail claims a full row of its own and
the button followed it in source order. Ordering the action first puts
both on the message row with the detail beneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 18, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
threadplane Ignored Ignored Preview Sep 18, 2026 2:32am UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

blove and others added 2 commits September 17, 2026 19:29
An interrupted error is never retryable because the server may already
have begun answering, so the guard is what stops an automated loop
sending the same tool call or booking three times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove merged commit bae26f5 into main Sep 18, 2026
77 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant