langchain-ai / langchain-ai/langgraphjs
SDK v2: hydration treats an active run's empty `next` checkpoint as idle and never subscribes
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 3.3k
- Forks
- 580
- Avg merge
- 1d 1h
- Merged PRs (30d)
- 56
Description
Checked other resources
- I added a very descriptive title to this issue.
- I searched the LangGraph.js documentation with the integrated search.
- I used the GitHub search to find a similar question and didn't find it.
- I am sure that this is a bug in LangGraph.js rather than my code.
- The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package).
Example Code
In a disposable directory, install these versions, save the code as repro.mjs,
and run node repro.mjs:
npm install @langchain/langgraph-sdk@1.10.2 @langchain/react@1.0.35 react@19.2.3 react-dom@19.2.3 @testing-library/react@16.3.0 jsdom@29.0.1
import assert from 'node:assert/strict';
import { Client } from '@langchain/langgraph-sdk';
import { useStream } from '@langchain/react';
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<html><body></body></html>', {
url: 'http://localhost/',
});
for (const name of [
'window',
'document',
'navigator',
'HTMLElement',
'Node',
'MutationObserver',
]) {
Object.defineProperty(globalThis, name, {
configurable: true,
value: dom.window[name],
});
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const { act, cleanup, renderHook } = await import('@testing-library/react');
const threadId = '00000000-0000-4000-8000-000000000001';
const taskMessage = {
id: 'dispatch',
type: 'ai',
content: '',
tool_calls: ['first', 'second'].map((id) => ({
id: `task-${id}`,
name: 'task',
type: 'tool_call',
args: { description: 'same objective', subagent_type: 'worker' },
})),
};
// Reduced replay of an observed intermediate server snapshot.
const state = {
values: {
messages: [
{ id: 'human', type: 'human', content: 'parallel' },
taskMessage,
],
},
next: [],
interrupts: [],
metadata: { step: 0, source: 'loop', parents: {} },
tasks: [
{
id: 'model-task',
name: 'model',
error: null,
interrupts: [],
result: { messages: [taskMessage] },
},
],
checkpoint: {
thread_id: threadId,
checkpoint_ns: '',
checkpoint_id: 'checkpoint-0',
},
parent_checkpoint: null,
created_at: '2026-09-09T00:00:00Z',
};
const requests = [];
const mockFetch = async (input) => {
const path = new URL(input instanceof Request ? input.url : String(input))
.pathname;
requests.push(path);
if (path.endsWith('/state')) return Response.json(state);
if (path.endsWith('/history')) return Response.json([state]);
if (path.endsWith('/runs'))
return Response.json([
{ run_id: 'active-run', thread_id: threadId, status: 'running' },
]);
if (path.endsWith('/stream/events')) {
return new Response('event: end\ndata: {}\n\n', {
headers: { 'Content-Type': 'text/event-stream' },
});
}
throw new Error(`Unexpected request: ${path}`);
};
const client = new Client({
apiUrl: 'http://repro.invalid',
apiKey: null,
streamProtocol: 'v2',
callerOptions: { fetch: mockFetch },
});
try {
const hook = renderHook(() =>
useStream({
client,
fetch: mockFetch,
assistantId: 'worker-parent',
threadId,
}),
);
await act(async () => {
await hook.result.current.hydrationPromise;
});
console.log({
requests,
messages: hook.result.current.messages.length,
isLoading: hook.result.current.isLoading,
});
assert.ok(
requests.some((path) => path.endsWith('/stream/events')),
'An active server run should establish its event subscription after hydration',
);
} finally {
cleanup();
dom.window.close();
}
The replay tests the missing subscription decision only. Its dummy stream response
does not test eventual completion or constitute a complete server implementation.
The real-server reproduction below establishes the resulting lost completion.
The replay was executed using our existing installed dependencies; the clean
installation command above has not separately been verified.
Error Message and Stack Trace (if applicable)
There is no server or hook exception: the client silently remains stale.
The replay prints:
requests: [
'/threads/00000000-0000-4000-8000-000000000001/state',
'/threads/00000000-0000-4000-8000-000000000001/history'
]
messages: 2
isLoading: false
AssertionError [ERR_ASSERTION]: An active server run should establish its event subscription after hydration
### Description
We are remounting native `useStream` for an existing thread while its server-side
run is still executing. With protocol v2, we expect native reconnect to observe
the run's completion and final persisted messages without another refresh or a
new user submission.
Instead, hydration can return `next: []` and no pending interrupts while the run
is still active. `isThreadStateActive` interprets that snapshot as idle and the
controller defers its root subscription and lifecycle watcher. The server later
persists both delegated task results and the final parent answer, but the hook
retains two initial messages and running subagent entries with `isLoading: false`.
No `/stream/events` request opens after the affected remount.
### Real-server reproduction and evidence
1. Run a model-free Python `create_agent` parent using Deep Agents
`SubAgentMiddleware`. Dispatch two parallel native `task` calls to compiled
messages-only adapters, each invoking a compiled inner agent with a delayed
tool. Return final messages and structured results.
2. Serve it with Agent Protocol v2. Mount native `useStream`, submit,
and wait for both native subagents to appear.
3. Disconnect and unmount the client without cancelling the server run. Remount
with the same thread ID while the server is running.
4. Capture hydration and event-subscription requests. Independently inspect the
run status and final thread state; do not copy those reads into the hook.
The affected snapshot has `next: []`, no interrupts, and metadata step 0. Its
messages contain the human request and assistant task calls. The model task has
pending result writes containing those calls, but no child results are present.
An immediate-remount trace confirmed that the run endpoint still reported
`running` after this hydration response.
A second variant captures the same intermediate state response, holds delivery
until the server finishes, and then delivers the original response unchanged:
```text
548 ms Remount disconnected thread
616 ms Capture hydration: next=[], two messages, no interrupts
1934 ms Server completes; deliver original hydration response
1934 ms SDK requests history; no event subscription follows
16962 ms Hook still has two messages, running subagents, isLoading=false
Independent final state contains five messages and final answer
The real checkpoint race is intermittent. Latest captured failures used the
in-memory broker; Redis passed the latest attempts, which does not establish that
it is unaffected. Both completion during hydration and completion after hydration
need coverage. Checking run status alone may still leave a gap if a run finishes
after the snapshot was captured but before that status check; that is a scenario
to verify, not a tested conclusion about #2585.
Suspected boundary and related work
The SDK activity gate assumes that a present empty next array and no interrupts
prove the thread is idle. That implication does not hold for the observed
snapshot/run pair. Server calls Python LangGraph aget_state() and passes through
its next; Python snapshot construction can include pending task writes while
filtering those written tasks out of next.
The primary report is therefore about the JavaScript SDK's reconnect decision,
with the observed server environment. We have not reproduced the real
execution race on LangGraph's hosted server, and are not claiming every server
implementation produces this state.
#2585 describes precisely
the active-run/idle-checkpoint category and added an active-run check. Please
clarify whether its absence from the inspected controller is intentional and what
signal clients should use to avoid losing completion here. A supported fix should
cover completion before hydration, during the hydration/subscription transition,
and after subscription, without application polling or historical-state repair.
System Info
Real-server contract environment:
- macOS 26.6.2, Apple Silicon
- Node.js 24.9.0; Yarn 4.17.1
@langchain/langgraph-sdk1.10.2@langchain/react1.0.35;@langchain/core1.2.9- React / React DOM 19.2.3
@testing-library/react16.3.0; jsdom 29.0.1- Python 3.13.5; LangGraph 1.2.11; LangChain 1.4.0; Deep Agents 0.7.13
- PostgreSQL 17; in-memory and Redis 7 event brokers
FF_V2_EVENT_STREAMING=true; clientstreamProtocol: 'v2'
The reduced replay above also fails under Node.js 24.18.0 with the same installed
SDK, React, and test packages.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the repro.mjs example and trace useStream hydration through isThreadStateActive and the protocol v2 controller, focusing on the decision to request /stream/events. Compare the hydration snapshot with the active /runs response and related work in #2585. Done means reconnect coverage handles runs completing before, during, and after hydration without losing the final persisted messages.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, react, typescript
- Domain
- api, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100