langchain-ai / langchain-ai/langgraphjs

SDK v2: hydration treats an active run's empty `next` checkpoint as idle and never subscribes

Open
#2,804 2 comments 1 reaction 0 assignees View on GitHub

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-sdk 1.10.2
  • @langchain/react 1.0.35; @langchain/core 1.2.9
  • React / React DOM 19.2.3
  • @testing-library/react 16.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; client streamProtocol: 'v2'

The reduced replay above also fails under Node.js 24.18.0 with the same installed
SDK, React, and test packages.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.