Skip to content

Commit 8cae5c5

Browse files
bloveclaudegithub-actions[bot]
authored
fix(langgraph): adopt a transport-created thread id instead of aborting the run (#1005)
* fix(langgraph): adopt a transport-created thread id instead of aborting 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> * chore(docs): regenerate api docs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 694003d commit 8cae5c5

5 files changed

Lines changed: 100 additions & 11 deletions

File tree

apps/website/content/docs/langgraph/api/api-docs.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -949,7 +949,7 @@
949949
{
950950
"name": "transport",
951951
"type": "AgentTransport",
952-
"description": "Custom transport. Defaults to FetchStreamTransport.",
952+
"description": "Custom transport. Defaults to FetchStreamTransport.\n\nA custom transport owns its own thread creation, so the runtime cannot\nobserve it directly: report every thread id the transport creates through\nAgentConfig.onThreadId or through the `threadId` signal, otherwise\nthe runtime keeps sending `null` and a new thread is created on each submit.",
953953
"optional": true
954954
}
955955
],
@@ -1091,7 +1091,7 @@
10911091
{
10921092
"name": "transport",
10931093
"type": "AgentTransport",
1094-
"description": "Custom transport. Defaults to FetchStreamTransport.",
1094+
"description": "Custom transport. Defaults to FetchStreamTransport.\n\nA custom transport owns its own thread creation, so the runtime cannot\nobserve it directly: report every thread id the transport creates through\nthis config's AgentOptions.onThreadId or through the `threadId`\nsignal, otherwise the runtime keeps sending `null` and a new thread is\ncreated on each submit.",
10951095
"optional": true
10961096
}
10971097
],

libs/langgraph/src/lib/agent.provider.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ export interface AgentConfig<
3636
throttle?: number | false;
3737
/** Custom message deserializer for non-standard message formats. */
3838
toMessage?: (msg: unknown) => BaseMessage;
39-
/** Custom transport. Defaults to {@link FetchStreamTransport}. */
39+
/**
40+
* Custom transport. Defaults to {@link FetchStreamTransport}.
41+
*
42+
* A custom transport owns its own thread creation, so the runtime cannot
43+
* observe it directly: report every thread id the transport creates through
44+
* {@link AgentConfig.onThreadId} or through the `threadId` signal, otherwise
45+
* the runtime keeps sending `null` and a new thread is created on each submit.
46+
*/
4047
transport?: AgentTransport;
4148
/** Tuning options for the default transport's LangGraph SDK client (e.g. retry budget). */
4249
clientOptions?: LangGraphClientOptions;

libs/langgraph/src/lib/agent.types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,15 @@ export interface AgentOptions<T, _ResolvedBag extends BagTemplate> {
277277
throttle?: number | false;
278278
/** Custom message deserializer for non-standard message formats. */
279279
toMessage?: (msg: unknown) => BaseMessage;
280-
/** Custom transport. Defaults to FetchStreamTransport. */
280+
/**
281+
* Custom transport. Defaults to FetchStreamTransport.
282+
*
283+
* A custom transport owns its own thread creation, so the runtime cannot
284+
* observe it directly: report every thread id the transport creates through
285+
* this config's {@link AgentOptions.onThreadId} or through the `threadId`
286+
* signal, otherwise the runtime keeps sending `null` and a new thread is
287+
* created on each submit.
288+
*/
281289
transport?: AgentTransport;
282290
/** Tuning options for the default transport's LangGraph SDK client (e.g. retry budget). */
283291
clientOptions?: LangGraphClientOptions;

libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2501,6 +2501,71 @@ describe('createStreamManagerBridge', () => {
25012501
destroy$.next();
25022502
});
25032503

2504+
it('adopts a thread id created mid-stream by a custom transport instead of aborting the run', async () => {
2505+
// A consumer-supplied transport creates the thread itself and reports the
2506+
// id back through the configured thread-id signal while the first run is
2507+
// still streaming. The bridge has no thread of its own yet, so this is an
2508+
// adoption — it must NOT be mistaken for a thread switch and must not
2509+
// abort the run that just started.
2510+
const transport = new MockAgentTransport();
2511+
const streamSpy = vi.spyOn(transport, 'stream');
2512+
const subjects = makeSubjects();
2513+
const destroy$ = new Subject<void>();
2514+
const threadId$ = new BehaviorSubject<string | null>(null);
2515+
const bridge = createStreamManagerBridge({
2516+
options: { apiUrl: '', assistantId: 'test', transport },
2517+
subjects,
2518+
threadId$: threadId$.asObservable(),
2519+
destroy$: destroy$.asObservable(),
2520+
});
2521+
2522+
const submitted = bridge.submit({});
2523+
await new Promise(r => setTimeout(r, 10));
2524+
2525+
threadId$.next('thread-created-by-transport');
2526+
2527+
transport.emit([{ type: 'values', values: { count: 1 } }]);
2528+
await new Promise(r => setTimeout(r, 10));
2529+
2530+
const signal = streamSpy.mock.calls[0][3] as AbortSignal;
2531+
expect(signal.aborted).toBe(false);
2532+
expect(subjects.values$.value).toEqual({ count: 1 });
2533+
expect(subjects.status$.value).not.toBe(ResourceStatus.Error);
2534+
2535+
transport.close();
2536+
await submitted;
2537+
destroy$.next();
2538+
});
2539+
2540+
it('still aborts and resets when a known thread id switches to a different one mid-stream', async () => {
2541+
const transport = new MockAgentTransport();
2542+
const streamSpy = vi.spyOn(transport, 'stream');
2543+
const subjects = makeSubjects();
2544+
const destroy$ = new Subject<void>();
2545+
const threadId$ = new BehaviorSubject<string | null>('thread-1');
2546+
const bridge = createStreamManagerBridge({
2547+
options: { apiUrl: '', assistantId: 'test', transport },
2548+
subjects,
2549+
threadId$: threadId$.asObservable(),
2550+
destroy$: destroy$.asObservable(),
2551+
});
2552+
2553+
bridge.submit({});
2554+
await new Promise(r => setTimeout(r, 10));
2555+
transport.emit([{ type: 'values', values: { count: 1 } }]);
2556+
await new Promise(r => setTimeout(r, 10));
2557+
expect(subjects.values$.value).toEqual({ count: 1 });
2558+
2559+
threadId$.next('thread-2');
2560+
await new Promise(r => setTimeout(r, 10));
2561+
2562+
const signal = streamSpy.mock.calls[0][3] as AbortSignal;
2563+
expect(signal.aborted).toBe(true);
2564+
expect(subjects.values$.value).toEqual({});
2565+
expect(subjects.messages$.value).toEqual([]);
2566+
destroy$.next();
2567+
});
2568+
25042569
it('stop() aborts the active stream and sets status to Idle (user-stop is not an error)', async () => {
25052570
const transport = new MockAgentTransport();
25062571
const subjects = makeSubjects();

libs/langgraph/src/lib/internals/stream-manager.bridge.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,14 @@ export interface StreamManagerBridge {
140140
export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = BagTemplate>(
141141
{ options, subjects, threadId$, destroy$, reportOperationFailure }: StreamManagerBridgeOptions<T, ResolvedBag>
142142
): StreamManagerBridge {
143-
// Intercept onThreadId to update currentThreadId when the transport
144-
// auto-creates a thread. Without this, each submit() creates a new thread
145-
// because currentThreadId stays null.
143+
// Intercept onThreadId so currentThreadId tracks a thread the DEFAULT
144+
// transport auto-creates. Without this, each submit() would create a new
145+
// thread because currentThreadId stays null. This wrapper only reaches the
146+
// transport the bridge constructs below — a consumer-supplied transport owns
147+
// its own creation callback, so it must report created ids through the
148+
// configured `onThreadId` or the thread-id signal (see AgentConfig.transport).
149+
// Either route is handled: the thread-id subscription treats a null
150+
// currentThreadId as adoption rather than a switch.
146151
const userOnThreadId = options.onThreadId;
147152
const wrappedOnThreadId = (id: string) => {
148153
currentThreadId = id;
@@ -161,7 +166,6 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
161166
let lastOptions: LangGraphSubmitOptions | undefined;
162167
let abortController: AbortController | null = null;
163168
let historyAbortController: AbortController | null = null;
164-
let hasSeenThreadId = false;
165169
const userAbortedControllers = new WeakSet<AbortController>();
166170
const toolProgressMap = new Map<string, ToolProgress>();
167171
// Message ids whose content is known-final (installed by a canonical
@@ -422,10 +426,15 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
422426
void refreshHistory();
423427
}
424428

425-
// Track threadId changes
429+
// Track threadId changes.
430+
//
431+
// A null currentThreadId means the bridge is not on a thread yet, so an
432+
// incoming id is an ADOPTION — typically the thread a transport just created
433+
// for the run that is streaming right now. Resetting there would abort the
434+
// bridge's own in-flight run. Only a KNOWN id changing to a different id (or
435+
// to null) is a genuine switch, and only that resets.
426436
threadId$.pipe(takeUntil(destroy$)).subscribe(id => {
427-
const shouldReset = hasSeenThreadId && currentThreadId !== id;
428-
hasSeenThreadId = true;
437+
const shouldReset = currentThreadId !== null && currentThreadId !== id;
429438
setThreadId(id, shouldReset);
430439
});
431440

0 commit comments

Comments
 (0)