feat: detect unexpectedly closed streams and offer only safe recovery - #1103
Merged
Merged
Conversation
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>
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
…tions-recovery-e6e202
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>
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_STARTEDwas 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:
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
retryablefor aninterruptederror is now true only whenrecoveryisretry. 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
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.RUN_ERRORthe reducer declined to attribute was being reported as a clean success.stream_ended, so operators countingstream_erroredsaw zero truncated streams.AGENT_RECOVERY_MESSAGESandAgentRecoverywere exported only from an inner barrel, so the adapter's import resolved toundefinedat 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