a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
360 lines
12 KiB
Python
360 lines
12 KiB
Python
"""Tests for arrival-ordered interleave and push stamps."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import operator
|
|
from typing import Annotated, Any
|
|
|
|
import pytest
|
|
from typing_extensions import TypedDict
|
|
|
|
from langgraph.constants import END, START
|
|
from langgraph.graph import StateGraph
|
|
from langgraph.stream import StreamChannel, StreamTransformer
|
|
from langgraph.stream._mux import StreamMux
|
|
from langgraph.stream._types import ProtocolEvent
|
|
from langgraph.stream.run_stream import GraphRunStream
|
|
from langgraph.stream.transformers import ValuesTransformer
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _TwoChannelTransformer(StreamTransformer):
|
|
"""Transformer that exposes two named channels for testing interleave."""
|
|
|
|
_native = True
|
|
|
|
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
|
super().__init__(scope)
|
|
self._alpha: StreamChannel[str] = StreamChannel("alpha")
|
|
self._beta: StreamChannel[str] = StreamChannel("beta")
|
|
|
|
def init(self) -> dict[str, Any]:
|
|
return {"alpha": self._alpha, "beta": self._beta}
|
|
|
|
def process(self, event: ProtocolEvent) -> bool:
|
|
return True
|
|
|
|
|
|
class SimpleState(TypedDict):
|
|
value: str
|
|
items: Annotated[list[str], operator.add]
|
|
|
|
|
|
def _build_simple_graph():
|
|
def node_a(state: SimpleState) -> dict:
|
|
return {"value": state["value"] + "A", "items": ["a"]}
|
|
|
|
def node_b(state: SimpleState) -> dict:
|
|
return {"value": state["value"] + "B", "items": ["b"]}
|
|
|
|
builder = StateGraph(SimpleState)
|
|
builder.add_node("node_a", node_a)
|
|
builder.add_node("node_b", node_b)
|
|
builder.add_edge(START, "node_a")
|
|
builder.add_edge("node_a", "node_b")
|
|
builder.add_edge("node_b", END)
|
|
return builder.compile()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests: push stamps on StreamChannel
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPushStamps:
|
|
def test_stamps_are_monotonic_across_channels(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
alpha = mux.extensions["alpha"]
|
|
beta = mux.extensions["beta"]
|
|
|
|
alpha._subscribed = True
|
|
beta._subscribed = True
|
|
|
|
alpha.push("a1")
|
|
beta.push("b1")
|
|
alpha.push("a2")
|
|
beta.push("b2")
|
|
|
|
all_stamped = list(alpha._items) + list(beta._items)
|
|
stamps = [s for s, _ in all_stamped]
|
|
assert len(set(stamps)) == 4
|
|
items_by_arrival = [item for _, item in sorted(all_stamped)]
|
|
assert items_by_arrival == ["a1", "b1", "a2", "b2"]
|
|
|
|
def test_regular_iter_strips_stamps(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
alpha = mux.extensions["alpha"]
|
|
it = iter(alpha)
|
|
alpha.push("a1")
|
|
alpha.push("a2")
|
|
alpha.close()
|
|
items = list(it)
|
|
assert items == ["a1", "a2"]
|
|
assert all(isinstance(item, str) for item in items)
|
|
|
|
def test_events_channel_gets_real_stamps(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
alpha = mux.extensions["alpha"]
|
|
|
|
alpha._subscribed = True
|
|
alpha.push("a1")
|
|
|
|
mux._events._subscribed = True
|
|
mux._events.push({"method": "test", "data": "x"})
|
|
|
|
alpha.push("a2")
|
|
|
|
all_stamps = [s for s, _ in alpha._items] + [s for s, _ in mux._events._items]
|
|
assert len(set(all_stamps)) == len(all_stamps), "all stamps should be unique"
|
|
assert all(s > 0 for s in all_stamps), "no stamp should be zero"
|
|
|
|
def test_channel_without_mux_gets_zero_stamp(self) -> None:
|
|
ch: StreamChannel[str] = StreamChannel()
|
|
ch._bind(is_async=False)
|
|
ch._subscribed = True
|
|
ch.push("x")
|
|
assert list(ch._items) == [(0, "x")]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests: interleave arrival order
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInterleaveArrivalOrder:
|
|
def test_arrival_order_not_round_robin(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
alpha = mux.extensions["alpha"]
|
|
beta = mux.extensions["beta"]
|
|
run = GraphRunStream(None, mux, wire_pump=False)
|
|
|
|
# interleave() subscribes channels directly and reads _items
|
|
# for stamp-ordered iteration. We simulate the pump by wiring
|
|
# a custom callback that pushes items in a known order.
|
|
push_script = [
|
|
("alpha", "a1"),
|
|
("alpha", "a2"),
|
|
("beta", "b1"),
|
|
("alpha", "a3"),
|
|
("beta", "b2"),
|
|
]
|
|
push_iter = iter(push_script)
|
|
channels = {"alpha": alpha, "beta": beta}
|
|
|
|
def fake_pump() -> bool:
|
|
try:
|
|
name, item = next(push_iter)
|
|
channels[name].push(item)
|
|
return True
|
|
except StopIteration:
|
|
mux.close()
|
|
return False
|
|
|
|
mux.bind_pump(fake_pump)
|
|
|
|
result = list(run.interleave("alpha", "beta"))
|
|
names = [name for name, _ in result]
|
|
items = [item for _, item in result]
|
|
|
|
assert items == ["a1", "a2", "b1", "a3", "b2"]
|
|
assert names == ["alpha", "alpha", "beta", "alpha", "beta"]
|
|
|
|
def test_single_projection(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
alpha = mux.extensions["alpha"]
|
|
run = GraphRunStream(None, mux, wire_pump=False)
|
|
|
|
push_script = [("alpha", "a1"), ("alpha", "a2")]
|
|
push_iter = iter(push_script)
|
|
|
|
def fake_pump() -> bool:
|
|
try:
|
|
_, item = next(push_iter)
|
|
alpha.push(item)
|
|
return True
|
|
except StopIteration:
|
|
mux.close()
|
|
return False
|
|
|
|
mux.bind_pump(fake_pump)
|
|
|
|
result = list(run.interleave("alpha"))
|
|
assert result == [("alpha", "a1"), ("alpha", "a2")]
|
|
|
|
def test_empty_projection(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
alpha = mux.extensions["alpha"]
|
|
run = GraphRunStream(None, mux, wire_pump=False)
|
|
|
|
push_script = [("alpha", "a1"), ("alpha", "a2")]
|
|
push_iter = iter(push_script)
|
|
channels = {"alpha": alpha}
|
|
|
|
def fake_pump() -> bool:
|
|
try:
|
|
name, item = next(push_iter)
|
|
channels[name].push(item)
|
|
return True
|
|
except StopIteration:
|
|
mux.close()
|
|
return False
|
|
|
|
mux.bind_pump(fake_pump)
|
|
|
|
result = list(run.interleave("alpha", "beta"))
|
|
assert result == [("alpha", "a1"), ("alpha", "a2")]
|
|
|
|
def test_unknown_projection_raises(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
run = GraphRunStream(None, mux, wire_pump=False)
|
|
mux.close()
|
|
with pytest.raises((KeyError, AttributeError)):
|
|
list(run.interleave("alpha", "does_not_exist"))
|
|
|
|
def test_all_empty(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
run = GraphRunStream(None, mux, wire_pump=False)
|
|
|
|
def fake_pump() -> bool:
|
|
mux.close()
|
|
return False
|
|
|
|
mux.bind_pump(fake_pump)
|
|
|
|
result = list(run.interleave("alpha", "beta"))
|
|
assert result == []
|
|
|
|
def test_error_propagation(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
alpha = mux.extensions["alpha"]
|
|
beta = mux.extensions["beta"]
|
|
run = GraphRunStream(None, mux, wire_pump=False)
|
|
|
|
err = RuntimeError("boom")
|
|
|
|
push_script = [
|
|
("alpha", "a1"),
|
|
("beta", "b1"),
|
|
]
|
|
push_iter = iter(push_script)
|
|
channels = {"alpha": alpha, "beta": beta}
|
|
|
|
def fake_pump() -> bool:
|
|
try:
|
|
name, item = next(push_iter)
|
|
channels[name].push(item)
|
|
return True
|
|
except StopIteration:
|
|
alpha.fail(err)
|
|
beta.close()
|
|
return False
|
|
|
|
mux.bind_pump(fake_pump)
|
|
|
|
collected = []
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
for pair in run.interleave("alpha", "beta"):
|
|
collected.append(pair)
|
|
|
|
assert ("alpha", "a1") in collected
|
|
assert ("beta", "b1") in collected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration test: interleave with stream_events(version="v3")
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInterleaveIntegration:
|
|
def test_interleave_values_and_messages(self) -> None:
|
|
run = _build_simple_graph().stream_events(
|
|
{"value": "x", "items": []}, version="v3"
|
|
)
|
|
tagged = list(run.interleave("values", "messages"))
|
|
names = [name for name, _ in tagged]
|
|
assert set(names).issubset({"values", "messages"})
|
|
assert names.count("values") >= 1
|
|
|
|
def test_interleave_rejects_already_subscribed(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
alpha = mux.extensions["alpha"]
|
|
run = GraphRunStream(None, mux, wire_pump=False)
|
|
|
|
# Subscribe alpha via iter first
|
|
_ = iter(alpha)
|
|
mux.close()
|
|
|
|
with pytest.raises(RuntimeError, match="already has a subscriber"):
|
|
list(run.interleave("alpha"))
|
|
|
|
def test_interleave_releases_projections_on_completion(self) -> None:
|
|
run = _build_simple_graph().stream_events(
|
|
{"value": "x", "items": []}, version="v3"
|
|
)
|
|
list(run.interleave("values", "messages"))
|
|
# Subscriptions should be released after the generator completes,
|
|
# so the channels can be re-iterated (they'll be empty / closed).
|
|
assert run.extensions["values"]._subscribed is False
|
|
assert run.extensions["messages"]._subscribed is False
|
|
|
|
def test_interleave_releases_projections_on_early_break(self) -> None:
|
|
run = _build_simple_graph().stream_events(
|
|
{"value": "x", "items": []}, version="v3"
|
|
)
|
|
gen = run.interleave("values", "messages")
|
|
next(gen)
|
|
gen.close()
|
|
assert run.extensions["values"]._subscribed is False
|
|
assert run.extensions["messages"]._subscribed is False
|
|
|
|
def test_interleave_releases_projections_on_validation_failure(self) -> None:
|
|
mux = StreamMux(
|
|
factories=[ValuesTransformer, _TwoChannelTransformer],
|
|
is_async=False,
|
|
)
|
|
alpha = mux.extensions["alpha"]
|
|
# Pre-subscribe alpha so that interleave will fail validation when
|
|
# it gets to the second name. The first (already-validated) channel
|
|
# should still be released.
|
|
run = GraphRunStream(None, mux, wire_pump=False)
|
|
mux.close()
|
|
alpha._subscribed = True
|
|
|
|
with pytest.raises(RuntimeError, match="already has a subscriber"):
|
|
list(run.interleave("values", "alpha"))
|
|
|
|
assert mux.extensions["values"]._subscribed is False
|