"""Tests for Pregel.stream_events(version="v3") / astream_events(version="v3") and the transformer pipeline.""" from __future__ import annotations import asyncio import operator import sys import time from typing import Annotated, Any import pytest from langgraph.checkpoint.memory import InMemorySaver 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._convert import convert_to_protocol_event from langgraph.stream._mux import StreamMux from langgraph.stream._types import ProtocolEvent from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer from langgraph.types import StreamWriter, interrupt NEEDS_CONTEXTVARS = pytest.mark.skipif( sys.version_info < (3, 11), reason="Python 3.11+ is required for async contextvars support", ) TS = int(time.time() * 1000) def _event( method: str, data: Any = None, *, namespace: list[str] | None = None, interrupts: tuple[Any, ...] | None = None, ) -> ProtocolEvent: params: dict[str, Any] = { "namespace": namespace or [], "timestamp": TS, "data": data if data is not None else {}, } if interrupts is not None: params["interrupts"] = interrupts return {"type": "event", "method": method, "params": params} # --------------------------------------------------------------------------- # Shared graph builders # --------------------------------------------------------------------------- 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() def _build_interrupt_graph(): def node_a(state: SimpleState) -> dict: return {"value": state["value"] + "A", "items": ["a"]} def node_b(state: SimpleState) -> dict: interrupt("need approval") 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(checkpointer=InMemorySaver()) def _build_error_graph(): def node_a(state: SimpleState) -> dict: return {"value": state["value"] + "A", "items": ["a"]} def node_b(state: SimpleState) -> dict: raise ValueError("boom") 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() def _build_custom_stream_graph(): def node_a(state: SimpleState, *, writer: StreamWriter) -> dict: writer({"step": "start"}) writer({"step": "end"}) return {"value": state["value"] + "A", "items": ["a"]} builder = StateGraph(SimpleState) builder.add_node("node_a", node_a) builder.add_edge(START, "node_a") builder.add_edge("node_a", END) return builder.compile() class _CustomPassthroughTransformer(StreamTransformer): """Opts a run into the `custom` stream mode without building a projection. `stream_events(version="v3")` requests only the modes that registered transformers declare via `required_stream_modes`. Custom events are raw user emissions from `StreamWriter`, so tests that want them visible on the main event log register this pass-through transformer. """ required_stream_modes = ("custom",) def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return True # --------------------------------------------------------------------------- # StreamChannel (local, unnamed) unit tests # --------------------------------------------------------------------------- class TestStreamChannelLocal: def test_sync_iteration(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) it = iter(log) log.push(1) log.push(2) log.push(3) log.close() assert list(it) == [1, 2, 3] def test_drain_on_consume(self) -> None: log: StreamChannel[str] = StreamChannel() log._bind(is_async=False) it = iter(log) log.push("a") log.push("b") log.close() assert list(it) == ["a", "b"] assert list(log._items) == [] def test_second_subscribe_raises(self) -> None: log: StreamChannel[str] = StreamChannel() log._bind(is_async=False) log.close() _ = iter(log) with pytest.raises(RuntimeError, match="already has a subscriber"): iter(log) def test_pre_subscription_push_is_noop(self) -> None: # Lazy-subscribe: pushes before subscription are dropped silently. log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) log.push(1) log.push(2) it = iter(log) log.push(3) log.close() assert list(it) == [3] def test_fail_propagation(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) it = iter(log) log.push(1) log.fail(ValueError("test error")) with pytest.raises(ValueError, match="test error"): list(it) def test_sync_cursor_yields_items_before_error(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) it = iter(log) log.push(1) log.push(2) log.push(3) log.fail(ValueError("late error")) items: list[int] = [] with pytest.raises(ValueError, match="late error"): for item in it: items.append(item) assert items == [1, 2, 3] def test_push_after_close_raises(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) it = iter(log) log.push(1) log.close() with pytest.raises(RuntimeError, match="Cannot push to a closed StreamChannel"): log.push(2) _ = list(it) def test_push_after_fail_raises(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) it = iter(log) log.fail(ValueError("err")) with pytest.raises(RuntimeError, match="Cannot push to a closed StreamChannel"): log.push(1) with pytest.raises(ValueError, match="err"): list(it) def test_empty_log_sync(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) log.close() assert list(log) == [] def test_empty_log_fail_sync(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) log.fail(ValueError("empty fail")) with pytest.raises(ValueError, match="empty fail"): list(log) def test_unbound_iter_raises(self) -> None: log: StreamChannel[int] = StreamChannel() log.close() with pytest.raises(TypeError, match="has not been bound"): list(log) def test_sync_bound_aiter_raises(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) log.close() with pytest.raises(TypeError, match="bound to sync mode"): log.__aiter__() def test_double_bind_raises(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) with pytest.raises(RuntimeError, match="already bound"): log._bind(is_async=True) @pytest.mark.anyio async def test_async_iteration(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=True) cursor = aiter(log) for i in range(3): log.push(i) log.close() assert [item async for item in cursor] == [0, 1, 2] @pytest.mark.anyio async def test_async_second_subscribe_raises(self) -> None: log: StreamChannel[str] = StreamChannel() log._bind(is_async=True) log.close() _ = log.__aiter__() with pytest.raises(RuntimeError, match="already has a subscriber"): log.__aiter__() @pytest.mark.anyio async def test_async_fail(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=True) cursor = aiter(log) log.push(1) log.fail(RuntimeError("async error")) with pytest.raises(RuntimeError, match="async error"): async for _ in cursor: pass @pytest.mark.anyio async def test_async_cursor_yields_items_before_error(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=True) cursor = aiter(log) log.push(1) log.push(2) log.push(3) log.fail(ValueError("late error")) items: list[int] = [] with pytest.raises(ValueError, match="late error"): async for item in cursor: items.append(item) assert items == [1, 2, 3] @pytest.mark.anyio async def test_empty_log_async(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=True) log.close() assert [item async for item in log] == [] @pytest.mark.anyio async def test_empty_log_fail_async(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=True) log.fail(ValueError("empty fail")) with pytest.raises(ValueError, match="empty fail"): async for _ in log: pass @pytest.mark.anyio async def test_async_bound_iter_raises(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=True) log.close() with pytest.raises(TypeError, match="bound to async mode"): iter(log) # --------------------------------------------------------------------------- # StreamChannel (named, wired) unit tests # --------------------------------------------------------------------------- class TestStreamChannelNamed: def test_push_and_iterate(self) -> None: ch: StreamChannel[str] = StreamChannel("test") ch._bind(is_async=False) it = iter(ch) ch.push("a") ch.push("b") ch.close() assert list(it) == ["a", "b"] def test_wire_callback(self) -> None: forwarded: list[str] = [] ch: StreamChannel[str] = StreamChannel("test") ch._bind(is_async=False) ch._wire(lambda item: forwarded.append(item)) it = iter(ch) ch.push("x") ch.push("y") ch.close() assert forwarded == ["x", "y"] assert list(it) == ["x", "y"] def test_fail_propagation(self) -> None: ch: StreamChannel[str] = StreamChannel("test") ch._bind(is_async=False) it = iter(ch) ch.push("a") ch.fail(ValueError("channel error")) items: list[str] = [] with pytest.raises(ValueError, match="channel error"): for item in it: items.append(item) assert items == ["a"] def test_push_without_wire(self) -> None: ch: StreamChannel[int] = StreamChannel("test") ch._bind(is_async=False) assert ch._wire_fn is None it = iter(ch) ch.push(42) ch.close() assert list(it) == [42] @pytest.mark.anyio async def test_async_iteration(self) -> None: ch: StreamChannel[str] = StreamChannel("test") ch._bind(is_async=True) cursor = ch.__aiter__() ch.push("x") ch.push("y") ch.close() assert [item async for item in cursor] == ["x", "y"] # --------------------------------------------------------------------------- # stream_events(version="v3") sync tests # --------------------------------------------------------------------------- class TestStreamV2Sync: def test_values_projection(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) snapshots = list(run.values) assert len(snapshots) >= 1 last = snapshots[-1] assert "A" in last["value"] and "B" in last["value"] def test_output(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) output = run.output assert output == {"value": "xAB", "items": ["a", "b"]} def test_raw_event_iteration(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) events = list(run) assert len(events) > 0 for event in events: assert event["type"] == "event" assert "method" in event assert "seq" in event assert isinstance(event["params"]["timestamp"], int) def test_extensions_has_native_keys(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) _ = run.output assert "values" in run.extensions and "messages" in run.extensions assert run.values is run.extensions["values"] assert run.messages is run.extensions["messages"] def test_extensions_is_read_only(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(TypeError): run.extensions["new_key"] = object() # type: ignore[index] with pytest.raises(TypeError): del run.extensions["values"] # type: ignore[attr-defined] def test_custom_stream_events(self) -> None: run = _build_custom_stream_graph().stream_events( {"value": "x", "items": []}, version="v3", transformers=[_CustomPassthroughTransformer], ) custom_events = [e for e in run if e["method"] == "custom"] assert len(custom_events) == 2 assert custom_events[0]["params"]["data"] == {"step": "start"} assert custom_events[1]["params"]["data"] == {"step": "end"} def test_custom_events_suppressed_without_transformer(self) -> None: """Without a transformer declaring `"custom"`, no custom events flow. `stream_events(version="v3")` asks the graph only for the modes that registered transformers require. Built-ins cover `values` / `messages`; consumers that want raw custom events surface them by registering a transformer whose `required_stream_modes` includes `"custom"`. """ run = _build_custom_stream_graph().stream_events( {"value": "x", "items": []}, version="v3" ) custom_events = [e for e in run if e["method"] == "custom"] assert custom_events == [] 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 # interleave releases its subscription on completion. assert run.extensions["values"]._subscribed is False assert run.extensions["messages"]._subscribed is False def test_abort_marks_exhausted_and_closes_mux(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) values_iter = iter(run.values) _ = next(values_iter) run.abort() list(values_iter) assert run._exhausted is True run.abort() # idempotent def test_context_manager_calls_abort_on_exit(self) -> None: with _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) as run: _ = next(iter(run.values)) assert run._exhausted is True def test_interleave_unknown_projection(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(KeyError): list(run.interleave("values", "does_not_exist")) class TestStreamV2SyncErrors: def test_error_propagation_output(self) -> None: run = _build_error_graph().stream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): _ = run.output def test_error_propagation_values(self) -> None: run = _build_error_graph().stream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): list(run.values) def test_error_propagation_raw_events(self) -> None: run = _build_error_graph().stream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): list(run) def test_error_propagation_interrupted(self) -> None: run = _build_error_graph().stream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): _ = run.interrupted def test_error_propagation_interrupts(self) -> None: run = _build_error_graph().stream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): _ = run.interrupts class TestStreamV2SyncInterrupt: def test_interrupted(self) -> None: run = _build_interrupt_graph().stream_events( {"value": "x", "items": []}, {"configurable": {"thread_id": "t1"}}, version="v3", ) _ = run.output assert run.interrupted is True assert len(run.interrupts) > 0 # --------------------------------------------------------------------------- # astream_events(version="v3") async tests # --------------------------------------------------------------------------- @pytest.mark.anyio @NEEDS_CONTEXTVARS class TestStreamV2Async: async def test_values_projection(self) -> None: run = await _build_simple_graph().astream_events( {"value": "x", "items": []}, version="v3" ) snapshots = [s async for s in run.values] assert len(snapshots) >= 1 last = snapshots[-1] assert "A" in last["value"] and "B" in last["value"] async def test_output(self) -> None: run = await _build_simple_graph().astream_events( {"value": "x", "items": []}, version="v3" ) output = await run.output() assert output == {"value": "xAB", "items": ["a", "b"]} async def test_raw_event_iteration(self) -> None: run = await _build_simple_graph().astream_events( {"value": "x", "items": []}, version="v3" ) events = [e async for e in run] assert len(events) > 0 for event in events: assert event["type"] == "event" async def test_abort_marks_exhausted_and_closes_mux(self) -> None: run = await _build_simple_graph().astream_events( {"value": "x", "items": []}, version="v3" ) values_iter = aiter(run.values) _ = await anext(values_iter) await run.abort() async for _item in values_iter: pass assert run._exhausted is True await run.abort() # idempotent async def test_context_manager_calls_abort_on_exit(self) -> None: run = await _build_simple_graph().astream_events( {"value": "x", "items": []}, version="v3" ) async with run: _ = await anext(aiter(run.values)) assert run._exhausted is True async def test_abort_cancels_running_subgraph(self) -> None: class CountState(TypedDict): count: int runs: list[int] = [] async def sub_node(state: CountState) -> dict: runs.append(state["count"] + 1) await asyncio.sleep(0.05) return {"count": state["count"] + 1} sub_graph = ( StateGraph(CountState) .add_node("sub_node", sub_node) .set_entry_point("sub_node") .add_conditional_edges( "sub_node", lambda s: END if s["count"] >= 10 else "sub_node", ) .compile() ) async def main_node(state: CountState) -> None: await sub_graph.ainvoke({"count": 0}) main_graph = ( StateGraph(CountState) .add_node("main_node", main_node) .set_entry_point("main_node") .compile() ) run = await main_graph.astream_events({"count": 0}, version="v3") async for e in run: if ( e["method"] == "values" and e["params"]["namespace"] and e["params"]["data"]["count"] >= 2 ): break await run.abort() runs_at_abort = len(runs) # Give the (now-cancelled) subgraph a chance to keep looping. await asyncio.sleep(0.3) assert len(runs) == runs_at_abort assert len(runs) < 10 async def test_abort_cancels_deeply_nested_subgraph(self) -> None: class CountState(TypedDict): count: int runs: list[int] = [] async def deep_node(state: CountState) -> dict: runs.append(state["count"] + 1) await asyncio.sleep(0.05) return {"count": state["count"] + 1} # Deepest graph loops until count >= 10. graph = ( StateGraph(CountState) .add_node("deep_node", deep_node) .set_entry_point("deep_node") .add_conditional_edges( "deep_node", lambda s: END if s["count"] >= 10 else "deep_node", ) .compile() ) # Wrap it three times: graph -> subgraph -> subgraph -> subgraph. for _ in range(3): async def caller(state: CountState, _child: Any = graph) -> dict: return await _child.ainvoke({"count": 0}) graph = ( StateGraph(CountState) .add_node("caller", caller) .set_entry_point("caller") .compile() ) run = await graph.astream_events({"count": 0}, version="v3") async for e in run: if ( e["method"] == "values" and e["params"]["namespace"] and e["params"]["data"]["count"] >= 2 ): break await run.abort() runs_at_abort = len(runs) # Give the (now-cancelled) nested subgraph a chance to keep looping. await asyncio.sleep(0.3) assert len(runs) == runs_at_abort assert len(runs) < 10 async def test_abort_cancels_subgraph_during_inflight_pump(self) -> None: class CountState(TypedDict): count: int started = asyncio.Event() cancelled = asyncio.Event() async def sub_node(state: CountState) -> dict: started.set() try: # Long-running node: still in flight when abort fires. await asyncio.sleep(5) except asyncio.CancelledError: cancelled.set() raise return {"count": state["count"] + 1} sub_graph = ( StateGraph(CountState) .add_node("sub_node", sub_node) .set_entry_point("sub_node") .compile() ) async def main_node(state: CountState) -> None: await sub_graph.ainvoke({"count": 0}) main_graph = ( StateGraph(CountState) .add_node("main_node", main_node) .set_entry_point("main_node") .compile() ) run = await main_graph.astream_events({"count": 0}, version="v3") # A consumer task drives the pump. Once the subgraph node is # running, no further event is produced, so the consumer parks # inside _apump_next awaiting graph_aiter.__anext__() — the # generator is "running" and a plain aclose() would raise. async def consume() -> None: async for _e in run: pass consumer = asyncio.create_task(consume()) try: await asyncio.wait_for(started.wait(), timeout=2.0) # Let the consumer drain and park in __anext__. await asyncio.sleep(0.05) # Abort from a different task while the consumer is in __anext__. await run.abort() # The in-flight subgraph node must observe cancellation. await asyncio.wait_for(cancelled.wait(), timeout=2.0) finally: consumer.cancel() try: await consumer except asyncio.CancelledError: pass async def test_extensions_has_native_keys(self) -> None: run = await _build_simple_graph().astream_events( {"value": "x", "items": []}, version="v3" ) _ = await run.output() assert "values" in run.extensions and "messages" in run.extensions assert run.values is run.extensions["values"] assert run.messages is run.extensions["messages"] async def test_custom_stream_events(self) -> None: run = await _build_custom_stream_graph().astream_events( {"value": "x", "items": []}, version="v3", transformers=[_CustomPassthroughTransformer], ) events = [e async for e in run] custom_events = [e for e in events if e["method"] == "custom"] assert len(custom_events) == 2 assert custom_events[0]["params"]["data"] == {"step": "start"} assert custom_events[1]["params"]["data"] == {"step": "end"} @pytest.mark.anyio @NEEDS_CONTEXTVARS class TestStreamV2AsyncErrors: async def test_error_propagation_output(self) -> None: run = await _build_error_graph().astream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): await run.output() async def test_error_propagation_values(self) -> None: run = await _build_error_graph().astream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): async for _ in run.values: pass async def test_error_propagation_raw_events(self) -> None: run = await _build_error_graph().astream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): async for _ in run: pass async def test_error_propagation_interrupted(self) -> None: run = await _build_error_graph().astream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): await run.interrupted() async def test_error_propagation_interrupts(self) -> None: run = await _build_error_graph().astream_events( {"value": "x", "items": []}, version="v3" ) with pytest.raises(ValueError, match="boom"): await run.interrupts() @pytest.mark.anyio @NEEDS_CONTEXTVARS class TestStreamV2AsyncInterrupt: async def test_interrupted(self) -> None: run = await _build_interrupt_graph().astream_events( {"value": "x", "items": []}, {"configurable": {"thread_id": "t2"}}, version="v3", ) _ = await run.output() assert await run.interrupted() is True assert len(await run.interrupts()) > 0 # --------------------------------------------------------------------------- # convert_to_protocol_event unit tests # --------------------------------------------------------------------------- class TestConvertToProtocolEvent: def test_basic_conversion(self) -> None: before = int(time.time() * 1000) event = convert_to_protocol_event( {"type": "values", "ns": ("sub", "graph"), "data": {"key": "val"}} ) after = int(time.time() * 1000) assert event["type"] == "event" assert event["method"] == "values" assert event["params"]["namespace"] == ["sub", "graph"] assert event["params"]["data"] == {"key": "val"} assert "interrupts" not in event["params"] assert before <= event["params"]["timestamp"] <= after def test_conversion_with_interrupts(self) -> None: event = convert_to_protocol_event( { "type": "values", "ns": (), "data": {"k": 1}, "interrupts": ({"value": "pause"},), } ) assert event["params"]["interrupts"] == ({"value": "pause"},) def test_namespace_tuple_becomes_list(self) -> None: event = convert_to_protocol_event( {"type": "updates", "ns": ("a", "b", "c"), "data": {}} ) assert event["params"]["namespace"] == ["a", "b", "c"] # --------------------------------------------------------------------------- # StreamMux unit tests # --------------------------------------------------------------------------- class TestStreamMux: def test_register_non_dict_raises(self) -> None: class BadTransformer(StreamTransformer): def init(self) -> Any: return ["not", "a", "dict"] def process(self, event: ProtocolEvent) -> bool: return True with pytest.raises(TypeError, match="must return a dict"): StreamMux([BadTransformer()]) def test_event_suppression(self) -> None: class FilterTransformer(StreamTransformer): def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return event["method"] != "updates" mux = StreamMux([FilterTransformer()]) it = iter(mux._events) mux.push(_event("values", {"a": 1})) mux.push(_event("updates", {"b": 2})) mux.push(_event("custom", {"c": 3})) mux.close() assert [e["method"] for e in it] == ["values", "custom"] def test_suppression_all_transformers_still_see_event(self) -> None: """If any transformer returns False, the event is suppressed from the main log, but all transformers still receive it.""" seen_by_second: list[str] = [] class PassTransformer(StreamTransformer): def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return True class RejectTransformer(StreamTransformer): def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: seen_by_second.append(event["method"]) return False mux = StreamMux([PassTransformer(), RejectTransformer()]) mux.push(_event("values")) mux.close() assert seen_by_second == ["values"] assert list(mux._events) == [] def test_empty_mux(self) -> None: mux = StreamMux() it = iter(mux._events) mux.push(_event("values", {"x": 1})) mux.close() events = list(it) assert len(events) == 1 assert events[0]["method"] == "values" def test_empty_mux_fail(self) -> None: mux = StreamMux() mux.fail(ValueError("boom")) with pytest.raises(ValueError, match="boom"): list(mux._events) # --------------------------------------------------------------------------- # ValuesTransformer / MessagesTransformer unit tests # --------------------------------------------------------------------------- class TestValuesTransformer: def test_ignores_non_root_namespace(self) -> None: t = ValuesTransformer() t.init() t._log._bind(is_async=False) it = iter(t._log) t.process(_event("values", {"val": "root"})) t.process(_event("values", {"val": "sub"}, namespace=["sub"])) t._log.close() items = list(it) assert len(items) == 1 assert items[0]["val"] == "root" def test_ignores_non_values_methods(self) -> None: t = ValuesTransformer() t.init() t._log._bind(is_async=False) it = iter(t._log) assert t.process(_event("updates", {"x": 1})) is True t._log.close() assert list(it) == [] def test_tracks_interrupts(self) -> None: t = ValuesTransformer() t.init() t.process( _event( "values", {"v": 1}, interrupts=({"value": "pause1"}, {"value": "pause2"}), ) ) assert t._interrupted is True assert len(t._interrupts) == 2 class TestOutputWithoutValuesTransformer: """run.output / run.interrupted / run.interrupts must work even when ValuesTransformer is not registered.""" def _stream_part( self, method: str, data: Any, namespace: tuple[str, ...] = () ) -> dict[str, Any]: return {"type": method, "ns": namespace, "data": data} def test_output_without_values_transformer(self) -> None: mux = StreamMux(factories=[MessagesTransformer], is_async=False) run = GraphRunStream( iter([self._stream_part("values", {"v": "final"})]), mux, ) assert "values" not in run.extensions assert run.output == {"v": "final"} def test_interrupts_without_values_transformer(self) -> None: part = self._stream_part("values", {"v": 1}) part["interrupts"] = ({"value": "pause"},) mux = StreamMux(factories=[MessagesTransformer], is_async=False) run = GraphRunStream(iter([part]), mux) assert run.interrupted is True assert len(run.interrupts) == 1 @pytest.mark.anyio async def test_async_output_without_values_transformer(self) -> None: async def _parts() -> Any: yield {"type": "values", "ns": (), "data": {"v": "async_final"}} mux = StreamMux(factories=[MessagesTransformer], is_async=True) run = AsyncGraphRunStream(_parts(), mux) assert "values" not in run.extensions assert await run.output() == {"v": "async_final"} class TestMessagesTransformer: def test_captures_root_messages(self) -> None: t = MessagesTransformer() t.init() t._log._bind(is_async=False) t._bind_pump(lambda: False) it = iter(t._log) meta = {"langgraph_node": "llm", "run_id": "run-1"} for evt in ( {"event": "message-start", "role": "ai", "message_id": "run-1"}, {"event": "message-finish", "reason": "stop"}, ): t.process(_event("messages", (evt, meta))) t._log.close() items = list(it) assert len(items) == 1 assert hasattr(items[0], "dispatch") assert items[0].message_id == "run-1" def test_ignores_non_root_namespace(self) -> None: t = MessagesTransformer() t.init() t._log._bind(is_async=False) t._bind_pump(lambda: False) it = iter(t._log) meta = {"langgraph_node": "llm", "run_id": "run-1"} t.process( _event( "messages", ({"event": "message-start", "message_id": "run-1"}, meta), namespace=["sub"], ) ) t._log.close() assert list(it) == [] def test_ignores_non_messages_methods(self) -> None: t = MessagesTransformer() t.init() t._log._bind(is_async=False) it = iter(t._log) assert t.process(_event("values", {"v": 1})) is True t._log.close() assert list(it) == [] def test_fail_propagates(self) -> None: t = MessagesTransformer() t.init() t._log._bind(is_async=False) it = iter(t._log) t._log.fail(ValueError("msg error")) with pytest.raises(ValueError, match="msg error"): list(it) # --------------------------------------------------------------------------- # StreamMux resilience: close/fail continue cleanup on transformer errors # --------------------------------------------------------------------------- class TestStreamMuxResilience: def test_close_continues_after_finalize_error(self) -> None: class BrokenFinalizer(StreamTransformer): def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return True def finalize(self) -> None: raise RuntimeError("finalize broke") class GoodTransformer(StreamTransformer): def __init__(self) -> None: self.finalized = False def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return True def finalize(self) -> None: self.finalized = True good = GoodTransformer() mux = StreamMux([BrokenFinalizer(), good]) mux.push(_event("values")) with pytest.raises(RuntimeError, match="finalize broke"): mux.close() assert good.finalized assert mux._events._closed def test_fail_continues_after_transformer_error(self) -> None: class BrokenFailer(StreamTransformer): def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return True def fail(self, err: BaseException) -> None: raise RuntimeError("fail handler broke") class GoodTransformer(StreamTransformer): def __init__(self) -> None: self.failed_with: BaseException | None = None def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return True def fail(self, err: BaseException) -> None: self.failed_with = err good = GoodTransformer() mux = StreamMux([BrokenFailer(), good]) original_error = ValueError("original") mux.fail(original_error) assert good.failed_with is original_error assert mux._events._error is original_error def test_channels_closed_after_finalize_error(self) -> None: class BrokenWithChannel(StreamTransformer): def __init__(self) -> None: self._channel: StreamChannel[str] = StreamChannel("ch") def init(self) -> dict[str, Any]: return {"ch": self._channel} def process(self, event: ProtocolEvent) -> bool: return True def finalize(self) -> None: raise RuntimeError("finalize broke") t = BrokenWithChannel() mux = StreamMux([t]) with pytest.raises(RuntimeError, match="finalize broke"): mux.close() assert t._channel._closed # --------------------------------------------------------------------------- # Custom transformer tests # --------------------------------------------------------------------------- class TestCustomTransformer: def test_extension_transformer_with_stream_channel(self) -> None: class CounterTransformer(StreamTransformer): def __init__(self, scope: tuple[str, ...] = ()) -> None: super().__init__(scope) self._channel: StreamChannel[int] = StreamChannel("counter") self._count = 0 def init(self) -> dict[str, Any]: return {"counter": self._channel} def process(self, event: ProtocolEvent) -> bool: if event["method"] == "values": self._count += 1 self._channel.push(self._count) return True run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3", transformers=[CounterTransformer] ) assert "counter" in run.extensions counter_iter = iter(run.extensions["counter"]) _ = run.output counts = list(counter_iter) assert len(counts) > 0 assert not hasattr(run, "counter") # non-native: no direct attribute def test_native_transformer_gets_direct_attr(self) -> None: class FooTransformer(StreamTransformer): _native = True def __init__(self, scope: tuple[str, ...] = ()) -> None: super().__init__(scope) self._log: StreamChannel[str] = StreamChannel() def init(self) -> dict[str, Any]: return {"foo": self._log} def process(self, event: ProtocolEvent) -> bool: if event["method"] == "values": self._log.push("saw_values") return True run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3", transformers=[FooTransformer] ) foo_iter = iter(run.foo) _ = run.output assert "foo" in run.extensions and run.foo is run.extensions["foo"] assert "saw_values" in list(foo_iter) def test_stream_events_v3_rejects_transformer_instances(self) -> None: class InstanceTransformer(StreamTransformer): def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return True with pytest.raises(TypeError, match="pre-built instance"): _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3", transformers=[InstanceTransformer()], ) def test_stream_channel_auto_forward(self) -> None: """StreamChannel pushes inject ProtocolEvents into the main log.""" class EmitterTransformer(StreamTransformer): def __init__(self, scope: tuple[str, ...] = ()) -> None: super().__init__(scope) self._channel: StreamChannel[str] = StreamChannel("emitter") def init(self) -> dict[str, Any]: return {"emitter": self._channel} def process(self, event: ProtocolEvent) -> bool: if event["method"] == "values": self._channel.push("emitted") return True run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3", transformers=[EmitterTransformer] ) custom_events = [e for e in run if e["method"] == "custom:emitter"] assert len(custom_events) > 0 assert custom_events[0]["params"]["data"] == "emitted" def test_stream_channel_seq_ordering(self) -> None: """Seq numbers must be monotonically increasing even when a channel push auto-forwards an event mid-pipeline.""" class ChannelPusher(StreamTransformer): def __init__(self) -> None: super().__init__() self._channel: StreamChannel[str] = StreamChannel("ch") def init(self) -> dict[str, Any]: return {"ch": self._channel} def process(self, event: ProtocolEvent) -> bool: self._channel.push(f"saw:{event['method']}") return True mux = StreamMux([ChannelPusher()]) it = iter(mux._events) mux.push(_event("values")) mux.push(_event("updates")) mux.close() seqs = [e["seq"] for e in it] for i in range(1, len(seqs)): assert seqs[i] > seqs[i - 1], f"Seq out of order at index {i}: {seqs}" def test_projection_key_conflict_raises(self) -> None: class ConflictTransformer(StreamTransformer): def __init__(self, scope: tuple[str, ...] = ()) -> None: super().__init__(scope) self._log: StreamChannel[str] = StreamChannel() def init(self) -> dict[str, Any]: return {"values": self._log} def process(self, event: ProtocolEvent) -> bool: return True with pytest.raises(ValueError, match=r"conflict.*'values'.*ValuesTransformer"): _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3", transformers=[ConflictTransformer], ) # --------------------------------------------------------------------------- # StreamChannel auto-lifecycle via StreamMux # --------------------------------------------------------------------------- class TestStreamChannelAutoLifecycle: def test_mux_auto_closes_channels(self) -> None: class SimpleTransformer(StreamTransformer): def __init__(self) -> None: super().__init__() self._log: StreamChannel[str] = StreamChannel() def init(self) -> dict[str, Any]: return {"items": self._log} def process(self, event: ProtocolEvent) -> bool: self._log.push("saw_event") return True mux = StreamMux([SimpleTransformer()]) it = iter(mux._events) mux.push(_event("values")) mux.close() assert len(list(it)) == 1 def test_mux_auto_fails_channels(self) -> None: class SimpleTransformer(StreamTransformer): def __init__(self) -> None: super().__init__() self._log: StreamChannel[str] = StreamChannel() def init(self) -> dict[str, Any]: return {"items": self._log} def process(self, event: ProtocolEvent) -> bool: self._log.push("saw_event") return True t = SimpleTransformer() mux = StreamMux([t]) it = iter(t._log) mux.push(_event("values")) mux.fail(ValueError("boom")) with pytest.raises(ValueError, match="boom"): list(it) def test_no_double_close_if_transformer_closes_own_log(self) -> None: class ManualCloseTransformer(StreamTransformer): def __init__(self) -> None: super().__init__() self._log: StreamChannel[str] = StreamChannel() def init(self) -> dict[str, Any]: return {"items": self._log} def process(self, event: ProtocolEvent) -> bool: return True def finalize(self) -> None: self._log.close() mux = StreamMux([ManualCloseTransformer()]) mux.close() # should not raise even with double-close def test_transformer_without_finalize_works(self) -> None: class MinimalTransformer(StreamTransformer): def __init__(self, scope: tuple[str, ...] = ()) -> None: super().__init__(scope) self._log: StreamChannel[str] = StreamChannel() def init(self) -> dict[str, Any]: return {"minimal": self._log} def process(self, event: ProtocolEvent) -> bool: if event["method"] == "values": self._log.push("got_it") return True run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3", transformers=[MinimalTransformer] ) minimal_iter = iter(run.extensions["minimal"]) _ = run.output assert len(list(minimal_iter)) > 0 class TestStreamTransformerSchedule: def test_schedule_without_running_loop_raises(self) -> None: class Sched(StreamTransformer): requires_async = True def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return True t = Sched() async def noop() -> None: pass coro = noop() try: with pytest.raises(RuntimeError, match="requires a running event loop"): t.schedule(coro) finally: coro.close() # --------------------------------------------------------------------------- # Async transformer lane # --------------------------------------------------------------------------- @pytest.mark.anyio class TestAsyncTransformerLane: async def test_aprocess_is_awaited_before_next_transformer(self) -> None: """aprocess must complete before the next transformer sees the event — load-bearing guarantee for mutating transformers like PII redaction.""" order: list[str] = [] class RedactTransformer(StreamTransformer): requires_async = True def init(self) -> dict[str, Any]: return {} async def aprocess(self, event: ProtocolEvent) -> bool: await asyncio.sleep(0.01) order.append("redact") event["params"]["data"]["redacted"] = True return True class ObserverTransformer(StreamTransformer): def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: if event["method"] == "values": order.append(f"observe:{event['params']['data'].get('redacted')}") return True mux = StreamMux([RedactTransformer(), ObserverTransformer()], is_async=True) await mux.apush(_event("values", {"secret": "x"})) await mux.aclose() assert order == ["redact", "observe:True"] async def test_schedule_joins_tasks_before_afinalize(self) -> None: """Every scheduled task must complete before afinalize runs.""" phase: list[str] = [] class SchedTransformer(StreamTransformer): requires_async = True def __init__(self) -> None: self._log: StreamChannel[str] = StreamChannel() def init(self) -> dict[str, Any]: return {"out": self._log} def process(self, event: ProtocolEvent) -> bool: if event["method"] == "values": async def work() -> None: await asyncio.sleep(0.01) phase.append("task") self._log.push("done") self.schedule(work()) return True async def afinalize(self) -> None: phase.append("afinalize") self._log.close() t = SchedTransformer() mux = StreamMux([t], is_async=True) await mux.apush(_event("values", {})) await mux.apush(_event("values", {})) await mux.aclose() assert phase.count("task") == 2 assert phase[-1] == "afinalize" async def test_sync_stream_rejects_async_transformer(self) -> None: class NeedsAsync(StreamTransformer): requires_async = True def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: return True with pytest.raises(RuntimeError, match="requires an async run"): StreamMux([NeedsAsync()], is_async=False) async def test_sync_stream_rejects_aprocess_override(self) -> None: class HasAprocess(StreamTransformer): def init(self) -> dict[str, Any]: return {} async def aprocess(self, event: ProtocolEvent) -> bool: return True with pytest.raises(RuntimeError, match="requires an async run"): StreamMux([HasAprocess()], is_async=False) async def test_schedule_on_error_log_swallows_exceptions(self) -> None: class Bad(StreamTransformer): requires_async = True def __init__(self) -> None: self._log: StreamChannel[str] = StreamChannel() def init(self) -> dict[str, Any]: return {"out": self._log} def process(self, event: ProtocolEvent) -> bool: async def work() -> None: raise ValueError("boom") self.schedule(work()) # default on_error="log" return True async def afinalize(self) -> None: self._log.close() mux = StreamMux([Bad()], is_async=True) await mux.apush(_event("values", {})) await mux.aclose() # should not raise; exception is logged async def test_schedule_on_error_raise_fails_the_run(self) -> None: class Strict(StreamTransformer): requires_async = True def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: async def work() -> None: raise ValueError("strict boom") self.schedule(work(), on_error="raise") return True mux = StreamMux([Strict()], is_async=True) await mux.apush(_event("values", {})) with pytest.raises(ValueError, match="strict boom"): await mux.aclose() async def test_afail_cancels_pending_scheduled_tasks(self) -> None: cancelled = asyncio.Event() class Sched(StreamTransformer): requires_async = True def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: async def work() -> None: try: await asyncio.sleep(5) except asyncio.CancelledError: cancelled.set() raise self.schedule(work()) return True mux = StreamMux([Sched()], is_async=True) await mux.apush(_event("values", {})) # Yield so the task actually starts before we cancel it. await asyncio.sleep(0) await mux.afail(RuntimeError("run died")) assert cancelled.is_set() async def test_mixed_sync_and_async_transformers(self) -> None: seen_sync: list[str] = [] class SyncOne(StreamTransformer): def init(self) -> dict[str, Any]: return {} def process(self, event: ProtocolEvent) -> bool: seen_sync.append(event["method"]) return True class AsyncOne(StreamTransformer): requires_async = True def __init__(self) -> None: self._log: StreamChannel[str] = StreamChannel() def init(self) -> dict[str, Any]: return {"seen": self._log} async def aprocess(self, event: ProtocolEvent) -> bool: await asyncio.sleep(0) self._log.push(event["method"]) return True async def afinalize(self) -> None: self._log.close() async_t = AsyncOne() mux = StreamMux([SyncOne(), async_t], is_async=True) seen_cursor = aiter(async_t._log) await mux.apush(_event("values", {})) await mux.apush(_event("updates", {})) await mux.aclose() assert seen_sync == ["values", "updates"] assert [x async for x in seen_cursor] == ["values", "updates"] async def test_handler_astream_with_scheduled_work(self) -> None: class Scorer(StreamTransformer): requires_async = True def __init__(self, scope: tuple[str, ...] = ()) -> None: super().__init__(scope) self._log: StreamChannel[int] = StreamChannel() def init(self) -> dict[str, Any]: return {"scores": self._log} def process(self, event: ProtocolEvent) -> bool: if event["method"] == "values": async def work() -> None: await asyncio.sleep(0.01) self._log.push(42) self.schedule(work()) return True async def afinalize(self) -> None: self._log.close() run = await _build_simple_graph().astream_events( {"value": "x", "items": []}, version="v3", transformers=[Scorer] ) scores_cursor = aiter(run.extensions["scores"]) _ = await run.output() scores = [x async for x in scores_cursor] assert scores and all(s == 42 for s in scores) # --------------------------------------------------------------------------- # Memory bounds: drain-on-consume semantics # --------------------------------------------------------------------------- @NEEDS_CONTEXTVARS class TestMemoryBounds: def test_sync_subscribed_buffer_stays_at_most_one_between_yields(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) events_iter = iter(run) max_buffered = 0 count = 0 for _ in events_iter: max_buffered = max(max_buffered, len(run._mux._events._items)) count += 1 assert count > 0 assert max_buffered == 0, ( f"drain-on-consume violated, observed max {max_buffered}" ) def test_unsubscribed_projections_never_accumulate(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) list(run) values_log = run.extensions["values"] messages_log = run.extensions["messages"] assert len(values_log._items) == 0 and not values_log._subscribed assert len(messages_log._items) == 0 and not messages_log._subscribed def test_output_path_does_not_retain_values(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) _ = run.output values_log = run.extensions["values"] assert len(values_log._items) == 0 and not values_log._subscribed def test_drained_subscriber_buffer_returns_to_empty(self) -> None: run = _build_simple_graph().stream_events( {"value": "x", "items": []}, version="v3" ) list(run.values) assert len(run.extensions["values"]._items) == 0 @pytest.mark.anyio async def test_async_single_consumer_buffer_stays_at_most_one(self) -> None: run = await _build_simple_graph().astream_events( {"value": "x", "items": []}, version="v3" ) max_buffered = 0 count = 0 async for _ in run: max_buffered = max(max_buffered, len(run._mux._events._items)) count += 1 assert count > 0 assert max_buffered == 0 @pytest.mark.anyio async def test_async_unsubscribed_projections_never_accumulate(self) -> None: run = await _build_simple_graph().astream_events( {"value": "x", "items": []}, version="v3" ) _ = await run.output() values_log = run.extensions["values"] messages_log = run.extensions["messages"] assert len(values_log._items) == 0 and not values_log._subscribed assert len(messages_log._items) == 0 and not messages_log._subscribed # --------------------------------------------------------------------------- # DrainOnConsume: StreamChannel capacity semantics # --------------------------------------------------------------------------- class TestDrainOnConsume: def test_invalid_maxlen_raises(self) -> None: with pytest.raises(ValueError, match="positive int or None"): StreamChannel(maxlen=0) with pytest.raises(ValueError, match="positive int or None"): StreamChannel(maxlen=-3) def test_push_unbounded_by_design(self) -> None: """Push is non-blocking; the caller-driven pump bounds memory via iteration pace.""" log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) it = iter(log) for i in range(100): log.push(i) log.close() assert list(it) == list(range(100)) def test_tee_fans_out_sync(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=False) a, b = log.tee(2) for i in range(3): log.push(i) log.close() assert list(a) == [0, 1, 2] assert list(b) == [0, 1, 2] @pytest.mark.anyio async def test_atee_fans_out(self) -> None: log: StreamChannel[int] = StreamChannel() log._bind(is_async=True) a, b = log.atee(2) for i in range(3): log.push(i) log.close() assert [x async for x in a] == [0, 1, 2] assert [x async for x in b] == [0, 1, 2]