Skip to content

fix(langgraph): adopt a transport-created thread id instead of aborting the run - #1005

Merged
blove merged 3 commits into
mainfrom
blove/fix-bridge-thread-adoption
Sep 5, 2026
Merged

fix(langgraph): adopt a transport-created thread id instead of aborting the run#1005
blove merged 3 commits into
mainfrom
blove/fix-bridge-thread-adoption

Conversation

@blove

@blove blove commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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.ts wraps onThreadId so currentThreadId stays in sync when the transport auto-creates a thread. Its own comment says so. But the wrapper is installed only on the fallback transport:

const transport = options.transport ?? new FetchStreamTransport(apiUrl, wrappedOnThreadId, );

Supply your own transport and the bridge never learns the created id, so currentThreadId stays null. When the consumer's callback then updates the thread-id signal:

const shouldReset = hasSeenThreadId && currentThreadId !== id;   // null !== 'abc' → true
if (resetState) abortController?.abort();                        // kills the run just created

The bridge mistakes a thread-id adoption for a thread switch.

The fix

const shouldReset = currentThreadId !== null && currentThreadId !== id;

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.

hasSeenThreadId is removed as genuinely subsumed: its only job was suppressing a reset on the first emission, where currentThreadId is 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 MockAgentTransport and no backend: a bridge over a custom transport that creates a thread mid-stream must not abort, asserted on the stream's own AbortSignal and on the event reaching subscribers rather than on internals. Against main it fails with expected true to be false on signal.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.transport and AgentOptions.transport document that a custom transport must report created thread ids through onThreadId or 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

@blove
blove enabled auto-merge (squash) September 5, 2026 04:15
@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 5, 2026 5:06am 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 5, 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 4, 2026 21:36
…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
blove force-pushed the blove/fix-bridge-thread-adoption branch from 69ee006 to eb11ba8 Compare September 5, 2026 04:36
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Sep 5, 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 8cae5c5 into main Sep 5, 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