"""Tests for ToolCallTransformer and the ToolCallStream projection.""" from __future__ import annotations import time from typing import Annotated, Any import pytest from langchain_core.messages import AIMessage, ToolMessage from langchain_core.tools import tool from langgraph.constants import END, START from langgraph.graph import StateGraph from langgraph.graph.message import add_messages from langgraph.stream._mux import StreamMux from langgraph.stream._types import ProtocolEvent from langgraph.stream.stream_channel import StreamChannel from langgraph.stream.transformers import ( MessagesTransformer, ValuesTransformer, ) from typing_extensions import TypedDict from langgraph.prebuilt import ( ToolCallTransformer, ToolNode, ToolRuntime, ) from langgraph.prebuilt._tool_call_stream import ToolCallStream TS = int(time.time() * 1000) def _unstamped(items): """Strip push stamps from a StreamChannel's internal buffer.""" return [item for _stamp, item in items] def _tool_event( event: str, tool_call_id: str, *, tool_name: str = "", input: dict[str, Any] | None = None, delta: Any = None, output: Any = None, message: str = "", namespace: list[str] | None = None, ) -> ProtocolEvent: data: dict[str, Any] = {"event": event, "tool_call_id": tool_call_id} if event == "tool-started": data["tool_name"] = tool_name if input is not None: data["input"] = input elif event == "tool-output-delta": data["delta"] = delta elif event == "tool-finished": data["output"] = output elif event == "tool-error": data["message"] = message return { "type": "event", "method": "tools", "params": { "namespace": namespace or [], "timestamp": TS, "data": data, }, } def _subscribe(log: StreamChannel) -> None: log._subscribed = True def _mux() -> tuple[StreamMux, ToolCallTransformer]: transformer = ToolCallTransformer() mux = StreamMux( [ ValuesTransformer(), MessagesTransformer(), transformer, ], is_async=False, ) _subscribe(transformer._log) return mux, transformer class TestToolCallTransformerUnit: def test_required_stream_modes_declares_tools(self) -> None: assert ToolCallTransformer.required_stream_modes == ("tools",) def test_tool_started_yields_handle(self) -> None: mux, transformer = _mux() mux.push( _tool_event( "tool-started", "tc1", tool_name="echo", input={"text": "hi"}, ) ) handles = _unstamped(transformer._log._items) assert len(handles) == 1 h = handles[0] assert isinstance(h, ToolCallStream) assert h.tool_call_id == "tc1" assert h.tool_name == "echo" assert h.input == {"text": "hi"} assert h.completed is False def test_delta_accumulates_on_active_stream(self) -> None: mux, transformer = _mux() mux.push(_tool_event("tool-started", "tc1", tool_name="echo")) _subscribe(transformer._active["tc1"]._output_deltas) mux.push(_tool_event("tool-output-delta", "tc1", delta="a")) mux.push(_tool_event("tool-output-delta", "tc1", delta="b")) stream = transformer._active["tc1"] assert _unstamped(stream._output_deltas._items) == ["a", "b"] def test_finish_closes_stream(self) -> None: mux, transformer = _mux() mux.push(_tool_event("tool-started", "tc1", tool_name="echo")) stream = transformer._active["tc1"] mux.push(_tool_event("tool-finished", "tc1", output="done")) assert stream.completed is True assert stream.output == "done" assert stream.error is None assert "tc1" not in transformer._active def test_finish_unwraps_tool_message_output(self) -> None: mux, transformer = _mux() mux.push(_tool_event("tool-started", "tc1", tool_name="echo")) stream = transformer._active["tc1"] mux.push( _tool_event( "tool-finished", "tc1", output=ToolMessage(content="done", tool_call_id="tc1"), ) ) assert stream.completed is True assert stream.output == "done" def test_finish_unwraps_serialized_tool_message_output(self) -> None: mux, transformer = _mux() mux.push(_tool_event("tool-started", "tc1", tool_name="echo")) stream = transformer._active["tc1"] mux.push( _tool_event( "tool-finished", "tc1", output={ "lc": 1, "type": "constructor", "id": ["langchain_core", "messages", "ToolMessage"], "kwargs": { "content": "serialized done", "tool_call_id": "tc1", }, }, ) ) assert stream.completed is True assert stream.output == "serialized done" def test_error_closes_stream(self) -> None: mux, transformer = _mux() mux.push(_tool_event("tool-started", "tc1", tool_name="boom")) stream = transformer._active["tc1"] mux.push(_tool_event("tool-error", "tc1", message="nope")) assert stream.completed is True assert stream.output is None assert stream.error == "nope" assert "tc1" not in transformer._active def test_concurrent_tool_calls_do_not_bleed(self) -> None: mux, transformer = _mux() mux.push(_tool_event("tool-started", "a", tool_name="t")) mux.push(_tool_event("tool-started", "b", tool_name="t")) for tc in ("a", "b"): _subscribe(transformer._active[tc]._output_deltas) mux.push(_tool_event("tool-output-delta", "a", delta="A1")) mux.push(_tool_event("tool-output-delta", "b", delta="B1")) mux.push(_tool_event("tool-output-delta", "a", delta="A2")) assert _unstamped(transformer._active["a"]._output_deltas._items) == [ "A1", "A2", ] assert _unstamped(transformer._active["b"]._output_deltas._items) == ["B1"] def test_tools_event_passes_through_main_log(self) -> None: mux, transformer = _mux() _subscribe(mux._events) mux.push(_tool_event("tool-started", "tc1", tool_name="echo")) kept = [e for e in _unstamped(mux._events._items) if e["method"] == "tools"] assert len(kept) == 1 def test_out_of_scope_event_skipped(self) -> None: """Subgraph-scoped `tools` events must not project into a parent transformer's `tool_calls` log. The parent's main event log keeps the event (so wire consumers still see it) but the parent's `ToolCallTransformer` only owns the projection at its own scope. Per-scope `ToolCallTransformer` instances on child mini-muxes are responsible for projecting events at their own depth. """ # Root-scope transformer (`scope == ()`). mux, transformer = _mux() _subscribe(mux._events) mux.push( _tool_event( "tool-started", "tc1", tool_name="inner_echo", namespace=["child:abc"], ) ) # No `ToolCallStream` was projected into the root's log. assert _unstamped(transformer._log._items) == [] assert "tc1" not in transformer._active # The event still passes through the main event log so consumers # of the raw `tools` channel see it untouched. kept = [e for e in _unstamped(mux._events._items) if e["method"] == "tools"] assert len(kept) == 1 def test_in_scope_event_projected_when_scope_set(self) -> None: """A non-root transformer projects only events at its own scope.""" scope: tuple[str, ...] = ("child:abc",) transformer = ToolCallTransformer(scope=scope) mux = StreamMux( [ValuesTransformer(), MessagesTransformer(), transformer], scope=scope, is_async=False, ) _subscribe(transformer._log) # Event at this scope: projected. mux.push( _tool_event( "tool-started", "tc1", tool_name="echo", namespace=list(scope), ) ) assert len(_unstamped(transformer._log._items)) == 1 # Event at a deeper scope: ignored. mux.push( _tool_event( "tool-started", "tc2", tool_name="grandchild", namespace=[*scope, "grand:xyz"], ) ) assert len(_unstamped(transformer._log._items)) == 1 # Event at root (above this scope): ignored. mux.push( _tool_event( "tool-started", "tc3", tool_name="root_tool", namespace=[], ) ) assert len(_unstamped(transformer._log._items)) == 1 # --------------------------------------------------------------------------- # End-to-end tests with a real graph # --------------------------------------------------------------------------- class _State(TypedDict): messages: Annotated[list, add_messages] def _build_graph(caller, tools): sg = StateGraph(_State) sg.add_node("caller", caller) sg.add_node("tools", ToolNode(tools)) sg.add_edge(START, "caller") sg.add_edge("caller", "tools") sg.add_edge("tools", END) return sg.compile() class TestToolCallTransformerEndToEnd: def test_sync_streaming_tool_populates_tool_calls(self) -> None: @tool def streamer(text: str, runtime: ToolRuntime) -> str: """streams chunks.""" for chunk in ("one", "two"): runtime.emit_output_delta(chunk) return text def caller(state: _State) -> dict: return { "messages": [ AIMessage( content="", tool_calls=[ {"name": "streamer", "args": {"text": "x"}, "id": "tc1"} ], ) ] } graph = _build_graph(caller, [streamer]) run = graph.stream_events( {"messages": []}, transformers=[ToolCallTransformer], version="v3" ) tool_calls: list[ToolCallStream] = [] for tc in run.tool_calls: tool_calls.append(tc) deltas = list(tc.output_deltas) assert deltas == ["one", "two"] assert len(tool_calls) == 1 tc = tool_calls[0] assert tc.tool_call_id == "tc1" assert tc.tool_name == "streamer" assert tc.completed is True assert tc.error is None def test_stream_modes_union_includes_tools(self) -> None: @tool def echo(text: str) -> str: """echo.""" return text def caller(state: _State) -> dict: return { "messages": [ AIMessage( content="", tool_calls=[ {"name": "echo", "args": {"text": "x"}, "id": "tc1"} ], ) ] } graph = _build_graph(caller, [echo]) # Without ToolCallTransformer, no tool_calls projection is # exposed and no `tools` events flow through (required_stream_modes # omits it). run_no_tc = graph.stream_events({"messages": []}, version="v3") assert "tool_calls" not in run_no_tc._mux.extensions # type: ignore[attr-defined] # With ToolCallTransformer, the projection is present. run = graph.stream_events( {"messages": []}, transformers=[ToolCallTransformer], version="v3" ) assert "tool_calls" in run._mux.extensions # type: ignore[attr-defined] # Drain so the run closes cleanly. list(run.tool_calls) @pytest.mark.anyio async def test_async_streaming_tool_populates_tool_calls(self) -> None: @tool async def astreamer(text: str, runtime: ToolRuntime) -> str: """async streams.""" runtime.emit_output_delta(text) runtime.emit_output_delta(text + "!") return text async def caller(state: _State) -> dict: return { "messages": [ AIMessage( content="", tool_calls=[ {"name": "astreamer", "args": {"text": "hi"}, "id": "tc1"} ], ) ] } graph = _build_graph(caller, [astreamer]) run = await graph.astream_events( {"messages": []}, version="v3", transformers=[ToolCallTransformer] ) collected: list[ToolCallStream] = [] async for tc in run.tool_calls: collected.append(tc) deltas = [d async for d in tc.output_deltas] assert deltas == ["hi", "hi!"] assert len(collected) == 1 assert collected[0].completed is True assert collected[0].error is None def test_tool_error_populates_error_field(self) -> None: @tool def boom() -> str: """raises.""" raise ValueError("nope") def caller(state: _State) -> dict: return { "messages": [ AIMessage( content="", tool_calls=[{"name": "boom", "args": {}, "id": "tc1"}], ) ] } graph = _build_graph(caller, [boom]) run = graph.stream_events( {"messages": []}, transformers=[ToolCallTransformer], version="v3" ) collected: list[ToolCallStream] = [] with pytest.raises(ValueError, match="nope"): for tc in run.tool_calls: collected.append(tc) # Drain deltas so the error field is populated before we # inspect it below. list(tc.output_deltas) assert len(collected) == 1 assert collected[0].error == "nope" assert collected[0].output is None assert collected[0].completed is True