Merge branch 'main' into fix/subgraph-delta-channel-hydration

This commit is contained in:
Elior Nataf Lackritz
2026-08-07 09:49:36 -04:00
committed by GitHub
88 changed files with 761 additions and 659 deletions
@@ -58,8 +58,14 @@ lint.select = [
"UP", # pyupgrade
"B", # flake8-bugbear
"I", # isort
"PLC0415", # import-outside-top-level
"RUF100", # unused noqa directive
]
lint.ignore = ["E501", "B008"]
# PLC0415 (import-outside-top-level) is enforced in tests only. Library code
# still has deferred imports that have not been reviewed, so it stays exempt
# for now.
lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] }
target-version = "py310"
[tool.uv.sources]
+8 -32
View File
@@ -67,24 +67,12 @@ with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
"v": 4,
"ts": "2024-07-31T20:14:19.804150+00:00",
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
"channel_values": {
"my_key": "meow",
"node": "node"
},
"channel_versions": {
"__start__": 2,
"my_key": 3,
"start:node": 3,
"node": 3
},
"channel_values": {"my_key": "meow", "node": "node"},
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
"versions_seen": {
"__input__": {},
"__start__": {
"__start__": 1
},
"node": {
"start:node": 2
}
"__start__": {"__start__": 1},
"node": {"start:node": 2},
},
}
@@ -108,24 +96,12 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
"v": 4,
"ts": "2024-07-31T20:14:19.804150+00:00",
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
"channel_values": {
"my_key": "meow",
"node": "node"
},
"channel_versions": {
"__start__": 2,
"my_key": 3,
"start:node": 3,
"node": 3
},
"channel_values": {"my_key": "meow", "node": "node"},
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
"versions_seen": {
"__input__": {},
"__start__": {
"__start__": 1
},
"node": {
"start:node": 2
}
"__start__": {"__start__": 1},
"node": {"start:node": 2},
},
}
@@ -223,7 +223,7 @@ class PostgresSaver(BasePostgresSaver):
>>> checkpoint_tuple = memory.get_tuple(config)
>>> print(checkpoint_tuple)
CheckpointTuple(...)
""" # noqa
"""
thread_id = config["configurable"]["thread_id"]
checkpoint_id = get_checkpoint_id(config)
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
@@ -478,9 +478,11 @@ class PostgresSaver(BasePostgresSaver):
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
parent_of: dict[str, str | None] = {}
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
hs_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
inline_by_i_by_cid: list[dict[str, Any]] = [{} for _ in channels]
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels}
seed_inline_by_ch: dict[str, Any] = {}
walk_cursor_by_ch: dict[str, str | None] = {}
seeded: set[str] = set()
cursor: str | None = None
@@ -489,7 +491,8 @@ class PostgresSaver(BasePostgresSaver):
while True:
stage1_params: list[Any] = []
for ch in channels:
stage1_params.extend([ch, ch])
# ver_i, blob channel, blob version, inline_i
stage1_params.extend([ch, ch, ch, ch])
stage1_params.extend(
[thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
)
@@ -502,16 +505,19 @@ class PostgresSaver(BasePostgresSaver):
channels,
parent_of,
ver_by_i_by_cid,
hs_by_i_by_cid,
hb_by_i_by_cid,
inline_by_i_by_cid,
)
self._try_advance_walks(
checkpoint_id,
channels,
parent_of,
ver_by_i_by_cid,
hs_by_i_by_cid,
hb_by_i_by_cid,
inline_by_i_by_cid,
chain_by_ch,
seed_ver_by_ch,
seed_inline_by_ch,
walk_cursor_by_ch,
seeded,
)
@@ -546,6 +552,7 @@ class PostgresSaver(BasePostgresSaver):
channels=channels,
chain_by_ch=chain_by_ch,
seed_ver_by_ch=seed_ver_by_ch,
seed_inline_by_ch=seed_inline_by_ch,
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
)
@@ -426,9 +426,11 @@ class AsyncPostgresSaver(BasePostgresSaver):
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
parent_of: dict[str, str | None] = {}
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
hs_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
inline_by_i_by_cid: list[dict[str, Any]] = [{} for _ in channels]
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels}
seed_inline_by_ch: dict[str, Any] = {}
walk_cursor_by_ch: dict[str, str | None] = {}
seeded: set[str] = set()
cursor: str | None = None
@@ -437,7 +439,8 @@ class AsyncPostgresSaver(BasePostgresSaver):
while True:
stage1_params: list[Any] = []
for ch in channels:
stage1_params.extend([ch, ch])
# ver_i, blob channel, blob version, inline_i
stage1_params.extend([ch, ch, ch, ch])
stage1_params.extend(
[thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
)
@@ -450,16 +453,19 @@ class AsyncPostgresSaver(BasePostgresSaver):
channels,
parent_of,
ver_by_i_by_cid,
hs_by_i_by_cid,
hb_by_i_by_cid,
inline_by_i_by_cid,
)
self._try_advance_walks(
checkpoint_id,
channels,
parent_of,
ver_by_i_by_cid,
hs_by_i_by_cid,
hb_by_i_by_cid,
inline_by_i_by_cid,
chain_by_ch,
seed_ver_by_ch,
seed_inline_by_ch,
walk_cursor_by_ch,
seeded,
)
@@ -490,6 +496,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
channels=channels,
chain_by_ch=chain_by_ch,
seed_ver_by_ch=seed_ver_by_ch,
seed_inline_by_ch=seed_inline_by_ch,
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
)
@@ -573,7 +580,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
while True:
try:
yield asyncio.run_coroutine_threadsafe(
anext(aiter_), # type: ignore[arg-type] # noqa: F821
anext(aiter_), # type: ignore[arg-type]
self.loop,
).result()
except StopAsyncIteration:
@@ -199,27 +199,68 @@ class _DeltaStage2Row(TypedDict, total=False):
def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
"""Build stage 1 SQL with 2K parallel JSONB key lookups.
"""Build stage 1 SQL with K parallel version lookups + seed probes.
For channels=["messages", "files"] (with `paged=True`) the result is::
SELECT checkpoint_id, parent_checkpoint_id,
checkpoint -> 'channel_versions' ->> %s AS ver_0,
(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_0,
EXISTS (SELECT 1 FROM checkpoint_blobs b0
WHERE b0.thread_id = checkpoints.thread_id
AND b0.checkpoint_ns = checkpoints.checkpoint_ns
AND b0.channel = %s
AND b0.version = checkpoint -> 'channel_versions' ->> %s
AND b0.type <> 'empty') AS hb_0,
checkpoint -> 'channel_values' -> %s AS inline_0,
checkpoint -> 'channel_versions' ->> %s AS ver_1,
(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_1
EXISTS (...) AS hb_1,
checkpoint -> 'channel_values' -> %s AS inline_1
FROM checkpoints
WHERE thread_id = %s AND checkpoint_ns = %s
AND (%s::text IS NULL OR checkpoint_id < %s)
ORDER BY checkpoint_id DESC
LIMIT %s
Channel names are passed as `%s` parameters (safe from SQL injection).
Only the column aliases `ver_i` / `hs_i` are interpolated into the
SQL string (i is bounded by len(channels) and uses safe identifiers).
A stored value for a channel lives in one of two places, because `put`
splits them:
Caller must extend params with `[ch_0, ch_0, ch_1, ch_1, ...,
thread_id, ns, cursor, cursor, page_size]` when `paged=True`.
* **blob** — non-primitive values (and `_DeltaSnapshot`) are moved to
`checkpoint_blobs`. `hb_i` ("has blob") probes for one. The probe hits
that table's primary key `(thread_id, checkpoint_ns, channel, version)`
exactly, so it is an index lookup per row per channel.
* **inline** — `None`, `str`, `int`, `float` and `bool` stay in the
checkpoint's own `channel_values` and get no blob row at all. `inline_i`
returns that value.
Testing only for a key in `channel_values` (the previous approach) missed
blob-stored plain values, since `put` leaves an inline marker there for
`_DeltaSnapshot` but not for a plain value — which is what a thread
migrated from a pre-delta channel type leaves behind. Probing only the
blobs table would conversely miss inline primitives. Both are needed, and
the caller treats "either present" as the seed.
`hb_i` also disambiguates the two: for a `_DeltaSnapshot`, `inline_i` is the
literal `true` marker rather than the value, so a blob must win over an
inline reading whenever one exists. That ordering is what makes a genuine
inline `true` (a bool channel) distinguishable from the marker.
The `type <> 'empty'` predicate mirrors the check stage 2 already applies
when resolving the seed blob. `put` does not currently produce `empty` rows
on this path — `blob_versions` is filtered to keys present in
`channel_values`, so `_dump_blobs`' empty branch is unreachable from it —
but without the predicate the two stages could disagree: stage 1 would
terminate the walk on a row stage 2 then discards, yielding no seed *and* a
truncated write chain, which is the failure this function exists to avoid.
Channel names are passed as `%s` parameters (safe from SQL injection).
Only the column aliases `ver_i` / `hb_i` / `inline_i` and the subquery alias
`b{i}` are interpolated into the SQL string (i is bounded by len(channels)
and uses safe identifiers).
Caller must extend params with `[ch_0 x4, ch_1 x4, ..., thread_id, ns,
cursor, cursor, page_size]` when `paged=True` — four per channel: the
version lookup, the blob's channel, the version the blob must match, and the
inline lookup.
When `paged=False`, the WHERE has no cursor predicate and there's no
LIMIT/ORDER BY — kept as a non-public helper for tests/diagnostics.
@@ -228,7 +269,13 @@ def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
for i in range(len(channels)):
cols.append(
f"checkpoint -> 'channel_versions' ->> %s AS ver_{i}, "
f"(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_{i}"
f"EXISTS (SELECT 1 FROM checkpoint_blobs b{i} "
f"WHERE b{i}.thread_id = checkpoints.thread_id "
f"AND b{i}.checkpoint_ns = checkpoints.checkpoint_ns "
f"AND b{i}.channel = %s "
f"AND b{i}.version = checkpoint -> 'channel_versions' ->> %s "
f"AND b{i}.type <> 'empty') AS hb_{i}, "
f"checkpoint -> 'channel_values' -> %s AS inline_{i}"
)
sql = (
"SELECT checkpoint_id, parent_checkpoint_id, "
@@ -342,7 +389,8 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
channels: Sequence[str],
parent_of: dict[str, str | None],
ver_by_i_by_cid: list[dict[str, str | None]],
hs_by_i_by_cid: list[dict[str, bool]],
hb_by_i_by_cid: list[dict[str, bool]],
inline_by_i_by_cid: list[dict[str, Any]],
) -> str | None:
"""Fold one stage-1 page into the running walk-state mappings.
@@ -356,7 +404,8 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
parent_of[cid] = cast("str | None", r["parent_checkpoint_id"])
for i in range(len(channels)):
ver_by_i_by_cid[i][cid] = cast("str | None", r.get(f"ver_{i}"))
hs_by_i_by_cid[i][cid] = bool(r.get(f"hs_{i}"))
hb_by_i_by_cid[i][cid] = bool(r.get(f"hb_{i}"))
inline_by_i_by_cid[i][cid] = r.get(f"inline_{i}")
# Rows are DESC; the last one is the smallest cid in the page.
oldest = cid
return oldest
@@ -367,9 +416,11 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
channels: Sequence[str],
parent_of: Mapping[str, str | None],
ver_by_i_by_cid: Sequence[Mapping[str, str | None]],
hs_by_i_by_cid: Sequence[Mapping[str, bool]],
hb_by_i_by_cid: Sequence[Mapping[str, bool]],
inline_by_i_by_cid: Sequence[Mapping[str, Any]],
chain_by_ch: dict[str, list[str]],
seed_ver_by_ch: dict[str, str | None],
seed_inline_by_ch: dict[str, Any],
walk_cursor_by_ch: dict[str, str | None],
seeded: set[str],
) -> None:
@@ -377,14 +428,15 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
Uses the partial `parent_of` map accumulated so far. A walk stops
either because:
(a) it found a snapshot for its channel (channel becomes seeded),
(a) it found a stored value for its channel — a blob or an inline
primitive (channel becomes seeded),
(b) it reached a real root (parent_of[cid] is None — fully
materialized at this point), or
(c) the next ancestor cid isn't in `parent_of` yet (waiting for
a later page; the cursor stays put).
Mutates `chain_by_ch`, `seed_ver_by_ch`, `walk_cursor_by_ch`, and
`seeded` in place.
Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`,
`walk_cursor_by_ch`, and `seeded` in place.
"""
for i, ch in enumerate(channels):
if ch in seeded:
@@ -394,15 +446,22 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
walk_cursor_by_ch[ch] = parent_of.get(target_id)
cur_cid = walk_cursor_by_ch[ch]
ch_chain = chain_by_ch[ch]
hs_i = hs_by_i_by_cid[i]
hb_i = hb_by_i_by_cid[i]
inline_i = inline_by_i_by_cid[i]
ver_i = ver_by_i_by_cid[i]
while cur_cid is not None:
if cur_cid not in parent_of:
# Need more pages to continue this walk.
break
ch_chain.append(cur_cid)
if hs_i.get(cur_cid, False):
has_blob = hb_i.get(cur_cid, False)
inline = inline_i.get(cur_cid)
if has_blob or inline is not None:
# A blob wins: for a `_DeltaSnapshot` the inline reading is
# the `true` marker, not the value.
seed_ver_by_ch[ch] = ver_i.get(cur_cid)
if not has_blob:
seed_inline_by_ch[ch] = inline
seeded.add(ch)
cur_cid = None
break
@@ -415,16 +474,23 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
channels: Sequence[str],
chain_by_ch: Mapping[str, list[str]],
seed_ver_by_ch: Mapping[str, str | None],
seed_inline_by_ch: Mapping[str, Any],
stage2_rows: Sequence[_DeltaStage2Row],
) -> dict[str, DeltaChannelHistory]:
"""Demux stage 2 rows per channel; produce per-channel histories.
stage2_rows carry `channel` on every row. We build per-channel
`writes_by_cid` and per-channel `seed_blob` dicts, then assemble
a `DeltaChannelHistory` per requested channel. The `seed` key is omitted
when the walk reached root with no snapshot found, or when the
seed blob is sentinel "empty" — in both cases the consumer treats
absence as "start empty".
a `DeltaChannelHistory` per requested channel.
A seed comes from the blobs table when the walk found one there, and
otherwise from `seed_inline_by_ch` — `put` keeps `None`, `str`, `int`,
`float` and `bool` values in the checkpoint's own `channel_values` with
no blob row, so those never appear in `stage2_rows`.
The `seed` key is omitted when the walk reached root without finding a
stored value, or when the seed blob is sentinel "empty" — in both cases
the consumer treats absence as "start empty".
"""
# writes_by_ch_by_cid[channel][cid] = list of (type, blob, task_id, idx)
writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = {
@@ -473,6 +539,10 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
blob = seed_blob_by_ver.get((ch, seed_version))
if blob is not None and blob[0] != "empty":
entry["seed"] = self.serde.loads_typed(blob)
elif ch in seed_inline_by_ch:
# Inline primitive: stored in the checkpoint, not the blobs
# table, so stage 2 never returned a row for it.
entry["seed"] = seed_inline_by_ch[ch]
result[ch] = entry
return result
@@ -334,7 +334,7 @@ class ShallowPostgresSaver(BasePostgresSaver):
>>> checkpoint_tuple = memory.get_tuple(config)
>>> print(checkpoint_tuple)
CheckpointTuple(...)
""" # noqa
"""
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
args = (thread_id, checkpoint_ns)
@@ -885,7 +885,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
while True:
try:
yield asyncio.run_coroutine_threadsafe(
anext(aiter_), # type: ignore[arg-type] # noqa: F821
anext(aiter_), # type: ignore[arg-type]
self.loop,
).result()
except StopAsyncIteration:
+4
View File
@@ -32,6 +32,7 @@ test = [
"pytest-mock",
"psycopg[binary]",
"langgraph-checkpoint",
"langgraph-checkpoint-conformance",
"pytest-watcher",
]
lint = [
@@ -49,6 +50,7 @@ default-groups = ['dev']
[tool.uv.sources]
langgraph-checkpoint = { path = "../checkpoint", editable = true }
langgraph-checkpoint-conformance = { path = "../checkpoint-conformance", editable = true }
[tool.hatch.build.targets.wheel]
include = ["langgraph"]
@@ -64,6 +66,8 @@ lint.select = [
"UP", # pyupgrade
"B", # flake8-bugbear
"I", # isort
"PLC0415", # import-outside-top-level
"RUF100", # unused noqa directive
"UP", # pyupgrade
]
lint.ignore = ["E501", "B008"]
+8 -6
View File
@@ -380,13 +380,15 @@ async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
"langgraph.channels.delta", reason="langgraph core not installed"
)
from typing import Annotated
# Deferred on purpose: langgraph core is not a test dependency of this
# package, so these must stay behind the importorskip above.
from typing import Annotated # noqa: PLC0415
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from typing_extensions import TypedDict
from langchain_core.messages import AIMessage, HumanMessage # noqa: PLC0415
from langgraph.channels.delta import DeltaChannel # noqa: PLC0415
from langgraph.graph import START, StateGraph # noqa: PLC0415
from langgraph.graph.message import _messages_delta_reducer # noqa: PLC0415
from typing_extensions import TypedDict # noqa: PLC0415
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
@@ -0,0 +1,30 @@
"""Run delta-channel conformance capabilities against AsyncPostgresSaver."""
from __future__ import annotations
import pytest
from langgraph.checkpoint.conformance import validate
from langgraph.checkpoint.conformance.initializer import checkpointer_test
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from tests.conftest import DEFAULT_URI
@pytest.mark.asyncio
async def test_delta_channel_conformance():
@checkpointer_test(name="AsyncPostgresSaver")
async def postgres_saver():
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
yield saver
report = await validate(
postgres_saver,
capabilities={
"delta_channel_history",
},
)
for cap, result in report.results.items():
if result.passed is False:
details = "\n".join(result.failures or [])
pytest.fail(f"Capability {cap} failed:\n{details}")
@@ -0,0 +1,204 @@
"""Seed detection for `DeltaChannel` histories on Postgres.
`put` splits stored values in two: primitives stay inline in the checkpoint's
`channel_values`, everything else moves to `checkpoint_blobs`. Only
`_DeltaSnapshot` leaves an inline marker behind when it moves, so the stage-1
walk has to check both places — a blob probe alone misses inline primitives, and
an inline-key check alone missed blob-stored plain values, which is what a thread
migrated from a pre-delta channel type leaves behind. See #8534.
"""
from __future__ import annotations
from typing import Any
from uuid import uuid4
import pytest
from langgraph.checkpoint.base import Checkpoint, empty_checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from tests.conftest import DEFAULT_URI
CHANNEL = "items"
async def _build_chain(saver: AsyncPostgresSaver, seed_value: Any) -> tuple[str, dict]:
"""Store `seed_value` at step 1, then two steps that store nothing.
Every step carries a write so the walk has something to collect.
Returns `(thread_id, head_config)`.
"""
thread_id = str(uuid4())
parent: dict | None = None
for step in range(4):
config: dict = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
if parent is not None:
config["configurable"]["checkpoint_id"] = parent["configurable"][
"checkpoint_id"
]
cp: Checkpoint = empty_checkpoint()
cp["id"] = str(uuid6(clock_seq=step))
new_versions: dict[str, Any] = {}
if step == 1:
cp["channel_values"][CHANNEL] = seed_value
cp["channel_versions"][CHANNEL] = "v1"
new_versions[CHANNEL] = "v1"
else:
cp["channel_versions"][CHANNEL] = f"v{step}"
parent = await saver.aput(
config, cp, {"source": "loop", "step": step, "parents": {}}, new_versions
)
await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
assert parent is not None
return thread_id, parent
@pytest.mark.asyncio
async def test_plain_value_seed_is_found() -> None:
"""A pre-delta plain value must be located as the seed.
Before #8534 the walk ran to the root and returned no seed, which happens
to reconstruct correctly for additive reducers while costing an
O(thread length) replay on every read.
"""
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
_, head = await _build_chain(saver, [10, 20])
result = await saver.aget_delta_channel_history(config=head, channels=[CHANNEL])
entry = result[CHANNEL]
assert entry.get("seed") == [10, 20], (
f"expected the plain value as seed, got {entry.get('seed', '<missing>')}"
)
# Only the writes between the seed and the head's parent replay: step 1
# (the seed's own) and step 2. Step 0 is older than the seed, step 3 is
# pending at the head.
assert [w[2] for w in entry["writes"]] == ["w1", "w2"]
@pytest.mark.asyncio
async def test_delta_snapshot_seed_is_found() -> None:
"""The `_DeltaSnapshot` path keeps working, so both seed kinds agree."""
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
_, head = await _build_chain(saver, _DeltaSnapshot([10, 20]))
result = await saver.aget_delta_channel_history(config=head, channels=[CHANNEL])
entry = result[CHANNEL]
seed = entry.get("seed")
assert isinstance(seed, _DeltaSnapshot), f"expected a snapshot, got {seed!r}"
assert seed.value == [10, 20]
assert [w[2] for w in entry["writes"]] == ["w1", "w2"]
@pytest.mark.asyncio
async def test_version_bump_without_a_value_does_not_hide_an_older_seed() -> None:
"""A delta-era step bumps `channel_versions` without storing a value, so no
blob exists for that version. The probe must report no seed there and keep
walking rather than stopping at a version it cannot resolve.
Step 0 holds the real value; step 1 bumps the version with nothing stored.
Walking back from the head has to pass step 1 to reach step 0.
"""
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
thread_id = str(uuid4())
parent: dict | None = None
for step in range(4):
config: dict = {
"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}
}
if parent is not None:
config["configurable"]["checkpoint_id"] = parent["configurable"][
"checkpoint_id"
]
cp: Checkpoint = empty_checkpoint()
cp["id"] = str(uuid6(clock_seq=step))
new_versions: dict[str, Any] = {}
if step == 0:
cp["channel_values"][CHANNEL] = [10, 20]
cp["channel_versions"][CHANNEL] = "v0"
new_versions[CHANNEL] = "v0"
elif step == 1:
# Version bumped, value absent -> no blob row written.
cp["channel_versions"][CHANNEL] = "v1"
new_versions[CHANNEL] = "v1"
else:
cp["channel_versions"][CHANNEL] = "v1"
parent = await saver.aput(
config,
cp,
{"source": "loop", "step": step, "parents": {}},
new_versions,
)
await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
assert parent is not None
result = await saver.aget_delta_channel_history(
config=parent, channels=[CHANNEL]
)
entry = result[CHANNEL]
assert entry.get("seed") == [10, 20], (
"the walk stopped at the empty blob instead of reaching the real "
f"value at step 0; got {entry.get('seed', '<missing>')}"
)
assert [w[2] for w in entry["writes"]] == ["w0", "w1", "w2"]
@pytest.mark.asyncio
async def test_inline_primitive_seed_is_found() -> None:
"""`put` keeps `None`, `str`, `int`, `float` and `bool` in the checkpoint's
own `channel_values` with no blob row, so a blob probe alone cannot see
them. Stage 1 reads the inline value too and uses it when there is no blob.
"""
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
for seed_value in (42, "x", 3.5, None):
_, head = await _build_chain(saver, seed_value)
entry = (
await saver.aget_delta_channel_history(config=head, channels=[CHANNEL])
)[CHANNEL]
if seed_value is None:
# A JSON null is indistinguishable from "no value stored", so
# the walk keeps going; replay from empty is the correct result.
assert "seed" not in entry
else:
assert entry.get("seed") == seed_value, (
f"inline {type(seed_value).__name__} seed not found: "
f"{entry.get('seed', '<missing>')!r}"
)
assert [w[2] for w in entry["writes"]] == ["w1", "w2"]
@pytest.mark.asyncio
async def test_inline_true_is_not_read_as_a_snapshot_marker() -> None:
"""`put` inlines a literal `true` in `channel_values` as the marker for a
`_DeltaSnapshot`, which is also what a genuine `bool` channel holding
`True` looks like. A blob exists only in the snapshot case, so preferring
the blob keeps the two apart.
"""
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
_, head = await _build_chain(saver, True)
entry = (
await saver.aget_delta_channel_history(config=head, channels=[CHANNEL])
)[CHANNEL]
assert entry.get("seed") is True, (
f"a real inline True must survive, got {entry.get('seed', '<missing>')!r}"
)
_, snap_head = await _build_chain(saver, _DeltaSnapshot(True))
snap_entry = (
await saver.aget_delta_channel_history(config=snap_head, channels=[CHANNEL])
)[CHANNEL]
seed = snap_entry.get("seed")
assert isinstance(seed, _DeltaSnapshot), (
f"the marker must resolve to the blob, not inline true; got {seed!r}"
)
assert seed.value is True
+32 -1
View File
@@ -159,7 +159,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -322,6 +322,33 @@ test = [
{ name = "redis" },
]
[[package]]
name = "langgraph-checkpoint-conformance"
version = "0.0.2"
source = { editable = "../checkpoint-conformance" }
dependencies = [
{ name = "langgraph-checkpoint" },
]
[package.metadata]
requires-dist = [{ name = "langgraph-checkpoint", editable = "../checkpoint" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "ty" },
]
lint = [
{ name = "ruff" },
{ name = "ty" },
]
test = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.1.1"
@@ -338,6 +365,7 @@ dev = [
{ name = "anyio" },
{ name = "codespell" },
{ name = "langgraph-checkpoint" },
{ name = "langgraph-checkpoint-conformance" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -354,6 +382,7 @@ lint = [
test = [
{ name = "anyio" },
{ name = "langgraph-checkpoint" },
{ name = "langgraph-checkpoint-conformance" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -374,6 +403,7 @@ dev = [
{ name = "anyio" },
{ name = "codespell" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "psycopg", extras = ["binary"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -390,6 +420,7 @@ lint = [
test = [
{ name = "anyio" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "psycopg", extras = ["binary"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -77,7 +77,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
>>> result = graph.invoke(3, config)
>>> graph.get_state(config)
StateSnapshot(values=4, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '0c62ca34-ac19-445d-bbb0-5b4984975b2a'}}, parent_config=None)
""" # noqa
"""
conn: sqlite3.Connection
is_setup: bool
@@ -222,7 +222,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
>>> checkpoint_tuple = memory.get_tuple(config)
>>> print(checkpoint_tuple)
CheckpointTuple(...)
""" # noqa
"""
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
with self.cursor(transaction=False) as cur:
# find the latest checkpoint for the thread_id
@@ -212,7 +212,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
while True:
try:
yield asyncio.run_coroutine_threadsafe(
anext(aiter_), # type: ignore[arg-type] # noqa: F821
anext(aiter_), # type: ignore[arg-type]
self.loop,
).result()
except StopAsyncIteration:
+4
View File
@@ -30,6 +30,7 @@ test = [
"pytest-mock",
"pytest-watcher",
"langgraph-checkpoint",
"langgraph-checkpoint-conformance",
"pytest-retry>=1.7.0",
]
lint = [
@@ -47,6 +48,7 @@ default-groups = ['dev']
[tool.uv.sources]
langgraph-checkpoint = { path = "../checkpoint", editable = true }
langgraph-checkpoint-conformance = { path = "../checkpoint-conformance", editable = true }
[tool.hatch.build.targets.wheel]
include = ["langgraph"]
@@ -62,6 +64,8 @@ lint.select = [
"UP", # pyupgrade
"B", # flake8-bugbear
"I", # isort
"PLC0415", # import-outside-top-level
"RUF100", # unused noqa directive
"UP", # pyupgrade
]
lint.ignore = ["E501", "B008"]
@@ -3,21 +3,14 @@
from __future__ import annotations
import pytest
from langgraph.checkpoint.conformance import validate
from langgraph.checkpoint.conformance.initializer import checkpointer_test
pytest.importorskip(
"langgraph.checkpoint.conformance",
reason="langgraph-checkpoint-conformance not installed",
)
pytest.importorskip("aiosqlite", reason="aiosqlite not installed")
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
@pytest.mark.asyncio
async def test_delta_channel_conformance():
from langgraph.checkpoint.conformance import validate
from langgraph.checkpoint.conformance.initializer import checkpointer_test
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
@checkpointer_test(name="AsyncSqliteSaver")
async def sqlite_saver():
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
@@ -29,13 +29,13 @@ pytest.importorskip("langgraph.channels.delta", reason="langgraph core not insta
pytest.importorskip("langgraph.channels.binop", reason="langgraph core not installed")
pytest.importorskip("langgraph.graph", reason="langgraph core not installed")
from langgraph.channels.binop import BinaryOperatorAggregate # type: ignore[import-untyped] # noqa: E402,I001
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: E402
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] # noqa: E402
from typing_extensions import TypedDict # noqa: E402
from langgraph.channels.binop import BinaryOperatorAggregate # type: ignore[import-untyped] # noqa: I001
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped]
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped]
from typing_extensions import TypedDict
from langgraph.checkpoint.sqlite import SqliteSaver # noqa: E402
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
pytestmark = pytest.mark.anyio
@@ -32,13 +32,13 @@ from langchain_core.runnables import RunnableConfig
pytest.importorskip("langgraph.channels.delta", reason="langgraph core not installed")
pytest.importorskip("langgraph.graph", reason="langgraph core not installed")
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: E402,I001
from langgraph.checkpoint.serde.types import _DeltaSnapshot # noqa: E402
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] # noqa: E402
from typing_extensions import TypedDict # noqa: E402
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: I001
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped]
from typing_extensions import TypedDict
from langgraph.checkpoint.sqlite import SqliteSaver # noqa: E402
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
pytestmark = pytest.mark.anyio
+4 -9
View File
@@ -1,7 +1,11 @@
import math
import os
import random
import re
import tempfile
import time
import uuid
from collections import Counter, defaultdict
from collections.abc import Generator, Iterable
from contextlib import contextmanager
from typing import Any, Literal, cast
@@ -33,10 +37,6 @@ class CharacterEmbeddings(Embeddings):
def __init__(self, dims: int = 50, seed: int = 42):
"""Initialize with embedding dimensions and random seed."""
import math
import random
from collections import defaultdict
self._rng = random.Random(seed)
self.dims = dims
# Create projection vector for each character lazily
@@ -48,9 +48,6 @@ class CharacterEmbeddings(Embeddings):
def _embed_one(self, text: str) -> list[float]:
"""Embed a single text."""
import math
from collections import Counter
counts = Counter(text)
total = sum(counts.values())
@@ -338,8 +335,6 @@ class TestSqliteStore:
# Test update
# Small delay to ensure the updated timestamp is different
import time
time.sleep(0.01)
updated_value = {"title": "Updated Document", "content": "Hello, Updated!"}
+32 -1
View File
@@ -168,7 +168,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -331,6 +331,33 @@ test = [
{ name = "redis" },
]
[[package]]
name = "langgraph-checkpoint-conformance"
version = "0.0.2"
source = { editable = "../checkpoint-conformance" }
dependencies = [
{ name = "langgraph-checkpoint" },
]
[package.metadata]
requires-dist = [{ name = "langgraph-checkpoint", editable = "../checkpoint" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "ty" },
]
lint = [
{ name = "ruff" },
{ name = "ty" },
]
test = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "3.1.1"
@@ -345,6 +372,7 @@ dependencies = [
dev = [
{ name = "codespell" },
{ name = "langgraph-checkpoint" },
{ name = "langgraph-checkpoint-conformance" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -360,6 +388,7 @@ lint = [
]
test = [
{ name = "langgraph-checkpoint" },
{ name = "langgraph-checkpoint-conformance" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -378,6 +407,7 @@ requires-dist = [
dev = [
{ name = "codespell" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -393,6 +423,7 @@ lint = [
]
test = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
+6
View File
@@ -59,9 +59,15 @@ lint.select = [
"UP", # pyupgrade
"B", # flake8-bugbear
"I", # isort
"PLC0415", # import-outside-top-level
"RUF100", # unused noqa directive
"UP", # pyupgrade
]
lint.ignore = ["E501", "B008"]
# PLC0415 (import-outside-top-level) is enforced in tests only. Library code
# still has deferred imports that have not been reviewed, so it stays exempt
# for now.
lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] }
target-version = "py310"
[tool.ty.rules]
@@ -12,10 +12,14 @@ conformance = pytest.importorskip(
@pytest.mark.asyncio
async def test_delta_channel_conformance():
from langgraph.checkpoint.conformance import validate
from langgraph.checkpoint.conformance.initializer import checkpointer_test
# Imported inside the test: the module-level importorskip above is what
# makes these safe, so they cannot move to the top of the file.
from langgraph.checkpoint.conformance import validate # noqa: PLC0415
from langgraph.checkpoint.conformance.initializer import ( # noqa: PLC0415
checkpointer_test,
)
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.memory import InMemorySaver # noqa: PLC0415
@checkpointer_test(name="InMemorySaver")
async def mem_saver():
-2
View File
@@ -307,8 +307,6 @@ class TestWithMsgpackAllowlistEncrypted:
def loads_typed(self, data: tuple[str, bytes]) -> None:
return None
from langgraph.checkpoint.serde.base import CipherProtocol
class DummyCipher(CipherProtocol):
def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
return "dummy", plaintext
+4 -9
View File
@@ -1,9 +1,12 @@
import dataclasses
import json
import logging
import os
import pathlib
import pickle
import re
import sys
import tempfile
import uuid
from collections import deque
from datetime import date, datetime, time, timezone
@@ -18,7 +21,7 @@ import ormsgpack
import pandas as pd
import pytest
from langchain_core.documents.base import Document
from langchain_core.messages import HumanMessage
from langchain_core.messages import AIMessage, HumanMessage
from pydantic import BaseModel, SecretStr
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import SecretStr as SecretStrV1
@@ -341,7 +344,6 @@ def test_lc2_json_safe_type_revives_without_allowlist() -> None:
constructor dicts. Resuming those threads must reconstruct proper BaseMessage objects
rather than returning raw dicts that cause MESSAGE_COERCION_FAILURE in add_messages.
"""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer() # default: _allowed_json_modules=None
@@ -410,7 +412,6 @@ def test_lc2_json_method_field_is_ignored() -> None:
to that method: the result is whatever ``AIMessage(*args, **kwargs)`` would
produce, which proves the default constructor ran instead of ``parse_raw``.
"""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer()
load = {
@@ -436,7 +437,6 @@ def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None:
method dispatch as a side effect. Revival is restricted to the default
constructor regardless of how the class reached the revival path.
"""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer(
allowed_json_modules=[("langchain_core.messages.ai", "AIMessage")]
@@ -455,7 +455,6 @@ def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None:
def test_lc2_json_safe_type_init_still_works() -> None:
"""SAFE-type lc=2 revival without a `method` field still constructs the class."""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer()
load = {
@@ -479,7 +478,6 @@ def test_lc2_json_legacy_pydantic_method_list_falls_back_to_default() -> None:
this shape continue to revive correctly as long as the default constructor
accepts the serialized kwargs.
"""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer()
load = {
@@ -551,9 +549,6 @@ def test_lc2_json_safe_type_pickle_payload_does_not_execute() -> None:
With method dispatch removed from `_revive_lc2`, the gadget bytes are never
passed to `parse_raw` and therefore never reach `pickle.loads`.
"""
import os
import pickle
import tempfile
marker = tempfile.NamedTemporaryFile(
prefix="lc2_block_proof_", suffix=".out", delete=False
+1 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
from typing import Any
@@ -523,7 +524,6 @@ class TestBaseFallbackGetChannelWrites:
`threading.local()` guard would let whichever task set it first
short-circuit the other to `writes=[]`.
"""
import asyncio
saver, thread_id, ns = self._build_saver_with_chain()
+6
View File
@@ -72,9 +72,15 @@ lint.select = [
"UP", # pyupgrade
"B", # flake8-bugbear
"I", # isort
"PLC0415", # import-outside-top-level
"RUF100", # unused noqa directive
"UP", # pyupgrade
]
lint.ignore = ["E501", "B008"]
# PLC0415 (import-outside-top-level) is enforced in tests only. Library code
# still has deferred imports that have not been reviewed, so it stays exempt
# for now.
lint.per-file-ignores = { "langgraph_cli/**" = ["PLC0415"], "generate_schema.py" = ["PLC0415"] }
target-version = "py310"
[tool.ty.rules]
+1 -6
View File
@@ -11,6 +11,7 @@ from langgraph_cli.archive import (
_tar_filter,
create_archive,
)
from langgraph_cli.config import LocalDeps
# ---------------------------------------------------------------------------
# _tar_filter
@@ -198,7 +199,6 @@ class TestCreateArchive:
@patch("langgraph_cli.archive._assemble_local_deps")
def test_yields_archive_with_config(self, mock_deps, tmp_path):
from langgraph_cli.config import LocalDeps
config_file = self._make_project(tmp_path)
mock_deps.return_value = LocalDeps(
@@ -218,7 +218,6 @@ class TestCreateArchive:
@patch("langgraph_cli.archive._assemble_local_deps")
def test_excludes_pycache(self, mock_deps, tmp_path):
from langgraph_cli.config import LocalDeps
config_file = self._make_project(tmp_path)
mock_deps.return_value = LocalDeps(
@@ -232,7 +231,6 @@ class TestCreateArchive:
@patch("langgraph_cli.archive._assemble_local_deps")
def test_cleans_up_tmp_dir_on_normal_exit(self, mock_deps, tmp_path):
from langgraph_cli.config import LocalDeps
config_file = self._make_project(tmp_path)
mock_deps.return_value = LocalDeps(
@@ -247,7 +245,6 @@ class TestCreateArchive:
@patch("langgraph_cli.archive._assemble_local_deps")
def test_cleans_up_tmp_dir_on_exception(self, mock_deps, tmp_path):
from langgraph_cli.config import LocalDeps
config_file = self._make_project(tmp_path)
mock_deps.return_value = LocalDeps(
@@ -264,7 +261,6 @@ class TestCreateArchive:
@patch("langgraph_cli.archive._assemble_local_deps")
@patch("langgraph_cli.archive._MAX_SIZE", 10)
def test_raises_on_oversized_archive(self, mock_deps, tmp_path):
from langgraph_cli.config import LocalDeps
config_file = self._make_project(tmp_path)
mock_deps.return_value = LocalDeps(
@@ -278,7 +274,6 @@ class TestCreateArchive:
@patch("langgraph_cli.archive._assemble_local_deps")
def test_handles_extra_contexts(self, mock_deps, tmp_path):
"""Monorepo case: project + sibling dependency directory."""
from langgraph_cli.config import LocalDeps
project = tmp_path / "myproject"
project.mkdir()
@@ -347,7 +347,6 @@ class TestCallHostBackendWithOptionalTenant:
def test_workspace_prompt_blocked_by_no_input(self, monkeypatch):
"""With _no_input=True, 403 requiring workspace should raise ClickException."""
import langgraph_cli.deploy as deploy_mod
monkeypatch.setattr(deploy_mod, "_no_input", True)
@@ -515,7 +514,6 @@ class TestEmitterTextMode:
class TestCreateHostBackendClientNoInput:
def test_raises_when_no_api_key_and_no_input(self, monkeypatch, tmp_path):
import langgraph_cli.deploy as deploy_mod
monkeypatch.setattr(deploy_mod, "_no_input", True)
monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
@@ -530,7 +528,6 @@ class TestCreateHostBackendClientNoInput:
)
def test_succeeds_with_api_key_in_env(self, monkeypatch, tmp_path):
import langgraph_cli.deploy as deploy_mod
monkeypatch.setattr(deploy_mod, "_no_input", True)
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
@@ -68,7 +68,7 @@ def _create_root_model(
def schema(
cls: type[BaseModel],
by_alias: bool = True, # noqa: FBT001,FBT002
by_alias: bool = True,
ref_template: str = DEFAULT_REF_TEMPLATE,
) -> dict[str, Any]:
# Complains about schema not being defined in superclass
@@ -80,7 +80,7 @@ def _create_root_model(
def model_json_schema(
cls: type[BaseModel],
by_alias: bool = True, # noqa: FBT001,FBT002
by_alias: bool = True,
ref_template: str = DEFAULT_REF_TEMPLATE,
schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema,
mode: JsonSchemaMode = "validation",
+1 -1
View File
@@ -22,7 +22,7 @@ from pydantic import BaseModel
from typing_extensions import NotRequired, Required, is_typeddict
try:
from langgraph.checkpoint.serde._msgpack import ( # noqa: F401
from langgraph.checkpoint.serde._msgpack import (
STRICT_MSGPACK_ENABLED,
)
except ImportError:
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import Any, Literal
from warnings import warn
# EmptyChannelError is re-exported from langgraph.channels.base
from langgraph.checkpoint.base import EmptyChannelError # noqa: F401
from langgraph.checkpoint.base import EmptyChannelError
from typing_extensions import deprecated
from langgraph.types import Command, Interrupt
+1 -1
View File
@@ -995,7 +995,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
Without type hints on the `path` function's return value (e.g., `-> Literal["foo", "__end__"]:`)
or a path_map, the graph visualization assumes the edge could transition to any node in the graph.
""" # noqa: E501
"""
if self.compiled:
logger.warning(
"Adding an edge to a graph that has already been compiled. This will "
+5 -1
View File
@@ -89,8 +89,12 @@ langgraph-sdk = { path = "../sdk-py", editable = true }
langgraph-cli = { path = "../cli", editable = true }
[tool.ruff]
lint.select = [ "E", "F", "I", "TID251", "UP" ]
lint.select = [ "E", "F", "I", "PLC0415", "RUF100", "TID251", "UP" ]
lint.ignore = [ "E501" ]
# PLC0415 (import-outside-top-level) is enforced in tests only. Library code
# still has deferred imports that have not been reviewed, so it stays exempt
# for now.
lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] }
line-length = 88
indent-width = 4
extend-include = ["*.ipynb"]
+1 -2
View File
@@ -1,5 +1,6 @@
import os
import tempfile
import time
from collections import defaultdict
from functools import partial
from typing import Any
@@ -73,8 +74,6 @@ class MemorySaverAssertImmutable(InMemorySaver):
new_versions: ChannelVersions,
) -> None:
if self.put_sleep:
import time
time.sleep(self.put_sleep)
# assert checkpoint hasn't been modified since last written
thread_id = config["configurable"]["thread_id"]
+3 -8
View File
@@ -2,14 +2,16 @@ import operator
from collections.abc import Sequence
from typing import Annotated
import orjson
import pytest
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import NotRequired, TypedDict
from langgraph._internal._constants import OVERWRITE
from langgraph._internal._typing import MISSING
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.binop import BinaryOperatorAggregate, _get_overwrite
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.channels.topic import Topic
@@ -194,10 +196,6 @@ def test_overwrite_dataclass_form_survives_json_roundtrip() -> None:
...}`) is indistinguishable from a literal channel value, and downstream
reducers raise `MESSAGE_COERCION_FAILURE` (or similar) on read.
"""
import orjson
from langgraph._internal._constants import OVERWRITE
from langgraph.channels.binop import _get_overwrite
ow = Overwrite(value=[HumanMessage(content="new", id="h2")])
erased = orjson.loads(orjson.dumps(ow, default=lambda o: o.model_dump()))
@@ -213,8 +211,6 @@ def test_overwrite_sentinel_dict_still_recognised() -> None:
"""The pre-existing `{"__overwrite__": value}` dict form continues to be
recognised. This is the canonical sentinel emitted by producers that do
not have an `Overwrite` dataclass available."""
from langgraph._internal._constants import OVERWRITE
from langgraph.channels.binop import _get_overwrite
is_overwrite, value = _get_overwrite({OVERWRITE: ["b"]})
assert is_overwrite
@@ -224,7 +220,6 @@ def test_overwrite_sentinel_dict_still_recognised() -> None:
def test_overwrite_non_matching_dict_not_recognised() -> None:
"""Dicts that resemble the erased shape but do not carry the
`__overwrite__` discriminator must not be misclassified as overwrites."""
from langgraph.channels.binop import _get_overwrite
assert _get_overwrite({"value": ["b"]}) == (False, None)
assert _get_overwrite({"type": "human", "value": "hi"}) == (False, None)
+4 -4
View File
@@ -24,7 +24,7 @@ class _TrackingCallback(BaseCallbackHandler):
def __init__(self) -> None:
self.called = False
def on_chain_start(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
def on_chain_start(self, *args, **kwargs) -> None:
self.called = True
@@ -55,7 +55,7 @@ async def test_with_config_configurable_preserved_on_invoke() -> None:
builder = StateGraph(dict)
captured: dict = {}
def node(state, config): # noqa: ANN001
def node(state, config):
captured.update(config.get("configurable") or {})
return state
@@ -79,7 +79,7 @@ async def test_with_config_metadata_preserved_on_invoke() -> None:
builder = StateGraph(dict)
captured: dict = {}
def node(state, config): # noqa: ANN001
def node(state, config):
captured.update(config.get("metadata") or {})
return state
@@ -104,7 +104,7 @@ async def test_with_config_tags_preserved_on_invoke() -> None:
builder = StateGraph(dict)
captured: list = []
def node(state, config): # noqa: ANN001
def node(state, config):
captured.extend(config.get("tags") or [])
return state
@@ -220,7 +220,7 @@ def _checkpointers() -> list[tuple[str, Any]]:
result: list[tuple[str, Any]] = [("InMemory", None)]
if _POSTGRES_AVAILABLE:
try:
import psycopg
import psycopg # noqa: PLC0415
psycopg.connect(_POSTGRES_URI).close()
result.append(("Postgres", "postgres"))
@@ -27,6 +27,7 @@ from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.types import StateUpdate
pytestmark = pytest.mark.anyio
@@ -277,7 +278,6 @@ def test_bulk_update_state_multi_task_per_superstep_delta_channel() -> None:
different `StateUpdate`s targeting the same node otherwise both share
the deterministic interrupt-derived id and collide in the saver.
"""
from langgraph.types import StateUpdate
saver = InMemorySaver()
graph = _build_graph(saver)
+4 -4
View File
@@ -88,13 +88,13 @@ def test_constants_deprecation() -> None:
LangGraphDeprecatedSinceV10,
match="Importing Send from langgraph.constants is deprecated. Please use 'from langgraph.types import Send' instead.",
):
from langgraph.constants import Send # noqa: F401
from langgraph.constants import Send # noqa: PLC0415, F401
with pytest.warns(
LangGraphDeprecatedSinceV10,
match="Importing Interrupt from langgraph.constants is deprecated. Please use 'from langgraph.types import Interrupt' instead.",
):
from langgraph.constants import Interrupt # noqa: F401
from langgraph.constants import Interrupt # noqa: PLC0415, F401
def test_pregel_types_deprecation() -> None:
@@ -102,7 +102,7 @@ def test_pregel_types_deprecation() -> None:
LangGraphDeprecatedSinceV10,
match="Importing from langgraph.pregel.types is deprecated. Please use 'from langgraph.types import ...' instead.",
):
from langgraph.pregel.types import StateSnapshot # noqa: F401
from langgraph.pregel.types import StateSnapshot # noqa: PLC0415, F401
def test_config_schema_deprecation() -> None:
@@ -195,7 +195,7 @@ def test_deprecated_import() -> None:
LangGraphDeprecatedSinceV10,
match="Importing PREVIOUS from langgraph.constants is deprecated. This constant is now private and should not be used directly.",
):
from langgraph.constants import PREVIOUS # noqa: F401
from langgraph.constants import PREVIOUS # noqa: PLC0415, F401
@pytest.mark.filterwarnings(
+1 -1
View File
@@ -13,6 +13,7 @@ from langgraph.callbacks import (
GraphCallbackHandler,
GraphInterruptEvent,
GraphResumeEvent,
_GraphCallbackManager,
)
from langgraph.graph import START, StateGraph
from langgraph.types import Command, Interrupt, interrupt
@@ -286,7 +287,6 @@ def test_non_graph_handler_via_add_handler_does_not_crash() -> None:
GraphCallbackHandler. They must be silently accepted graph lifecycle
events will simply not be dispatched to them.
"""
from langgraph.callbacks import _GraphCallbackManager
manager = _GraphCallbackManager()
plain_handler = _LangChainCustomEventHandler()
+16 -45
View File
@@ -2,11 +2,26 @@ import json
import operator
import re
import time
from copy import deepcopy
from dataclasses import replace
from typing import Annotated, Any, Literal, cast
import pytest
from langchain_core.messages import AIMessage, AnyMessage, ToolCall
from langchain_core.callbacks import CallbackManagerForLLMRun
from langchain_core.language_models.fake import FakeStreamingListLLM
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import (
AIMessage,
AnyMessage,
BaseMessage,
HumanMessage,
ToolCall,
ToolMessage,
)
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnableConfig, RunnableMap, RunnablePick
from langchain_core.tools import tool
from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION
@@ -484,9 +499,6 @@ def test_conditional_state_graph(
snapshot: SnapshotAssertion,
sync_checkpointer: BaseCheckpointSaver,
) -> None:
from langchain_core.language_models.fake import FakeStreamingListLLM
from langchain_core.prompts import PromptTemplate
from langchain_core.tools import tool
class AgentState(TypedDict, total=False):
input: Annotated[str, UntrackedValue]
@@ -1261,8 +1273,6 @@ def test_conditional_state_graph(
def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import tool
@tool()
def search_api(query: str) -> str:
@@ -1626,17 +1636,6 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
def test_state_graph_packets(
sync_checkpointer: BaseCheckpointSaver, mocker: MockerFixture
) -> None:
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
ToolCall,
ToolMessage,
)
from langchain_core.tools import tool
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
@@ -2381,15 +2380,6 @@ def test_message_graph(
deterministic_uuids: MockerFixture,
sync_checkpointer: BaseCheckpointSaver,
) -> None:
from copy import deepcopy
from langchain_core.callbacks import CallbackManagerForLLMRun
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool
class FakeFunctionChatModel(FakeMessagesListChatModel):
def bind_functions(self, functions: list):
@@ -3099,20 +3089,6 @@ def test_root_graph(
deterministic_uuids: MockerFixture,
sync_checkpointer: BaseCheckpointSaver,
) -> None:
from copy import deepcopy
from langchain_core.callbacks import CallbackManagerForLLMRun
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
ToolMessage,
)
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool
class FakeFunctionChatModel(FakeMessagesListChatModel):
def bind_functions(self, functions: list):
@@ -5837,7 +5813,6 @@ def test_send_to_nested_graphs(sync_checkpointer: BaseCheckpointSaver) -> None:
def test_send_react_interrupt(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage
ai_message = AIMessage(
"",
@@ -6228,7 +6203,6 @@ def test_send_react_interrupt(
def test_send_react_interrupt_control(
sync_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion
) -> None:
from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage
ai_message = AIMessage(
"",
@@ -6455,9 +6429,6 @@ def test_send_react_interrupt_control(
def test_weather_subgraph(
sync_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion
) -> None:
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
# setup subgraph
+16 -29
View File
@@ -9,8 +9,22 @@ from typing import (
)
import pytest
from langchain_core.messages import AnyMessage, ToolCall
from langchain_core.agents import AgentAction, AgentFinish
from langchain_core.language_models.fake import FakeStreamingListLLM
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import (
AIMessage,
AnyMessage,
BaseMessage,
HumanMessage,
ToolCall,
ToolMessage,
)
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnableConfig, RunnablePick
from langchain_core.tools import tool
from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.prebuilt.chat_agent_executor import create_react_agent
@@ -22,6 +36,7 @@ from langgraph._internal._constants import PULL, PUSH
from langgraph.channels.last_value import LastValue
from langgraph.channels.untracked_value import UntrackedValue
from langgraph.constants import END, START
from langgraph.graph import MessagesState
from langgraph.graph.message import add_messages
from langgraph.graph.state import StateGraph
from langgraph.pregel import NodeBuilder, Pregel
@@ -479,10 +494,6 @@ async def test_fork_always_re_runs_nodes(
async def test_conditional_graph_state(async_checkpointer: BaseCheckpointSaver) -> None:
from langchain_core.agents import AgentAction, AgentFinish
from langchain_core.language_models.fake import FakeStreamingListLLM
from langchain_core.prompts import PromptTemplate
from langchain_core.tools import tool
class AgentState(TypedDict):
input: Annotated[str, UntrackedValue]
@@ -1017,8 +1028,6 @@ async def test_conditional_graph_state(async_checkpointer: BaseCheckpointSaver)
async def test_prebuilt_tool_chat() -> None:
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import tool
model = FakeChatModel(
messages=[
@@ -1358,16 +1367,6 @@ async def test_prebuilt_tool_chat() -> None:
async def test_state_graph_packets(async_checkpointer: BaseCheckpointSaver) -> None:
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
ToolMessage,
)
from langchain_core.tools import tool
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
@@ -2072,11 +2071,6 @@ async def test_state_graph_packets(async_checkpointer: BaseCheckpointSaver) -> N
async def test_message_graph(async_checkpointer: BaseCheckpointSaver) -> None:
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import tool
class FakeFunctionChatModel(FakeMessagesListChatModel):
def bind_functions(self, functions: list):
@@ -3537,13 +3531,6 @@ async def test_send_to_nested_graphs(async_checkpointer: BaseCheckpointSaver) ->
async def test_weather_subgraph(
async_checkpointer: BaseCheckpointSaver,
) -> None:
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import AIMessage, ToolCall
from langchain_core.tools import tool
from langgraph.graph import MessagesState
# setup subgraph
+18 -27
View File
@@ -4,25 +4,39 @@ import gc
import json
import logging
import operator
import random
import threading
import time
import uuid
from collections import Counter, deque
from collections import Counter, defaultdict, deque
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from random import randrange
from typing import Annotated, Any, Literal, get_type_hints
from unittest.mock import patch
import pytest
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage
from langchain_core.language_models.fake import FakeStreamingListLLM
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import (
AIMessage,
AnyMessage,
BaseMessage,
HumanMessage,
RemoveMessage,
)
from langchain_core.prompts import ChatPromptTemplate, PromptTemplate
from langchain_core.runnables import (
RunnableConfig,
RunnableLambda,
RunnablePassthrough,
)
from langchain_core.runnables.graph import Edge
from langchain_core.tools import tool
from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION
from langgraph.cache.base import BaseCache
from langgraph.checkpoint.base import (
@@ -56,8 +70,9 @@ from langgraph.pregel import (
NodeBuilder,
Pregel,
)
from langgraph.pregel._loop import SyncPregelLoop
from langgraph.pregel._loop import PregelLoop, SyncPregelLoop
from langgraph.pregel._runner import PregelRunner
from langgraph.runtime import RunControl
from langgraph.types import (
CachePolicy,
Command,
@@ -125,7 +140,6 @@ def test_graph_validation() -> None:
def test_request_drain_allows_inflight_call_scheduling(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
from langgraph.runtime import RunControl
@task
def child(x: int) -> int:
@@ -1769,9 +1783,6 @@ def test_conditional_state_graph_with_list_edge_inputs(snapshot: SnapshotAsserti
def test_state_graph_w_config_inherited_state_keys(snapshot: SnapshotAssertion) -> None:
from langchain_core.language_models.fake import FakeStreamingListLLM
from langchain_core.prompts import PromptTemplate
from langchain_core.tools import tool
class BaseState(TypedDict):
input: str
@@ -3769,12 +3780,6 @@ def test_checkpoint_metadata(sync_checkpointer: BaseCheckpointSaver) -> None:
previous checkpoint config for each step in the run.
"""
# set up test
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import AIMessage, AnyMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.tools import tool
# graph state
class BaseState(TypedDict):
@@ -3940,7 +3945,6 @@ def test_checkpoint_metadata(sync_checkpointer: BaseCheckpointSaver) -> None:
def test_remove_message_via_state_update(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type]
workflow.add_node(
@@ -3973,7 +3977,6 @@ def test_remove_message_via_state_update(
def test_remove_message_from_node():
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
workflow = StateGraph(state_schema=Annotated[list[AnyMessage], add_messages]) # type: ignore[arg-type]
workflow.add_node(
@@ -3999,7 +4002,6 @@ def test_remove_message_from_node():
def test_xray_lance(snapshot: SnapshotAssertion):
from langchain_core.messages import AnyMessage, HumanMessage
class Analyst(BaseModel):
affiliation: str = Field(
@@ -4483,7 +4485,6 @@ def test_debug_subgraphs(
def test_debug_nested_subgraphs(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
):
from collections import defaultdict
class State(TypedDict):
messages: Annotated[list[str], operator.add]
@@ -4743,8 +4744,6 @@ def test_runnable_passthrough_node_graph() -> None:
def test_parent_command(
sync_checkpointer: BaseCheckpointSaver, subgraph_persist: bool
) -> None:
from langchain_core.messages import BaseMessage
from langchain_core.tools import tool
@tool(return_direct=True)
def get_user_name() -> Command:
@@ -5164,7 +5163,6 @@ def test_command_with_static_breakpoints(
def test_multistep_plan(sync_checkpointer: BaseCheckpointSaver):
from langchain_core.messages import AnyMessage
class State(TypedDict, total=False):
plan: list[str | list[str]]
@@ -5910,9 +5908,6 @@ def test_no_redundant_put_writes_for_cached_task(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Cached @tasks on resume must not trigger redundant put_writes."""
from unittest.mock import patch
from langgraph.pregel._loop import PregelLoop
@task
def setup(x: int) -> int:
@@ -6975,7 +6970,6 @@ def test_configurable_propagates_to_stream_metadata() -> None:
def test_stream_mode_messages_command() -> None:
from langchain_core.messages import HumanMessage
def my_node(state):
return {"messages": HumanMessage(content="foo")}
@@ -7243,7 +7237,6 @@ def test_get_stream_writer() -> None:
def test_stream_messages_dedupe_inputs() -> None:
from langchain_core.messages import AIMessage
def call_model(state):
return {"messages": AIMessage("hi", id="1")}
@@ -7281,7 +7274,6 @@ def test_stream_messages_dedupe_inputs() -> None:
def test_stream_messages_dedupe_state(sync_checkpointer: BaseCheckpointSaver) -> None:
from langchain_core.messages import AIMessage
to_emit = [AIMessage("bye", id="1"), AIMessage("bye again", id="2")]
@@ -8253,7 +8245,6 @@ def test_get_graph_loop(snapshot: SnapshotAssertion) -> None:
def test_get_graph_self_loop(snapshot: SnapshotAssertion) -> None:
import random
subgraph_builder = StateGraph(MessagesState)
subgraph_builder.add_node("agent", lambda x: x)
+18 -29
View File
@@ -7,7 +7,7 @@ import operator
import random
import sys
import uuid
from collections import Counter, deque
from collections import Counter, defaultdict, deque
from dataclasses import replace
from time import perf_counter
from typing import (
@@ -21,8 +21,20 @@ from uuid import UUID
import pytest
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.messages import HumanMessage
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import (
AIMessage,
AnyMessage,
BaseMessage,
HumanMessage,
ToolCall,
ToolMessage,
)
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough
from langchain_core.tools import tool
from langchain_core.utils.aiter import aclosing
from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION
from langgraph.cache.base import BaseCache
@@ -45,6 +57,7 @@ from typing_extensions import NotRequired, TypedDict
from langgraph._internal._constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL
from langgraph._internal._queue import AsyncQueue
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.channels.topic import Topic
from langgraph.errors import (
@@ -55,10 +68,11 @@ from langgraph.errors import (
)
from langgraph.func import entrypoint, task
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import MessagesState, add_messages
from langgraph.graph.message import MessagesState, _messages_delta_reducer, add_messages
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.pregel._loop import AsyncPregelLoop
from langgraph.pregel._loop import AsyncPregelLoop, PregelLoop
from langgraph.pregel._runner import PregelRunner
from langgraph.runtime import RunControl
from langgraph.types import (
CachePolicy,
Command,
@@ -222,7 +236,6 @@ async def test_checkpoint_errors() -> None:
async def test_request_drain_allows_inflight_acall_scheduling(
async_checkpointer: BaseCheckpointSaver,
) -> None:
from langgraph.runtime import RunControl
@task
async def child(x: int) -> int:
@@ -2868,7 +2881,6 @@ async def test_send_dedupe_on_resume(
async def test_send_react_interrupt(async_checkpointer: BaseCheckpointSaver) -> None:
from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage
ai_message = AIMessage(
"",
@@ -3259,7 +3271,6 @@ async def test_send_react_interrupt(async_checkpointer: BaseCheckpointSaver) ->
async def test_send_react_interrupt_control(
async_checkpointer: BaseCheckpointSaver, snapshot: SnapshotAssertion
) -> None:
from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage
ai_message = AIMessage(
"",
@@ -5538,12 +5549,6 @@ async def test_checkpoint_metadata(async_checkpointer: BaseCheckpointSaver) -> N
previous checkpoint config for each step in the run.
"""
# set up test
from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel,
)
from langchain_core.messages import AIMessage, AnyMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.tools import tool
# graph state
class BaseState(TypedDict):
@@ -5944,7 +5949,6 @@ async def test_debug_subgraphs(
async def test_debug_nested_subgraphs(
async_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
from collections import defaultdict
class State(TypedDict):
messages: Annotated[list[str], operator.add]
@@ -6061,8 +6065,6 @@ async def test_debug_nested_subgraphs(
async def test_parent_command(
async_checkpointer: BaseCheckpointSaver, subgraph_persist: bool
) -> None:
from langchain_core.messages import BaseMessage
from langchain_core.tools import tool
@tool(return_direct=True)
def get_user_name() -> Command:
@@ -6130,10 +6132,6 @@ async def test_parent_command(
async def test_delta_channel_durability_exit_stores_snapshot_async() -> None:
"""DeltaChannel must reload from an async durability='exit' checkpoint."""
from langchain_core.messages import AIMessage
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
@@ -6420,7 +6418,6 @@ async def test_command_with_static_breakpoints(
async def test_multistep_plan(async_checkpointer: BaseCheckpointSaver) -> None:
from langchain_core.messages import AnyMessage
class State(TypedDict, total=False):
plan: list[str | list[str]]
@@ -6758,7 +6755,6 @@ async def test_multiple_interrupts_functional(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test multiple interrupts with functional API."""
from langgraph.func import entrypoint, task
counter = 0
@@ -7674,7 +7670,6 @@ async def test_configurable_propagates_to_stream_metadata() -> None:
async def test_stream_mode_messages_command() -> None:
from langchain_core.messages import HumanMessage
async def my_node(state):
return {"messages": HumanMessage(content="foo")}
@@ -7723,7 +7718,6 @@ async def test_stream_mode_messages_command() -> None:
async def test_stream_messages_dedupe_inputs() -> None:
from langchain_core.messages import AIMessage
async def call_model(state):
return {"messages": AIMessage("hi", id="1")}
@@ -7763,7 +7757,6 @@ async def test_stream_messages_dedupe_inputs() -> None:
async def test_stream_messages_dedupe_state(
async_checkpointer: BaseCheckpointSaver,
) -> None:
from langchain_core.messages import AIMessage
to_emit = [AIMessage("bye", id="1"), AIMessage("bye again", id="2")]
@@ -8142,9 +8135,6 @@ async def test_no_redundant_put_writes_for_cached_task(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Cached @tasks on resume must not trigger redundant put_writes."""
from unittest.mock import patch
from langgraph.pregel._loop import PregelLoop
@task
async def setup(x: int) -> int:
@@ -8646,7 +8636,6 @@ async def test_batch_update_as_input(
async def test_draw_invalid():
from langchain_core.messages import BaseMessage
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
+3 -4
View File
@@ -4,10 +4,13 @@ import ipaddress
import pathlib
import re
import sys
import typing
import uuid
from enum import Enum
from typing import Annotated, Literal, Optional
import pydantic
import typing_extensions
from langgraph.checkpoint.base import BaseCheckpointSaver
from pydantic import (
BaseModel,
@@ -32,10 +35,6 @@ from tests.any_str import AnyStr
def test_is_supported_by_pydantic() -> None:
"""Test if types are supported by pydantic."""
import typing
import pydantic
import typing_extensions
class TypedDictExtensions(typing_extensions.TypedDict):
x: int
+3 -5
View File
@@ -10,12 +10,14 @@ from langchain_core.messages import AnyMessage, BaseMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.graph import Edge as DrawableEdge
from langchain_core.runnables.graph import Node as DrawableNode
from langgraph.checkpoint.memory import InMemorySaver
from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.schema import StreamPart
from pydantic import BaseModel
from typing_extensions import TypedDict
from langgraph.errors import GraphInterrupt
from langgraph.graph import StateGraph, add_messages
from langgraph.graph import END, START, MessagesState, StateGraph, add_messages
from langgraph.pregel import Pregel
from langgraph.pregel.remote import RemoteGraph
from langgraph.types import Interrupt, StateSnapshot
@@ -1097,10 +1099,6 @@ def test_stream_context_base_model():
)
@pytest.mark.anyio
async def test_langgraph_cloud_integration():
from langgraph.checkpoint.memory import InMemorySaver
from langgraph_sdk.client import get_client, get_sync_client
from langgraph.graph import END, START, MessagesState, StateGraph
# create RemotePregel instance
client = get_client(url="http://localhost:8123")
+1 -1
View File
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from langgraph.pregel import remote as remote_mod
from langgraph.pregel._remote_run_stream import (
_AsyncRemoteGraphRunStream,
_ChannelProjection,
@@ -577,7 +578,6 @@ def test_stream_events_v3_strips_checkpoint_keys_from_configurable():
def test_stream_events_v3_merges_tracing_headers_when_distributed_tracing(
monkeypatch,
):
from langgraph.pregel import remote as remote_mod
sync_client = MagicMock()
sync_client.threads.stream.return_value = MagicMock()
+3 -5
View File
@@ -11,7 +11,9 @@ from typing import Annotated, Any
from unittest.mock import Mock, patch
from uuid import uuid4
import httpx
import pytest
import requests
from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, BaseCallbackHandler
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage
@@ -63,6 +65,7 @@ from langgraph.types import (
RetryPolicy,
Send,
TimeoutPolicy,
interrupt,
)
NEEDS_CONTEXTVARS = pytest.mark.skipif(
@@ -171,8 +174,6 @@ def test_checkpoint_ns_for_parent_command() -> None:
def test_should_retry_default_retry_on():
"""Test the default retry_on function."""
import httpx
import requests
# Create a RetryPolicy with default_retry_on
policy = RetryPolicy()
@@ -2198,7 +2199,6 @@ def test_graph_error_handler_does_not_swallow_interrupt_concurrent():
"""When a graph error handler is configured and a node calls interrupt()
concurrently with other nodes, the interrupt must still be raised not
silently swallowed."""
from langgraph.types import interrupt
class State(TypedDict):
foo: str
@@ -2587,8 +2587,6 @@ async def test_set_node_defaults_timeout():
.compile()
)
from langgraph.errors import NodeTimeoutError
with pytest.raises(NodeTimeoutError):
await graph.ainvoke({"foo": ""})
+2 -3
View File
@@ -6,9 +6,11 @@ from typing import Any
import pytest
from langgraph.checkpoint.memory import MemorySaver
from langgraph.store.memory import InMemoryStore
from pydantic import BaseModel, ValidationError
from typing_extensions import TypedDict
from langgraph._internal._constants import CONFIG_KEY_RUNTIME
from langgraph.errors import GraphDrained
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import (
@@ -1177,9 +1179,6 @@ def test_foreign_object_in_runtime_slot_is_coerced() -> None:
`merge` when no per-run `context` is provided. `store` is resolved
separately, so it is not read off the foreign object in the coercion.
"""
from langgraph.store.memory import InMemoryStore
from langgraph._internal._constants import CONFIG_KEY_RUNTIME
store = InMemoryStore()
graph_level_context = {"source": "graph-level"}
+1 -1
View File
@@ -79,7 +79,7 @@ class DummyChannel:
def test_curated_core_allowlist_includes_messages() -> None:
try:
from langchain_core.messages import BaseMessage
from langchain_core.messages import BaseMessage # noqa: PLC0415
except Exception:
pytest.skip("langchain_core not available")
allowlist = curated_core_allowlist()
@@ -13,8 +13,10 @@ import operator
import time
from typing import Annotated, Any
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.config import get_stream_writer
from langgraph.constants import END, START
from langgraph.graph import StateGraph
from langgraph.stream._mux import StreamMux
@@ -488,7 +490,6 @@ class _State(TypedDict):
def _my_node(state: _State) -> dict[str, Any]:
from langgraph.config import get_stream_writer
writer = get_stream_writer()
writer({"status": "working", "node": "my_node"})
@@ -606,7 +607,6 @@ def test_stream_events_v3_all_transformers_interleaved() -> None:
def test_stream_events_v3_all_transformers_with_checkpointer() -> None:
"""All transformers with a checkpointer — run.checkpoints populated."""
from langgraph.checkpoint.memory import InMemorySaver
builder = StateGraph(_State, input_schema=_State)
builder.add_node("my_node", _my_node)
@@ -645,7 +645,6 @@ def test_stream_events_v3_all_transformers_with_checkpointer() -> None:
def test_stream_events_v3_checkpoints_projection_opt_in() -> None:
"""run.checkpoints surfaces checkpoint data when opted in with a checkpointer."""
from langgraph.checkpoint.memory import InMemorySaver
builder = StateGraph(_State, input_schema=_State)
builder.add_node("my_node", _my_node)
@@ -3,8 +3,10 @@ legacy v1 chunk filtering, and end-to-end via stream_events(version="v3") / astr
from __future__ import annotations
import asyncio
import time
from typing import Any
from uuid import uuid4
import pytest
from langchain_core.language_models import GenericFakeChatModel
@@ -13,11 +15,13 @@ from langchain_core.language_models.chat_model_stream import (
ChatModelStream,
)
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
from langchain_core.runnables import RunnableConfig
from typing_extensions import TypedDict
from langgraph.constants import END, START
from langgraph.graph import MessagesState, StateGraph
from langgraph.pregel._messages import StreamMessagesHandlerV2
from langgraph.stream._mux import StreamMux
from langgraph.stream.run_stream import GraphRunStream
from langgraph.stream.stream_channel import StreamChannel
@@ -607,7 +611,6 @@ class TestEndToEnd:
@pytest.mark.anyio
async def test_nested_async_iteration_yields_text_deltas(self) -> None:
"""Inner stream.text drives the shared graph pump via the async pump binding."""
import asyncio
model = GenericFakeChatModel(messages=iter(["hello world"]))
@@ -870,11 +873,6 @@ class TestDirectMessagesModeStaysV1:
class TestStreamMessagesHandlerV2Unit:
def test_on_llm_new_token_is_noop(self) -> None:
"""v2 handler must not emit v1 chunks even when on_llm_new_token fires."""
from uuid import uuid4
from langchain_core.outputs import ChatGenerationChunk
from langgraph.pregel._messages import StreamMessagesHandlerV2
emitted: list[Any] = []
handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False)
@@ -890,9 +888,6 @@ class TestStreamMessagesHandlerV2Unit:
assert emitted == []
def test_on_chain_end_does_not_emit_tool_messages(self) -> None:
from uuid import uuid4
from langgraph.pregel._messages import StreamMessagesHandlerV2
emitted: list[Any] = []
handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False)
@@ -909,11 +904,6 @@ class TestStreamMessagesHandlerV2Unit:
def test_on_llm_end_dedupes_when_final_message_id_differs(self) -> None:
"""A streamed v2 message should not be emitted again from the final
AIMessage fallback when its final id does not match `message-start`."""
from uuid import uuid4
from langchain_core.outputs import ChatGeneration, LLMResult
from langgraph.pregel._messages import StreamMessagesHandlerV2
emitted: list[Any] = []
handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False)
+4 -10
View File
@@ -2,6 +2,7 @@ import functools
import sys
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from typing import (
Annotated,
Any,
@@ -17,7 +18,10 @@ import langsmith
import pytest
from langchain_core.callbacks import BaseCallbackHandler, CallbackManager
from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.config import var_child_runnable_config
from langchain_core.tracers import LangChainTracer
from langsmith import get_current_run_tree # type: ignore
from pydantic import BaseModel, Field
from typing_extensions import NotRequired, Required, TypedDict
from langgraph._internal._config import (
@@ -118,7 +122,6 @@ def rt_graph() -> CompiledStateGraph:
node_run_id: int
def node(_: State):
from langsmith import get_current_run_tree # type: ignore
return {"node_run_id": get_current_run_tree().id} # type: ignore
@@ -243,10 +246,6 @@ def test_is_required():
def test_enhanced_type_hints() -> None:
from dataclasses import dataclass
from typing import Annotated
from pydantic import BaseModel, Field
class MyTypedDict(TypedDict):
val_1: str
@@ -510,7 +509,6 @@ def test_ensure_config_explicit_configurable_replaces_ambient() -> None:
# An explicit checkpoint coordinate (here a new thread_id) starts a fresh
# lineage and drops the ambient run context (e.g. a parent task's
# checkpoint_ns), so a child graph does not inherit it.
from langchain_core.runnables.config import var_child_runnable_config
token = var_child_runnable_config.set(
{"configurable": {"checkpoint_ns": "p:parent-task", "checkpoint_id": "cid"}}
@@ -527,7 +525,6 @@ def test_ensure_config_explicit_configurable_replaces_ambient() -> None:
def test_ensure_config_ambient_inherited_when_no_explicit_configurable() -> None:
# With no explicit configurable, the ambient run context is inherited
# unchanged (stateless subgraph / interrupt-resume pattern).
from langchain_core.runnables.config import var_child_runnable_config
token = var_child_runnable_config.set(
{"configurable": {"checkpoint_ns": "p:parent-task"}}
@@ -543,7 +540,6 @@ def test_ensure_config_explicit_configurables_still_merge_over_ambient() -> None
# A new thread_id drops the ambient, but explicit configs still shallow-merge
# among themselves, so a with_config(...) value (ls_agent_type) survives
# alongside an invoke-time thread_id.
from langchain_core.runnables.config import var_child_runnable_config
token = var_child_runnable_config.set(
{"configurable": {"checkpoint_ns": "p:parent-task"}}
@@ -564,7 +560,6 @@ def test_ensure_config_non_coordinate_config_keeps_ambient_checkpoint_ns() -> No
# A nested subagent is invoked with a non-coordinate configurable key
# (ls_agent_type) and no thread_id; it must keep the inherited checkpoint_ns
# so it stays a discoverable child of the parent run (deepagents `task` tool).
from langchain_core.runnables.config import var_child_runnable_config
token = var_child_runnable_config.set(
{"configurable": {"thread_id": "parent", "checkpoint_ns": "p:parent-task"}}
@@ -582,7 +577,6 @@ def test_ensure_config_same_thread_id_still_clears_ambient() -> None:
# A child that reuses the parent's thread_id is still addressing its own root
# namespace on that thread, so the parent task's checkpoint_ns must not leak
# in; otherwise the child writes state that get_state cannot read back.
from langchain_core.runnables.config import var_child_runnable_config
token = var_child_runnable_config.set(
{"configurable": {"thread_id": "shared", "checkpoint_ns": "p:parent-task"}}
+4
View File
@@ -1692,6 +1692,7 @@ dev = [
{ name = "anyio" },
{ name = "codespell" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "psycopg", extras = ["binary"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -1708,6 +1709,7 @@ lint = [
test = [
{ name = "anyio" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "psycopg", extras = ["binary"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -1736,6 +1738,7 @@ requires-dist = [
dev = [
{ name = "codespell" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -1751,6 +1754,7 @@ lint = [
]
test = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -87,8 +87,8 @@ from langgraph._internal._runnable import RunnableCallable
from langgraph.errors import GraphBubbleUp
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.pregel._tools import _tool_call_writer
from langgraph.runtime import ExecutionInfo, ServerInfo # noqa: TC002
from langgraph.store.base import BaseStore # noqa: TC002
from langgraph.runtime import ExecutionInfo, ServerInfo
from langgraph.store.base import BaseStore
from langgraph.types import Command, Send, StreamWriter
from pydantic import BaseModel, ValidationError
from typing_extensions import TypeVar, Unpack
@@ -332,7 +332,7 @@ def msg_content_output(output: Any) -> str | list[dict]:
# any existing ToolNode usage.
try:
return json.dumps(output, ensure_ascii=False)
except Exception: # noqa: BLE001
except Exception:
return str(output)
@@ -736,7 +736,7 @@ class ToolNode(RunnableCallable):
tool_node = ToolNode([my_tool], handle_tool_errors=handle_errors)
```
""" # noqa: E501
"""
name: str = "tools"
+5 -1
View File
@@ -75,8 +75,12 @@ addopts = "--strict-markers --strict-config --durations=5 -vv"
asyncio_mode = "auto"
[tool.ruff]
lint.select = [ "E", "F", "I", "TID251", "UP" ]
lint.select = [ "E", "F", "I", "PLC0415", "RUF100", "TID251", "UP" ]
lint.ignore = [ "E501" ]
# PLC0415 (import-outside-top-level) is enforced in tests only. Library code
# still has deferred imports that have not been reviewed, so it stays exempt
# for now.
lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] }
target-version = "py310"
[tool.ty.rules]
+1 -2
View File
@@ -1,5 +1,6 @@
import os
import tempfile
import time
from collections import defaultdict
from functools import partial
@@ -38,8 +39,6 @@ class MemorySaverAssertImmutable(InMemorySaver):
new_versions: ChannelVersions,
) -> None:
if self.put_sleep:
import time
time.sleep(self.put_sleep)
# assert checkpoint hasn't been modified since last written
thread_id = config["configurable"]["thread_id"]
@@ -9,16 +9,19 @@ handle missing fields by injecting None instead of raising KeyError.
import sys
from typing import Annotated
from unittest.mock import Mock
import pytest
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.graph.message import add_messages
from langgraph.runtime import Runtime
from pydantic import BaseModel, Field
from typing_extensions import NotRequired
from langgraph.prebuilt import InjectedState, ToolNode, create_react_agent
from langgraph.prebuilt.chat_agent_executor import AgentState
from langgraph.prebuilt.tool_node import ToolRuntime
from .model import FakeToolCallingModel
@@ -50,9 +53,6 @@ def _create_mock_runtime(
store=None,
):
"""Create a mock Runtime for testing ToolNode directly."""
from unittest.mock import Mock
from langgraph.runtime import Runtime
mock_runtime = Mock(spec=Runtime)
mock_runtime.context = {}
@@ -61,7 +61,6 @@ def _create_mock_runtime(
def _create_config_with_runtime(store=None, state=None):
"""Create a RunnableConfig with mocked runtime for direct ToolNode testing."""
from langgraph.prebuilt.tool_node import ToolRuntime
tool_runtime = ToolRuntime(
state=state or {},
+2 -2
View File
@@ -1,5 +1,6 @@
"""Unit tests for tool call interceptor in ToolNode."""
import functools
from collections.abc import Callable
from unittest.mock import Mock
@@ -1331,14 +1332,13 @@ def _config_with_channel_read(
learn channel names. The stub matches the shape: partial whose second and
third positional args are `channels` and `managed` mappings.
"""
import functools
channels_stub = {k: None for k in channel_values}
managed_stub: dict[str, object] = {}
# Shape matches pregel's real partial:
# functools.partial(local_read, scratchpad, channels, managed, task)
def _read(scratchpad, channels, managed, task, select, fresh): # noqa: ARG001
def _read(scratchpad, channels, managed, task, select, fresh):
if isinstance(select, str):
return channel_values[select]
return {k: channel_values[k] for k in select if k in channel_values}
+4 -10
View File
@@ -2,6 +2,7 @@ import contextlib
import dataclasses
import json
import sys
import warnings
from functools import partial
from typing import (
Annotated,
@@ -23,10 +24,12 @@ from langchain_core.messages import (
from langchain_core.runnables.config import RunnableConfig
from langchain_core.tools import BaseTool, InjectedToolArg, ToolException
from langchain_core.tools import tool as dec_tool
from langchain_core.tools.base import InjectedToolCallId
from langgraph.config import get_stream_writer
from langgraph.errors import GraphBubbleUp, GraphInterrupt
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages
from langgraph.runtime import ExecutionInfo, ServerInfo
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.types import Command, Send
@@ -41,6 +44,7 @@ from langgraph.prebuilt import (
)
from langgraph.prebuilt.tool_node import (
TOOL_CALL_ERROR_TEMPLATE,
ToolCallRequest,
ToolInvocationError,
ToolRuntime,
tools_condition,
@@ -59,7 +63,6 @@ def _create_mock_runtime(store: BaseStore | None = None) -> Mock:
which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"].
When testing ToolNode directly (outside a graph), we need to provide this manually.
"""
from langgraph.runtime import ExecutionInfo
mock_runtime = Mock()
mock_runtime.store = store
@@ -625,7 +628,6 @@ def test_tool_node_node_interrupt() -> None:
@pytest.mark.parametrize("input_type", ["dict", "tool_calls"])
async def test_tool_node_command(input_type: str) -> None:
from langchain_core.tools.base import InjectedToolCallId
@dec_tool
def transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]):
@@ -934,7 +936,6 @@ async def test_tool_node_command(input_type: str) -> None:
async def test_tool_node_command_list_input() -> None:
from langchain_core.tools.base import InjectedToolCallId
@dec_tool
def transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]):
@@ -1194,7 +1195,6 @@ async def test_tool_node_command_list_input() -> None:
def test_tool_node_parent_command_with_send() -> None:
from langchain_core.tools.base import InjectedToolCallId
@dec_tool
def transfer_to_alice(tool_call_id: Annotated[str, InjectedToolCallId]):
@@ -1282,7 +1282,6 @@ def test_tool_node_parent_command_with_send() -> None:
async def test_tool_node_command_remove_all_messages() -> None:
from langchain_core.tools.base import InjectedToolCallId
@dec_tool
def remove_all_messages_tool(tool_call_id: Annotated[str, InjectedToolCallId]):
@@ -1621,9 +1620,6 @@ def test_tool_node_stream_writer() -> None:
def test_tool_call_request_setattr_deprecation_warning():
"""Test that ToolCallRequest raises a deprecation warning on direct attribute modification."""
import warnings
from langgraph.prebuilt.tool_node import ToolCallRequest
# Create a mock ToolCall
tool_call = {"name": "test", "args": {"a": 1}, "id": "call_1", "type": "tool_call"}
@@ -2031,7 +2027,6 @@ def test_tool_runtime_defaults_tools_to_empty_list() -> None:
def test_tool_runtime_forwards_execution_info_server_info_and_tools() -> None:
"""Test that execution_info, server_info, and tools are forwarded from Runtime to ToolRuntime."""
from langgraph.runtime import ExecutionInfo, ServerInfo
exec_info = ExecutionInfo(
thread_id="t-1",
@@ -2088,7 +2083,6 @@ async def test_tool_runtime_forwards_execution_info_server_info_and_tools_async(
None
):
"""Test that execution_info, server_info, and tools are forwarded in async path."""
from langgraph.runtime import ExecutionInfo, ServerInfo
exec_info = ExecutionInfo(
thread_id="t-2",
+5 -1
View File
@@ -168,7 +168,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -439,6 +439,7 @@ dev = [
{ name = "anyio" },
{ name = "codespell" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "psycopg", extras = ["binary"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -455,6 +456,7 @@ lint = [
test = [
{ name = "anyio" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "psycopg", extras = ["binary"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -483,6 +485,7 @@ requires-dist = [
dev = [
{ name = "codespell" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -498,6 +501,7 @@ lint = [
]
test = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
+5 -1
View File
@@ -80,6 +80,7 @@ select = [
"SIM", # flake8-simplify (code simplification)
"RUF", # ruff-specific rules
"S101", # flake8-bandit: use of assert
"PLC0415", # import-outside-top-level
]
ignore = [
"E501", # line too long (handled by formatter)
@@ -87,7 +88,10 @@ ignore = [
"B904", # raise without from inside except (sometimes intentional)
"SIM102", # nested if statements (sometimes clearer)
]
per-file-ignores = { "tests/**" = ["S101", "B017"], "integration/**" = ["S101", "T20", "B017", "ARG001", "ARG002"] }
# PLC0415 (import-outside-top-level) is enforced in tests only. Library code
# still has deferred imports that have not been reviewed, so it stays exempt
# for now.
per-file-ignores = { "tests/**" = ["S101", "B017"], "integration/**" = ["S101", "T20", "B017", "ARG001", "ARG002", "PLC0415"], "langgraph_sdk/**" = ["PLC0415"] }
[tool.ty.src]
# The `integration/` graphs run inside the docker image (with `deepagents`
+5 -4
View File
@@ -18,6 +18,11 @@ from collections.abc import AsyncIterator, Iterator
import httpx
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.threads import SyncThreadsClient
BASE_URL = os.environ.get("LANGGRAPH_INTEGRATION_URL", "http://localhost:2024")
ASSISTANT_ID = "agent"
TOOLS_ASSISTANT_ID = "tools_agent"
@@ -47,8 +52,6 @@ def _require_running_api() -> None:
@pytest.fixture
async def async_threads() -> AsyncIterator[tuple[object, httpx.AsyncClient]]:
"""Build an async ThreadsClient. Yields `(threads, raw_httpx)` so tests can close raw."""
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
raw = httpx.AsyncClient(base_url=BASE_URL, timeout=30.0)
try:
@@ -60,8 +63,6 @@ async def async_threads() -> AsyncIterator[tuple[object, httpx.AsyncClient]]:
@pytest.fixture
def sync_threads() -> Iterator[tuple[object, httpx.Client]]:
"""Build a sync ThreadsClient. Yields `(threads, raw_httpx)` so tests can close raw."""
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.threads import SyncThreadsClient
raw = httpx.Client(base_url=BASE_URL, timeout=30.0)
try:
@@ -9,21 +9,22 @@ from __future__ import annotations
import pytest
from langgraph_sdk._async.assistants import AssistantsClient
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._sync.assistants import SyncAssistantsClient
from langgraph_sdk._sync.http import SyncHttpClient
from .conftest import ASSISTANT_ID
pytestmark = pytest.mark.integration
def _async_assistants(raw):
from langgraph_sdk._async.assistants import AssistantsClient
from langgraph_sdk._async.http import HttpClient
return AssistantsClient(HttpClient(raw))
def _sync_assistants(raw):
from langgraph_sdk._sync.assistants import SyncAssistantsClient
from langgraph_sdk._sync.http import SyncHttpClient
return SyncAssistantsClient(SyncHttpClient(raw))
+5 -4
View File
@@ -10,6 +10,11 @@ from typing import Any
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.runs import RunsClient
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.runs import SyncRunsClient
from .conftest import ASSISTANT_ID
pytestmark = pytest.mark.integration
@@ -29,8 +34,6 @@ async def _cancel_after_first_event(
async def test_cancel_async(async_threads) -> None:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.runs import RunsClient
threads, raw = async_threads
runs_client = RunsClient(HttpClient(raw))
@@ -88,8 +91,6 @@ def _cancel_after_first_event_sync(
def test_cancel_sync(sync_threads) -> None:
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.runs import SyncRunsClient
threads, raw = sync_threads
runs_client = SyncRunsClient(SyncHttpClient(raw))
+5 -4
View File
@@ -10,21 +10,22 @@ from __future__ import annotations
import pytest
from langgraph_sdk._async.cron import CronClient
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._sync.cron import SyncCronClient
from langgraph_sdk._sync.http import SyncHttpClient
from .conftest import ASSISTANT_ID
pytestmark = pytest.mark.integration
def _async_crons(raw):
from langgraph_sdk._async.cron import CronClient
from langgraph_sdk._async.http import HttpClient
return CronClient(HttpClient(raw))
def _sync_crons(raw):
from langgraph_sdk._sync.cron import SyncCronClient
from langgraph_sdk._sync.http import SyncHttpClient
return SyncCronClient(SyncHttpClient(raw))
@@ -13,21 +13,22 @@ from __future__ import annotations
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.runs import RunsClient
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.runs import SyncRunsClient
from .conftest import FACTORY_ASSISTANT_ID
pytestmark = pytest.mark.integration
def _async_runs(raw):
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.runs import RunsClient
return RunsClient(HttpClient(raw))
def _sync_runs(raw):
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.runs import SyncRunsClient
return SyncRunsClient(SyncHttpClient(raw))
+5 -4
View File
@@ -11,21 +11,22 @@ from __future__ import annotations
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.runs import RunsClient
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.runs import SyncRunsClient
from .conftest import ASSISTANT_ID
pytestmark = pytest.mark.integration
def _async_runs(raw):
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.runs import RunsClient
return RunsClient(HttpClient(raw))
def _sync_runs(raw):
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.runs import SyncRunsClient
return SyncRunsClient(SyncHttpClient(raw))
+5 -4
View File
@@ -10,19 +10,20 @@ import uuid
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.store import StoreClient
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.store import SyncStoreClient
pytestmark = pytest.mark.integration
def _async_store(raw):
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.store import StoreClient
return StoreClient(HttpClient(raw))
def _sync_store(raw):
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.store import SyncStoreClient
return SyncStoreClient(SyncHttpClient(raw))
@@ -4,6 +4,11 @@ from __future__ import annotations
import pytest
from langgraph_sdk.stream.transport import (
ProtocolWebSocketTransport,
SyncProtocolWebSocketTransport,
)
from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS
pytestmark = pytest.mark.integration
@@ -14,8 +19,6 @@ async def test_websocket_async(async_threads) -> None:
async with threads.stream(
assistant_id=ASSISTANT_ID, transport="websocket"
) as thread:
from langgraph_sdk.stream.transport import ProtocolWebSocketTransport
assert isinstance(thread._transport, ProtocolWebSocketTransport)
await thread.run.start(input={"messages": [], "value": "init", "items": []})
@@ -34,8 +37,6 @@ async def test_websocket_async(async_threads) -> None:
def test_websocket_sync(sync_threads) -> None:
threads, _ = sync_threads
with threads.stream(assistant_id=ASSISTANT_ID, transport="websocket") as thread:
from langgraph_sdk.stream.transport import SyncProtocolWebSocketTransport
assert isinstance(thread._transport, SyncProtocolWebSocketTransport)
thread.run.start(input={"messages": [], "value": "init", "items": []})
+9 -15
View File
@@ -3,14 +3,21 @@
from __future__ import annotations
import asyncio
import asyncio as _asyncio
import logging
from collections.abc import AsyncIterator
from typing import Any
from unittest.mock import AsyncMock
import httpx
import pytest
from langgraph_sdk.stream.controller import StreamController, _SeenEventIds
from langgraph_sdk.stream.transport.http import EventStreamHandle
from langgraph_sdk.stream.controller import (
StreamController,
_close_after,
_SeenEventIds,
)
from langgraph_sdk.stream.transport.http import EventStreamHandle, ProtocolSseTransport
# ---------------------------------------------------------------------------
# Task 3.1: bounded subscription queues
@@ -20,9 +27,6 @@ from langgraph_sdk.stream.transport.http import EventStreamHandle
@pytest.mark.asyncio
async def test_subscription_queue_bounded_by_max_queue_size():
"""`StreamController` must create per-subscription queues bounded by `max_queue_size`."""
import httpx
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
transport = ProtocolSseTransport(
client=httpx.AsyncClient(base_url="http://test"),
@@ -36,9 +40,6 @@ async def test_subscription_queue_bounded_by_max_queue_size():
@pytest.mark.asyncio
async def test_subscription_queue_default_max_queue_size_is_1024():
"""`StreamController` default `max_queue_size` is 1024."""
import httpx
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
transport = ProtocolSseTransport(
client=httpx.AsyncClient(base_url="http://test"),
@@ -114,12 +115,6 @@ def test_seen_event_ids_iter_returns_keys():
async def test_close_awaits_pending_rotation_closes():
"""When a rotation is mid-flight, controller.close() must await the old
stream close before returning."""
import asyncio as _asyncio
import httpx
from langgraph_sdk.stream.controller import _close_after
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
rotation_close_done = _asyncio.Event()
@@ -269,7 +264,6 @@ async def test_reconnect_accepts_backoff_kwargs():
@pytest.mark.anyio
async def test_transport_drop_exception_logged_with_type(monkeypatch, caplog):
"""Bare `pass` discarded exception types; the drop should at least log."""
import logging
monkeypatch.setattr("asyncio.sleep", AsyncMock())
+2 -1
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
from typing import Any
import pytest
from langgraph_sdk.stream.decoders import (
DataDecoder,
ExtensionsDecoder,
@@ -454,7 +456,6 @@ def test_extensions_decoder_ignores_non_dict_data():
def test_extensions_decoder_rejects_empty_name():
import pytest
with pytest.raises(ValueError):
ExtensionsDecoder(name="")
@@ -3,6 +3,7 @@ from __future__ import annotations
import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.stream import ScopedStreamHandle
from langgraph_sdk._async.threads import ThreadsClient
from streaming._events import custom_event, lifecycle_completed_event
from streaming._fake_server import FakeServer
@@ -46,8 +47,6 @@ async def test_extension_projection_supports_namespace_scope_on_subgraph_handle(
)
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.stream import ScopedStreamHandle
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
@@ -7,9 +7,11 @@ import contextlib
from typing import Any
import httpx
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
from langgraph_sdk.stream.transport import EventStreamHandle, ProtocolSseTransport
from streaming._events import (
input_requested_event,
lifecycle_completed_event,
@@ -153,7 +155,6 @@ async def test_lifecycle_clean_eof_resolves_run_done_with_errored():
"""If the lifecycle SSE stream ends cleanly (server closes without a
terminal `completed` or `errored` event), `_run_done` must resolve with
an errored terminal so awaiters don't hang."""
import pytest
fake = FakeServer()
# Emit a non-terminal lifecycle event, then close cleanly without
@@ -179,7 +180,6 @@ async def test_lifecycle_mid_iteration_error_resolves_run_done_with_error(
"""If the transport reports an error via `handle.done` after iteration
exits without a terminal lifecycle event, `_run_done` propagates the
transport error rather than the generic clean-EOF message."""
from langgraph_sdk.stream.transport import EventStreamHandle, ProtocolSseTransport
def synthetic_handle() -> EventStreamHandle:
loop = asyncio.get_running_loop()
@@ -2,12 +2,16 @@
from __future__ import annotations
from unittest.mock import MagicMock
import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.stream import ScopedStreamHandle
from langgraph_sdk._async.threads import ThreadsClient
from streaming._events import (
lifecycle_completed_event,
lifecycle_errored_event,
lifecycle_started_event,
message_finish_event,
message_start_event,
@@ -430,9 +434,6 @@ async def test_grandchild_events_dispatched_to_correct_sibling_not_first_match()
def test_scoped_handle_inboxes_bounded_by_max_queue_size():
"""ScopedStreamHandle with max_queue_size=N creates queues with maxsize=N."""
from unittest.mock import MagicMock
from langgraph_sdk._async.stream import ScopedStreamHandle
fake_thread = MagicMock()
handle = ScopedStreamHandle(
@@ -484,7 +485,6 @@ async def test_force_complete_uses_failed_when_run_errored():
"""If the lifecycle signals an errored run, scoped children that are still
'started' when the subgraphs projection's finally block runs must be
force-finished as 'failed', not 'completed'."""
from streaming._events import lifecycle_errored_event
fake = FakeServer()
fake.script(
@@ -538,9 +538,6 @@ async def test_force_complete_uses_completed_when_run_completed():
def test_close_inboxes_does_not_enqueue_on_uniterated_inboxes():
"""_close_inboxes must not push a sentinel on inboxes that had no consumer."""
from unittest.mock import MagicMock
from langgraph_sdk._async.stream import ScopedStreamHandle
fake_thread = MagicMock()
handle = ScopedStreamHandle(
@@ -559,9 +556,6 @@ def test_close_inboxes_does_not_enqueue_on_uniterated_inboxes():
def test_close_inboxes_enqueues_sentinel_on_iterated_inboxes():
"""_close_inboxes must push a None sentinel only on inboxes that had a consumer,
so projection iterators see the EOF signal."""
from unittest.mock import MagicMock
from langgraph_sdk._async.stream import ScopedStreamHandle
fake_thread = MagicMock()
handle = ScopedStreamHandle(
@@ -3,14 +3,15 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from typing import Any, cast
from unittest.mock import MagicMock
import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
from langgraph_sdk.stream.controller import StreamController
from langgraph_sdk.stream.transport.http import EventStreamHandle
from streaming._events import lifecycle_event, values_event
from langgraph_sdk.stream.transport.http import EventStreamHandle, ProtocolSseTransport
from streaming._events import lifecycle_completed_event, lifecycle_event, values_event
from streaming._fake_server import FakeServer, _StreamScript
@@ -165,7 +166,6 @@ async def test_values_projection_registers_via_delegation_not_controller_directl
directly the subscription count seen through the thread wrapper equals
the count inside the controller at the moment the subscription is live.
"""
from streaming._events import lifecycle_completed_event
fake = FakeServer()
fake.script([lifecycle_completed_event(seq=0)])
@@ -255,10 +255,6 @@ async def test_shared_stream_reconnects_with_since_after_transport_drop():
handle2, _ = _make_handle([values_event(seq=2, values={"counter": 2})])
handles = [handle1, handle2]
from unittest.mock import MagicMock
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
transport = MagicMock(spec=ProtocolSseTransport)
def _open(params: dict[str, Any]) -> EventStreamHandle:
@@ -303,10 +299,6 @@ async def test_shared_stream_reconnect_dedupes_replayed_overlap():
)
handles = [handle1, handle2]
from unittest.mock import MagicMock
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
transport = MagicMock(spec=ProtocolSseTransport)
transport.open_event_stream.side_effect = lambda _params: handles.pop(0)
@@ -3,6 +3,7 @@ from __future__ import annotations
import httpx
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.stream import SyncScopedStreamHandle
from langgraph_sdk._sync.threads import SyncThreadsClient
from streaming._events import custom_event, lifecycle_completed_event
from streaming._sync_fake_server import SyncFakeServer
@@ -41,8 +42,6 @@ def test_sync_extension_projection_supports_namespace_scope_on_subgraph_handle()
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
from langgraph_sdk._sync.stream import SyncScopedStreamHandle
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
@@ -2,6 +2,8 @@
from __future__ import annotations
import time
from collections.abc import Generator
from typing import Any, cast
import httpx
@@ -10,6 +12,7 @@ from langchain_core.language_models.chat_model_stream import ChatModelStream
from langchain_protocol import Event
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.stream import SyncToolCallHandle
from langgraph_sdk._sync.threads import SyncThreadsClient
from streaming._events import (
lifecycle_completed_event,
@@ -459,11 +462,6 @@ def test_sync_tool_calls_explicit_close_does_not_block_1s():
tool_started_event(seq=1, tool_call_id="call-1"),
]
)
import time
from collections.abc import Generator
from typing import cast
from langgraph_sdk._sync.stream import SyncToolCallHandle
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
@@ -528,7 +526,6 @@ def test_sync_tool_call_handle_deltas_queue_is_bounded():
Unbounded queues allow producers to enqueue indefinitely, causing memory
growth when consumers are slow.
"""
from langgraph_sdk._sync.stream import SyncToolCallHandle
handle_default = SyncToolCallHandle(tool_call_id="tc1", name="foo")
assert handle_default._deltas.maxsize > 0, (
@@ -552,7 +549,6 @@ def test_sync_tool_call_handle_deltas_single_consumer_guard():
The property must raise before returning the iterator so the caller
sees the error even without iterating.
"""
from langgraph_sdk._sync.stream import SyncToolCallHandle
handle = SyncToolCallHandle(tool_call_id="tc1", name="foo")
@@ -6,12 +6,14 @@ import threading
from concurrent.futures import ThreadPoolExecutor, wait
import httpx
from langchain_protocol import Event
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.stream import SyncScopedStreamHandle
from langgraph_sdk._sync.threads import SyncThreadsClient
from streaming._events import (
lifecycle_completed_event,
lifecycle_errored_event,
lifecycle_started_event,
message_finish_event,
message_start_event,
@@ -356,7 +358,6 @@ def test_sync_register_descendant_forwards_buffered_events_in_order():
"""_register_descendant must drain already-buffered events whose namespace
matches the new grandchild, push them into the grandchild, and preserve
the original arrival order in the parent inbox."""
from langchain_protocol import Event
parent = SyncScopedStreamHandle(
thread=None, # ty: ignore[invalid-argument-type]
@@ -673,7 +674,6 @@ def test_sync_force_complete_uses_failed_when_run_errored():
"""If the lifecycle signals an errored run, scoped children that are still
'started' when the subgraphs iterator's finally block runs must be
force-finished as 'failed', not 'completed'."""
from streaming._events import lifecycle_errored_event
fake = SyncFakeServer()
fake.script(
@@ -2,21 +2,43 @@
from __future__ import annotations
import queue
import re
import threading
import time
import uuid
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import httpx
import orjson
import pytest
import langgraph_sdk.stream.sync_controller as _ctrl_mod
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.threads import SyncThreadsClient
from langgraph_sdk.stream.sync_controller import SyncStreamController
from langgraph_sdk.stream.transport.sync_http import (
SyncEventStreamHandle,
SyncProtocolSseTransport,
)
from streaming._events import (
checkpoints_event,
custom_event,
lifecycle_completed_event,
lifecycle_event,
lifecycle_started_event,
message_finish_event,
message_start_event,
message_text_delta_event,
message_text_finish_event,
tasks_start_event,
tool_finished_event,
tool_started_event,
updates_event,
values_event,
)
from streaming._sync_fake_server import SyncFakeServer, SyncStreamScript
# ---------------------------------------------------------------------------
@@ -71,14 +93,10 @@ def test_sync_subscribe_before_run_start_waits_on_gate():
def test_sync_reconnect_uses_backoff_between_attempts(monkeypatch):
"""_reconnect_shared_stream sleeps between retry attempts with exp+jitter
backoff, mirroring the async reconnect behavior."""
import langgraph_sdk.stream.sync_controller as _ctrl_mod
sleeps: list[float] = []
monkeypatch.setattr(_ctrl_mod.time, "sleep", lambda d: sleeps.append(d))
from langgraph_sdk.stream.sync_controller import SyncStreamController
from langgraph_sdk.stream.transport.sync_http import SyncProtocolSseTransport
class _FailingTransport(SyncProtocolSseTransport):
"""Transport that always raises on open_event_stream."""
@@ -113,15 +131,6 @@ def test_sync_rotation_does_not_lose_buffered_events():
"""When the shared stream rotates, old-stream events already in the queue
are not dropped. _drain_and_close dispatches remaining events from the
old handle to subscribers before closing it."""
import queue
from typing import Any
from langgraph_sdk.stream.sync_controller import SyncStreamController
from langgraph_sdk.stream.transport.sync_http import (
SyncEventStreamHandle,
SyncProtocolSseTransport,
)
from streaming._events import values_event
event_a = values_event(seq=1, counter=1)
@@ -188,8 +197,6 @@ def test_sync_rotation_does_not_lose_buffered_events():
def test_sync_concurrent_commands_do_not_share_command_id():
"""50 concurrent threads calling _send_command must each get a unique id."""
from concurrent.futures import ThreadPoolExecutor
from typing import Any
captured_ids: list[int] = []
ids_lock = threading.Lock()
@@ -246,7 +253,6 @@ def test_sync_events_returns_fresh_iterator_each_access():
"""Two accesses of `thread.events` yield independent subscriptions,
mirroring the async semantics where each access opens a new subscriber."""
fake = SyncFakeServer()
from streaming._events import values_event
event_1 = values_event(seq=1, counter=1)
fake.script_sequence(
@@ -280,7 +286,6 @@ def test_close_unblocks_active_subscription_before_lifecycle_join():
"""close() must send None to active subscriptions BEFORE joining the
lifecycle watcher thread, so callers wake quickly even if the watcher
thread blocks for up to 1s."""
import queue
# Gate that keeps the lifecycle watcher thread alive for 0.4s.
lifecycle_block = threading.Event()
@@ -293,8 +298,6 @@ def test_close_unblocks_active_subscription_before_lifecycle_join():
def _handle(self, request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/stream/events"):
import orjson
body = orjson.loads(request.content)
channels = body.get("channels", [])
if "lifecycle" in channels:
@@ -417,7 +420,6 @@ def test_sync_threads_stream_mints_uuid4_when_thread_id_none():
def test_sync_run_start_sends_command():
from streaming._events import lifecycle_completed_event
fake = SyncFakeServer()
fake.script([lifecycle_completed_event(seq=1)])
@@ -432,7 +434,6 @@ def test_sync_run_start_sends_command():
def test_sync_events_iterates_raw_events():
from streaming._events import values_event
fake = SyncFakeServer()
fake.script([values_event(seq=1, counter=1)])
@@ -446,7 +447,6 @@ def test_sync_events_iterates_raw_events():
def test_sync_lifecycle_watcher_reconnects_with_since_after_transport_drop():
from streaming._events import lifecycle_completed_event, lifecycle_event
fake = SyncFakeServer()
fake.set_state({"ok": True})
@@ -481,7 +481,6 @@ def test_sync_threads_stream_accepts_websocket_transport_option():
def test_sync_threads_stream_rejects_unknown_transport_option():
import pytest
with httpx.Client(base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
@@ -494,17 +493,6 @@ def test_sync_threads_stream_rejects_unknown_transport_option():
def test_v3_streaming_sync_surface_smoke():
from streaming._events import (
custom_event,
lifecycle_completed_event,
message_finish_event,
message_start_event,
message_text_delta_event,
message_text_finish_event,
tool_finished_event,
tool_started_event,
values_event,
)
fake = SyncFakeServer()
fake.set_state({"final": True})
@@ -610,11 +598,6 @@ def test_v3_streaming_sync_surface_smoke():
def test_interleave_projections_single_channel_values():
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
values_event,
)
fake = SyncFakeServer()
fake.set_state({"counter": 0})
@@ -639,13 +622,6 @@ def test_interleave_projections_single_channel_values():
def test_interleave_projections_values_and_messages_arrival_order():
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
message_finish_event,
message_start_event,
values_event,
)
fake = SyncFakeServer()
fake.set_state({"counter": 0})
@@ -672,12 +648,6 @@ def test_interleave_projections_values_and_messages_arrival_order():
def test_interleave_projections_mixes_builtin_and_extension():
from streaming._events import (
custom_event,
lifecycle_completed_event,
lifecycle_started_event,
values_event,
)
fake = SyncFakeServer()
fake.set_state({"counter": 0})
@@ -701,12 +671,6 @@ def test_interleave_projections_mixes_builtin_and_extension():
def test_interleave_projections_tool_calls_uses_public_name():
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
tool_finished_event,
tool_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
@@ -735,10 +699,6 @@ def test_interleave_projections_tool_calls_uses_public_name():
def test_interleave_projections_subgraphs_discovers_child():
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
@@ -761,11 +721,6 @@ def test_interleave_projections_subgraphs_discovers_child():
def test_interleave_projections_inflight_tool_call_failed_on_break():
"""A tool handle held past an early break is failed in teardown, never left hanging."""
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
tool_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
@@ -794,10 +749,6 @@ def test_interleave_projections_inflight_tool_call_failed_on_break():
def test_interleave_projections_inflight_subgraph_finished_on_terminal():
"""A discovered subgraph child with no terminal tasks-result is force-completed."""
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
@@ -829,10 +780,6 @@ def test_interleave_projections_rejects_reserved_channel(channel):
would subscribe to a channel that never matches and yield nothing. Fail
closed. (`updates`/`checkpoints`/`tasks` are supported and tested below.)
"""
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
@@ -849,13 +796,6 @@ def test_interleave_projections_rejects_reserved_channel(channel):
def test_interleave_projections_data_channels_yield_payloads():
"""`updates`/`checkpoints`/`tasks` yield their raw `params.data` payloads."""
from streaming._events import (
checkpoints_event,
lifecycle_completed_event,
lifecycle_started_event,
tasks_start_event,
updates_event,
)
fake = SyncFakeServer()
fake.set_state({})
@@ -882,11 +822,6 @@ def test_interleave_projections_data_channels_yield_payloads():
def test_interleave_projections_data_channel_scoped_to_root_namespace():
"""A child-namespace checkpoint must not leak into a root interleave."""
from streaming._events import (
checkpoints_event,
lifecycle_completed_event,
lifecycle_started_event,
)
fake = SyncFakeServer()
fake.set_state({"counter": 0})
@@ -6,8 +6,10 @@ import httpx
import orjson
import pytest
from langgraph_sdk.stream.sync_controller import SyncStreamController
from langgraph_sdk.stream.transport.sync_ws import SyncProtocolWebSocketTransport
from streaming._events import values_event
from streaming._sync_fake_server import SyncFakeServer
class _FakeSyncWebSocket:
@@ -102,7 +104,6 @@ def test_sync_websocket_records_post_ready_error():
def test_sync_websocket_send_command_uses_http_commands_endpoint():
from streaming._sync_fake_server import SyncFakeServer
fake = SyncFakeServer()
with httpx.Client(transport=fake.transport, base_url="http://test") as client:
@@ -124,7 +125,6 @@ def test_sync_websocket_open_event_stream_raises_when_closed():
def test_sync_websocket_transport_feeds_sync_stream_controller():
from langgraph_sdk.stream.sync_controller import SyncStreamController
socket = _FakeSyncWebSocket(
[
@@ -164,7 +164,6 @@ def test_sync_websocket_transport_feeds_sync_stream_controller():
def test_sync_websocket_controller_reconnects_with_since_after_drop():
from langgraph_sdk.stream.sync_controller import SyncStreamController
first_socket = _FakeSyncWebSocket(
[values_event(seq=1, values={"counter": 1})],
@@ -4,10 +4,14 @@ import asyncio
import contextlib
import re
import uuid
from typing import Any
from typing import Any, cast
import httpx
import pytest
from langchain_protocol import Event
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.stream import AsyncThreadStream
@@ -24,6 +28,8 @@ from streaming._events import (
lifecycle_started_event,
message_finish_event,
message_start_event,
message_text_delta_event,
message_text_finish_event,
tasks_start_event,
tool_finished_event,
tool_started_event,
@@ -225,9 +231,6 @@ async def test_aenter_constructs_transport_with_thread_id():
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
stream = threads.stream(thread_id="t-1", assistant_id="agent")
async with stream:
@@ -237,9 +240,6 @@ async def test_aenter_constructs_transport_with_thread_id():
async def test_aenter_selects_websocket_transport():
async with httpx.AsyncClient(base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
stream = threads.stream(
thread_id="t-1", assistant_id="agent", transport="websocket"
@@ -252,9 +252,6 @@ async def test_aexit_closes_transport():
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
stream = threads.stream(thread_id="t-1", assistant_id="agent")
async with stream:
@@ -268,9 +265,6 @@ async def test_run_start_sends_command_with_assistant_id():
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
result = await thread.run.start(input={"x": 1})
@@ -286,9 +280,6 @@ async def test_command_ids_are_monotonic():
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={"x": 1})
@@ -300,9 +291,6 @@ async def test_run_start_forwards_config_and_metadata():
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(
@@ -316,7 +304,6 @@ async def test_run_start_forwards_config_and_metadata():
async def test_run_start_raises_outside_context_manager():
import pytest
async with httpx.AsyncClient(base_url="http://test") as raw:
stream = AsyncThreadStream(
@@ -327,9 +314,6 @@ async def test_run_start_raises_outside_context_manager():
async def test_run_start_raises_on_error_envelope():
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
async def commands(_request):
return JSONResponse(
@@ -346,11 +330,6 @@ async def test_run_start_raises_on_error_envelope():
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
with pytest.raises(RuntimeError, match="invalid_argument"):
@@ -367,9 +346,6 @@ async def test_events_yields_raw_events_after_run_start():
)
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
@@ -383,9 +359,6 @@ async def test_events_subscribes_to_all_channels():
fake.script([])
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
@@ -405,17 +378,11 @@ async def test_events_subscribes_to_all_channels():
async def test_events_terminates_on_aexit():
import asyncio
import pytest
fake = FakeServer()
fake.script([lifecycle_event(seq=i) for i in range(5)])
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
stream = threads.stream(thread_id="t-1", assistant_id="agent")
async with stream as thread:
@@ -462,9 +429,6 @@ async def test_events_property_returns_fresh_iterator_each_access():
fake.script([])
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
first_iter = thread.events
@@ -549,7 +513,6 @@ async def test_unregister_subscription_removes_from_registry():
async def test_await_run_start_gate_honors_timeout():
"""Gate must raise asyncio.TimeoutError if run.start never completes
within the configured timeout."""
import asyncio
async with httpx.AsyncClient(base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
@@ -568,7 +531,6 @@ async def test_await_run_start_gate_honors_timeout():
async def test_await_run_start_gate_returns_when_gate_resolves_in_time():
"""With a generous timeout and a gate that resolves promptly, the
gate returns without raising."""
import asyncio
async with httpx.AsyncClient(base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
@@ -583,7 +545,6 @@ async def test_await_run_start_gate_returns_when_gate_resolves_in_time():
async def test_run_start_timeout_constructor_kwarg_forwarded_to_gate():
"""`run_start_timeout` constructor kwarg is stored and consulted by
`_reconcile_stream` via `_await_run_start_gate`."""
import asyncio
async with httpx.AsyncClient(base_url="http://test") as raw:
stream = AsyncThreadStream(
@@ -608,7 +569,6 @@ async def test_subscribe_waits_for_run_start_to_commit():
their SSE. Without it, a fast subscribe would 404 against a thread the
server hasn't created yet.
"""
import asyncio
fake = FakeServer()
fake.script([])
@@ -699,7 +659,6 @@ async def test_run_respond_snapshots_interrupts_under_lock():
`respond()` blocks until the lock is released proving it serializes
with the terminal-clear path that takes the same lock.
"""
import asyncio
fake = FakeServer()
asgi = httpx.ASGITransport(app=fake.app)
@@ -737,7 +696,6 @@ async def test_terminal_lifecycle_clear_acquires_interrupts_lock():
"""Terminal lifecycle event clears `interrupts` under the same lock
that `respond()` uses, preventing TOCTOU between snapshot and
dispatch."""
import asyncio
fake = FakeServer()
# No scripted events; we exercise `_apply_lifecycle_event` directly.
@@ -754,10 +712,6 @@ async def test_terminal_lifecycle_clear_acquires_interrupts_lock():
# clearing interrupts.
await thread._interrupts_lock.acquire()
try:
from typing import cast
from langchain_protocol import Event
terminal_event = cast(
Event,
{
@@ -909,19 +863,6 @@ async def test_threads_stream_rejects_unknown_transport_option():
async def test_v3_streaming_async_surface_smoke():
import asyncio
from streaming._events import (
custom_event,
lifecycle_completed_event,
message_finish_event,
message_start_event,
message_text_delta_event,
message_text_finish_event,
tool_finished_event,
tool_started_event,
values_event,
)
fake = FakeServer()
fake.set_state({"final": True})
@@ -2,12 +2,15 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import AsyncGenerator
import httpx
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.stream import ToolCallHandle
from langgraph_sdk._async.threads import ThreadsClient
from streaming._events import (
lifecycle_completed_event,
@@ -214,7 +217,6 @@ async def test_tool_calls_explicit_aclose_does_not_block_1s():
await thread.run.start(input={})
# _tool_calls_iter() is an AsyncGenerator; cast so the type checker
# knows aclose() is available without a bare AsyncIterator protocol.
from collections.abc import AsyncGenerator
gen: AsyncGenerator = thread.tool_calls._tool_calls_iter()
_call = await gen.__anext__() # receive the one tool-started handle
@@ -230,11 +232,9 @@ def test_tool_call_handle_deltas_queue_is_bounded():
Unbounded queues allow producers to enqueue indefinitely, causing memory
growth when consumers are slow.
"""
import asyncio
# We need a running loop to create the Future inside ToolCallHandle.__init__.
async def _make() -> None:
from langgraph_sdk._async.stream import ToolCallHandle
handle_default = ToolCallHandle(tool_call_id="tc1", name="foo")
assert handle_default._deltas.maxsize > 0, (
@@ -255,10 +255,8 @@ def test_tool_call_handle_deltas_single_consumer_guard():
The property must raise before returning the iterator so the caller
sees the error even without iterating.
"""
import asyncio
async def _run() -> None:
from langgraph_sdk._async.stream import ToolCallHandle
handle = ToolCallHandle(tool_call_id="tc1", name="foo")
@@ -6,8 +6,17 @@ import contextlib
import httpx
import orjson
import pytest
from starlette.applications import Starlette
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from langgraph_sdk.stream.transport.http import EventStreamHandle, ProtocolSseTransport
from langgraph_sdk.stream.transport.http import (
EventStreamHandle,
ProtocolSseTransport,
_build_event_stream_body,
)
from streaming._events import lifecycle_event, values_event
from streaming._fake_server import FakeServer
async def test_event_stream_handle_constructs_with_open_state():
@@ -35,7 +44,6 @@ async def test_event_stream_handle_constructs_with_open_state():
async def test_send_command_posts_json_and_returns_response():
from streaming._fake_server import FakeServer
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
@@ -53,9 +61,6 @@ async def test_send_command_posts_json_and_returns_response():
async def test_send_command_returns_none_on_202():
from starlette.applications import Starlette
from starlette.responses import Response
from starlette.routing import Route
received: list[dict] = []
@@ -75,7 +80,6 @@ async def test_send_command_returns_none_on_202():
async def test_send_command_raises_when_closed():
from streaming._fake_server import FakeServer
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
@@ -87,9 +91,6 @@ async def test_send_command_raises_when_closed():
async def test_send_command_raises_http_error_on_4xx():
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
async def commands(_request):
return JSONResponse({"error": "bad request"}, status_code=400)
@@ -105,8 +106,6 @@ async def test_send_command_raises_http_error_on_4xx():
async def test_open_event_stream_yields_scripted_events():
from streaming._events import lifecycle_event, values_event
from streaming._fake_server import FakeServer
fake = FakeServer()
fake.script(
@@ -129,7 +128,6 @@ async def test_open_event_stream_yields_scripted_events():
async def test_open_event_stream_passes_since_in_body():
from streaming._fake_server import FakeServer
fake = FakeServer()
fake.script([])
@@ -145,8 +143,6 @@ async def test_open_event_stream_passes_since_in_body():
async def test_open_event_stream_close_cancels_in_flight_iteration():
from streaming._events import lifecycle_event
from streaming._fake_server import FakeServer
fake = FakeServer()
fake.script(
@@ -219,9 +215,6 @@ async def test_mid_stream_error_after_ready_surfaces_on_done():
"""If the SSE response body iteration raises after headers/ready, the
error must be exposed on handle.done so callers can distinguish a clean
end from a transport failure."""
import httpx
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
def handler(_request: httpx.Request) -> httpx.Response:
async def body():
@@ -250,9 +243,6 @@ async def test_mid_stream_error_after_ready_surfaces_on_done():
@pytest.mark.anyio
async def test_clean_stream_end_done_resolves_with_none():
"""A stream that ends without error must resolve `done` with None."""
import httpx
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
def handler(_request: httpx.Request) -> httpx.Response:
async def body():
@@ -280,9 +270,6 @@ async def test_clean_stream_end_done_resolves_with_none():
async def test_send_command_empty_200_body_raises_runtime_error_not_decoder_error():
"""A 200 response with empty body must raise RuntimeError matching the
'did not return a valid response' contract, not orjson.JSONDecodeError."""
import httpx
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=b"")
@@ -305,9 +292,6 @@ async def test_send_command_empty_200_body_raises_runtime_error_not_decoder_erro
async def test_cancel_event_prevents_post_cancel_flush():
"""When the consumer cancels the handle mid-stream, the pump's decoder
flush MUST NOT emit additional events after the cancel point."""
import httpx
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
received: list = []
@@ -342,9 +326,6 @@ async def test_cancel_event_prevents_post_cancel_flush():
@pytest.mark.anyio
async def test_open_event_stream_ready_rejects_on_5xx():
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
async def stream_events(_request):
return JSONResponse({"error": "boom"}, status_code=500)
@@ -371,14 +352,12 @@ async def test_open_event_stream_ready_rejects_on_5xx():
def test_build_event_stream_body_minimal_channels_only():
from langgraph_sdk.stream.transport.http import _build_event_stream_body
body = _build_event_stream_body({"channels": ["values"]})
assert body == {"channels": ["values"]}
def test_build_event_stream_body_includes_all_optional_fields():
from langgraph_sdk.stream.transport.http import _build_event_stream_body
body = _build_event_stream_body(
{
@@ -397,14 +376,12 @@ def test_build_event_stream_body_includes_all_optional_fields():
def test_build_event_stream_body_omits_since_when_not_int():
from langgraph_sdk.stream.transport.http import _build_event_stream_body
body = _build_event_stream_body({"channels": ["values"], "since": None})
assert "since" not in body
async def test_open_event_stream_raises_when_closed():
from streaming._fake_server import FakeServer
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
@@ -416,8 +393,6 @@ async def test_open_event_stream_raises_when_closed():
async def test_transport_close_cancels_open_event_streams():
from streaming._events import lifecycle_event
from streaming._fake_server import FakeServer
fake = FakeServer()
fake.script([lifecycle_event(seq=i) for i in range(5)], delay=0.05)
@@ -439,7 +414,6 @@ async def test_transport_close_cancels_open_event_streams():
async def test_default_headers_forwarded_to_send_command():
"""Headers passed at construction are sent on every command request."""
from streaming._fake_server import FakeServer
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
@@ -457,7 +431,6 @@ async def test_default_headers_forwarded_to_send_command():
async def test_default_headers_forwarded_to_open_event_stream():
"""Headers passed at construction are sent on every SSE stream request."""
from streaming._fake_server import FakeServer
fake = FakeServer()
fake.script([])
@@ -479,7 +452,6 @@ async def test_default_headers_forwarded_to_open_event_stream():
async def test_default_headers_cannot_override_sse_fixed_headers():
"""Caller-supplied default headers must not override content-type or accept."""
from streaming._fake_server import FakeServer
fake = FakeServer()
fake.script([])
@@ -506,7 +478,6 @@ async def test_default_headers_cannot_override_sse_fixed_headers():
async def test_fake_server_state_endpoint():
"""State endpoint returns the set state and increments the counter."""
from streaming._fake_server import FakeServer
fake = FakeServer()
fake.set_state({"foo": "bar"}, next=["node_a"])
@@ -527,7 +498,6 @@ async def test_fake_server_state_endpoint():
def test_values_event_builder_shape():
"""values_event produces the expected shape with params.data as the snapshot."""
from streaming._events import values_event
evt = values_event(seq=1, values={"foo": 1})
assert evt["event_id"] == "evt-1"
@@ -537,13 +507,11 @@ def test_values_event_builder_shape():
async def test_open_event_stream_done_records_post_ready_error():
from streaming._events import values_event
event_data = values_event(seq=1)
class _FailAfterOneStream(httpx.AsyncByteStream):
async def __aiter__(self):
import orjson
payload = orjson.dumps(event_data).decode()
yield f"id: {event_data.get('event_id', '')}\n".encode()
@@ -10,8 +10,10 @@ import pytest
from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
from websockets.frames import Close
from langgraph_sdk.stream.controller import StreamController
from langgraph_sdk.stream.transport.ws import ProtocolWebSocketTransport
from streaming._events import values_event
from streaming._fake_server import FakeServer
class _FakeAsyncWebSocket:
@@ -334,7 +336,6 @@ async def test_websocket_done_records_post_ready_error():
async def test_websocket_send_command_uses_http_commands_endpoint():
from streaming._fake_server import FakeServer
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
@@ -357,7 +358,6 @@ async def test_websocket_open_event_stream_raises_when_closed():
async def test_websocket_transport_feeds_async_stream_controller():
from langgraph_sdk.stream.controller import StreamController
socket = _FakeAsyncWebSocket(
[
@@ -418,7 +418,6 @@ async def test_ws_transport_default_max_queue_size_is_1024():
async def test_websocket_controller_reconnects_with_since_after_drop():
from langgraph_sdk.stream.controller import StreamController
first_socket = _FakeAsyncWebSocket(
[values_event(seq=1, values={"counter": 1})],
@@ -465,7 +464,6 @@ async def test_websocket_controller_reconnects_with_since_after_drop():
async def test_async_close_sends_normal_close_frame():
"""`handle.close()` sends a WebSocket close frame with code 1000 explicitly."""
import asyncio
# Use an event to distinguish an explicit close(code=1000) call from
# the implicit one in __aexit__ when the task is cancelled.
+2 -2
View File
@@ -8,7 +8,9 @@ import httpx
import pytest
from typing_extensions import assert_type
from langgraph_sdk._async.runs import _wrap_stream_v2
from langgraph_sdk._shared.utilities import _sse_to_v2_dict
from langgraph_sdk._sync.runs import _wrap_stream_v2_sync
from langgraph_sdk.client import HttpClient, SyncHttpClient
from langgraph_sdk.schema import (
CheckpointPayload,
@@ -380,7 +382,6 @@ def test_sse_to_v2_dict_values_with_interrupts() -> None:
@pytest.mark.asyncio
async def test_async_stream_v2_client_side_conversion() -> None:
from langgraph_sdk._async.runs import _wrap_stream_v2
async def mock_stream() -> Any:
yield StreamPart(event="metadata", data={"run_id": "r1"})
@@ -415,7 +416,6 @@ async def test_async_stream_v2_client_side_conversion() -> None:
def test_sync_stream_v2_client_side_conversion() -> None:
from langgraph_sdk._sync.runs import _wrap_stream_v2_sync
def mock_stream() -> Any:
yield StreamPart(event="metadata", data={"run_id": "r1"})
+2 -5
View File
@@ -7,6 +7,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from langgraph_sdk._async.runs import RunsClient
from langgraph_sdk._sync.runs import SyncRunsClient
from langgraph_sdk.schema import LangSmithTracing
@@ -24,7 +26,6 @@ class TestLangSmithTracingPayload:
@pytest.mark.asyncio
async def test_async_create_includes_langsmith_tracer(self, tracing_config):
"""Test that async create sends langsmith_tracer in payload."""
from langgraph_sdk._async.runs import RunsClient
captured: dict[str, Any] = {}
@@ -50,7 +51,6 @@ class TestLangSmithTracingPayload:
def test_sync_create_includes_langsmith_tracer(self, tracing_config):
"""Test that sync create sends langsmith_tracer in payload."""
from langgraph_sdk._sync.runs import SyncRunsClient
captured: dict[str, Any] = {}
@@ -76,7 +76,6 @@ class TestLangSmithTracingPayload:
def test_sync_wait_includes_langsmith_tracer(self, tracing_config):
"""Test that sync wait sends langsmith_tracer in payload."""
from langgraph_sdk._sync.runs import SyncRunsClient
captured: dict[str, Any] = {}
@@ -102,7 +101,6 @@ class TestLangSmithTracingPayload:
def test_create_without_langsmith_tracing_excludes_key(self):
"""Test that langsmith_tracer is not in payload when not provided."""
from langgraph_sdk._sync.runs import SyncRunsClient
captured: dict[str, Any] = {}
@@ -123,7 +121,6 @@ class TestLangSmithTracingPayload:
def test_langsmith_tracing_project_name_only(self):
"""Test that langsmith_tracing works with only project_name."""
from langgraph_sdk._sync.runs import SyncRunsClient
captured: dict[str, Any] = {}
+2 -1
View File
@@ -8,6 +8,8 @@ URL paths.
from __future__ import annotations
import uuid
import httpx
import pytest
@@ -60,7 +62,6 @@ class TestQuotePathParam:
assert "/" not in encoded
def test_non_string_values_are_coerced_to_str(self) -> None:
import uuid
uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
assert _quote_path_param(uid) == str(uid)
+1 -1
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from typing import Any
import orjson
@@ -36,7 +37,6 @@ async def test_serde_pydantic():
async def test_serde_dataclass():
from dataclasses import dataclass
@dataclass
class TestDataClass: