fix(langgraph): adopt a transport-created thread id instead of aborting the run - #1005
Merged
Conversation
blove
enabled auto-merge (squash)
September 5, 2026 04:15
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
…ng the run The bridge treated the first non-null thread id as a thread SWITCH whenever its own currentThreadId was still null, which aborted the in-flight run that had just created that thread. Only the fallback FetchStreamTransport got the wrappedOnThreadId interceptor, so a consumer-supplied transport left currentThreadId at null and every adoption looked like a switch. shouldReset now requires a KNOWN current thread: a null currentThreadId is adoption, never a switch. hasSeenThreadId is subsumed by that check and is removed. A genuine switch (known id -> different id, or -> null) still aborts and resets exactly as before. Also corrects the wrappedOnThreadId comment, which claimed a guarantee it only provides on the default transport, and documents on AgentConfig.transport / AgentOptions.transport that a custom transport must report created thread ids through onThreadId or the threadId signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove
force-pushed
the
blove/fix-bridge-thread-adoption
branch
from
September 5, 2026 04:36
69ee006 to
eb11ba8
Compare
Contributor
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.
The bridge aborts its own in-flight run, about two milliseconds after opening the stream, whenever a consumer supplies a custom transport.
Root cause
stream-manager.bridge.tswrapsonThreadIdsocurrentThreadIdstays in sync when the transport auto-creates a thread. Its own comment says so. But the wrapper is installed only on the fallback transport:Supply your own transport and the bridge never learns the created id, so
currentThreadIdstaysnull. When the consumer's callback then updates the thread-id signal:The bridge mistakes a thread-id adoption for a thread switch.
The fix
A null current thread is an adoption, because the run streaming right now is what created that thread. A known id changing to a different id, or to null, still aborts and resets exactly as before.
hasSeenThreadIdis removed as genuinely subsumed: its only job was suppressing a reset on the first emission, wherecurrentThreadIdis null by construction. The one case where the two predicates differ is a transport setting the id before the first signal emission and the signal then emitting a different id — a real switch, which should reset.How this was found
Live instrumentation, after the first diagnosis turned out to be wrong. The reported symptom was "the browser submits the first prompt twice and the duplicate races the interrupt into an HTTP 400". The timing disproved it: across four threads the two runs were 39.584, 39.571, 39.604 and 39.598 seconds apart, a 30ms spread, and they did not overlap. That is a timer, not a race.
It was the hero walkthrough's 30-second wait timing out because the approval never arrived, then its loop re-sending the first prompt into a thread holding an unanswered interrupt. The approval never arrived because the client had aborted its own stream before any event reached it. The server had run the whole thing, interrupt included, and succeeded.
The 400 was a symptom two steps downstream of this line.
Not affected
Consumers who let the bridge construct the transport, which is why the production takeover path works. Verified by driving it by hand: prompt, approval panel, Accept, resume, all clean.
Tests
A failing test first, reproducing it with
MockAgentTransportand no backend: a bridge over a custom transport that creates a thread mid-stream must not abort, asserted on the stream's ownAbortSignaland on the event reaching subscribers rather than on internals. Against main it fails withexpected true to be falseonsignal.aborted. A second test pins that a genuine switch still aborts and resets. Both mutation-checked: reverting the predicate turns the first red and leaves the second green.Also
The wrapper's comment claimed a guarantee it only delivered in one branch. It now says what the code does, and both
AgentConfig.transportandAgentOptions.transportdocument that a custom transport must report created thread ids throughonThreadIdor the thread-id signal.Verification
langgraph 405 tests green; langgraph, ag-ui and chat green together; 0 lint errors; production build passes. No public export added, so no api-docs regeneration.
For a reviewer
Anyone working around this bug — withholding the created id from the signal, or re-submitting after the abort — will now see the first run complete normally instead.
🤖 Generated with Claude Code