fix(sdk): coalesce submit threadId when useStream hook is unbound - #2569
fix(sdk): coalesce submit threadId when useStream hook is unbound#2569stescott wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: 24fdf8a The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@langchain/langgraph-checkpoint
@langchain/langgraph-checkpoint-mongodb
@langchain/langgraph-checkpoint-postgres
@langchain/langgraph-checkpoint-redis
@langchain/langgraph-checkpoint-sqlite
@langchain/langgraph-checkpoint-validation
create-langgraph
@langchain/langgraph-api
@langchain/langgraph-cli
@langchain/langgraph
@langchain/langgraph-cua
@langchain/langgraph-supervisor
@langchain/langgraph-swarm
@langchain/langgraph-ui
@langchain/langgraph-sdk
@langchain/angular
@langchain/react
@langchain/svelte
@langchain/vue
commit: |
There was a problem hiding this comment.
Nice catch on the redundant-create / orphan-thread issue. My concern is that skipping creation entirely breaks the documented contract: SubmitOptions.threadId is documented as the ID used to create a new thread (optimistic UI). After this change, an unbound hook streams straight to /threads/${threadId}/runs/stream, which 404s if the host hasn't pre-provisioned the thread.
Could we use the idempotent middle ground instead of skipping create?
let usableThreadId = threadId ?? submitOptions?.threadId;
if (!threadId) { // hook still unbound
const thread = await client.threads.create({
threadId: usableThreadId, // honor caller-supplied hint
ifExists: "do_nothing", // idempotent for pre-provisioned threads
metadata: submitOptions?.metadata,
signal,
});
usableThreadId = usableThreadId ?? thread.thread_id;
threadIdRef.current = usableThreadId;
threadIdStreamingRef.current = usableThreadId;
onThreadId(usableThreadId);
}This hits all three goals: no redundant churn/orphan, streams on the caller ID, and still provisions for callers relying on the documented behavior.
Smaller notes:
- Metadata drop: the adopt branch skips create, so
submitOptions.metadatais never applied at the thread level. TheifExistsapproach avoids this. - Tests: only the React path is covered, not
orchestrator.ts, and there's no test for the risky case (unbound hook +threadIdfor a thread that doesn't exist server-side).
I also want to point out that we recommend users to use @langchain/react for better streaming DevX and feature support. Eventually we will deprecate and remove the current React primitives in the SDK package.
When the hook is bound with threadId: null (e.g. while a host app bootstraps thread creation), submit() incorrectly ignored submitOptions.threadId and always called threads.create(), using whatever thread_id the server returned — often a newly minted orphan thread. Align LGP useStream/orchestrator with the custom transport path: coalesce hook threadId ?? submitOptions.threadId before minting. Adds regression test for the HOZ-3555 DocuSign return race. Co-authored-by: Cursor <cursoragent@cursor.com>
Address review feedback: keep SubmitOptions.threadId create semantics via threads.create with ifExists: do_nothing instead of skipping create, so pre-provisioned threads are reused and missing threads are still provisioned. Co-authored-by: Cursor <cursoragent@cursor.com>
86679e1 to
24fdf8a
Compare
Summary
Aligns the LGP
useStream/ orchestratorsubmit()paths with the custom transport behavior: when the hook is mounted withthreadId: null, a caller-providedsubmitOptions.threadIdshould be usable forruns.stream()without always going throughthreads.create().Context / docs nuance
SubmitOptions.threadIdis documented as:So today’s LGP behavior (pass it into
threads.create(), then stream on the responsethread_id) is consistent with that wording. The problems are:this.#threadId ?? submitOptions?.threadIdand streams without a forced create; LGP React + orchestrator do not.ifExists: do_nothing),submit()should not need anotherthreads.create()just because the hook prop is stillnull(bootstrap gate / render lag).thread.thread_idfrom the create response rather than the caller-supplied ID; any platform mismatch can split conversation state.Root cause (LGP)
In
stream.lgp.tsxsubmit():When hook
threadIdisnullandsubmitOptions.threadIdis set, LGP always callsthreads.create()instead of coalescing and streaming directly (custom transport pattern).Fix
threadId ?? submitOptions?.threadIdbefore deciding to mintsubmitOptions.threadIdwhile the hook is still unbound, sync internal refs /onThreadIdwithout callingthreads.create()orchestrator.ts(LGP non-React path)Test plan
uses submitOptions.threadId when hook threadId is null (no threads.create)pnpm test src/react/stream.lgp.test.tsxinlibs/sdkRelated / consumer note
useStreamuntil bootstrap completes, awaits upsert, and pinsthreadIdon submit. That works on current SDK when create honors the hint; this PR makes the SDK path match custom transport and avoids redundant create for pre-provisioned threads.ifExists) instead.