"""Tests for StreamToolCallHandler and ToolRuntime.emit_output_delta. These tests exercise the langgraph-core piece in isolation — the prebuilt `ToolCallTransformer` has its own test file. Here we feed real graphs through `Pregel.stream(stream_mode=["tools", ...])` and inspect the raw `(ns, mode, payload)` tuples on the `tools` channel. """ from __future__ import annotations from typing import Annotated, Any import pytest from langchain_core.messages import AIMessage from langchain_core.tools import tool from langgraph.prebuilt import ToolNode, ToolRuntime from typing_extensions import TypedDict from langgraph.constants import END, START from langgraph.graph import StateGraph from langgraph.graph.message import add_messages from langgraph.pregel._tools import _tool_call_writer class _State(TypedDict): messages: Annotated[list, add_messages] def _caller_sync(tool_name: str, tool_args: dict[str, Any], tc_id: str = "tc1"): def caller(state: _State) -> dict: return { "messages": [ AIMessage( content="", tool_calls=[{"name": tool_name, "args": tool_args, "id": tc_id}], ) ] } return caller def _caller_async(tool_name: str, tool_args: dict[str, Any], tc_id: str = "tc1"): async def caller(state: _State) -> dict: return { "messages": [ AIMessage( content="", tool_calls=[{"name": tool_name, "args": tool_args, "id": tc_id}], ) ] } return caller def _build_graph(caller, tools) -> Any: 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() def _tool_events(stream) -> list[tuple[tuple[str, ...], dict]]: """Collect `(ns, payload)` for every `tools`-mode chunk.""" out: list[tuple[tuple[str, ...], dict]] = [] for ns, mode, payload in stream: if mode == "tools": out.append((tuple(ns), payload)) return out class TestSyncGraphSyncTool: def test_started_finished_cycle(self) -> None: @tool def echo(text: str) -> str: """echo.""" return f"echoed:{text}" graph = _build_graph(_caller_sync("echo", {"text": "hi"}), [echo]) events = _tool_events( graph.stream( {"messages": []}, stream_mode=["tools"], subgraphs=True, ) ) assert [p["event"] for _, p in events] == [ "tool-started", "tool-finished", ] assert events[0][1]["tool_call_id"] == "tc1" assert events[0][1]["tool_name"] == "echo" assert events[0][1]["input"] == {"text": "hi"} # ToolNode wraps the return in a ToolMessage. assert events[1][1]["tool_call_id"] == "tc1" def test_emit_output_delta_produces_delta_events(self) -> None: @tool def streaming_echo(text: str, runtime: ToolRuntime) -> str: """stream chunks.""" for chunk in ("a", "b", "c"): runtime.emit_output_delta(chunk) return text graph = _build_graph( _caller_sync("streaming_echo", {"text": "x"}), [streaming_echo] ) events = _tool_events( graph.stream( {"messages": []}, stream_mode=["tools"], subgraphs=True, ) ) deltas = [p["delta"] for _, p in events if p["event"] == "tool-output-delta"] assert deltas == ["a", "b", "c"] # The deltas must be bracketed by started and finished. ordered = [p["event"] for _, p in events] assert ordered[0] == "tool-started" assert ordered[-1] == "tool-finished" def test_tool_error_event(self) -> None: @tool def boom() -> str: """raises.""" raise ValueError("nope") graph = _build_graph(_caller_sync("boom", {}), [boom]) events: list[tuple[tuple[str, ...], dict]] = [] with pytest.raises(ValueError, match="nope"): for ns, mode, payload in graph.stream( {"messages": []}, stream_mode=["tools"], subgraphs=True, ): if mode == "tools": events.append((tuple(ns), payload)) kinds = [p["event"] for _, p in events] assert kinds == ["tool-started", "tool-error"] assert events[1][1]["message"] == "nope" def test_writer_unset_outside_tool(self) -> None: # Outside any tool body the ContextVar that ToolRuntime reads # is unset — emitting from there would be a no-op. assert _tool_call_writer.get() is None def test_no_events_without_tools_mode(self) -> None: @tool def echo(text: str) -> str: """echo.""" return text graph = _build_graph(_caller_sync("echo", {"text": "hi"}), [echo]) # No "tools" in stream_mode — handler is not attached and zero # `tools`-method events fire. chunks = list( graph.stream( {"messages": []}, stream_mode=["values"], subgraphs=True, ) ) assert all( not (isinstance(c, tuple) and len(c) == 3 and c[1] == "tools") for c in chunks ) class TestAsyncGraphAsyncTool: @pytest.mark.anyio async def test_async_tool_produces_events(self) -> None: @tool async def aecho(text: str, runtime: ToolRuntime) -> str: """async echo.""" runtime.emit_output_delta(text) return f"got:{text}" graph = _build_graph(_caller_async("aecho", {"text": "hi"}), [aecho]) events: list[tuple[tuple[str, ...], dict]] = [] async for ns, mode, payload in graph.astream( {"messages": []}, stream_mode=["tools"], subgraphs=True, ): if mode == "tools": events.append((tuple(ns), payload)) kinds = [p["event"] for _, p in events] assert kinds == ["tool-started", "tool-output-delta", "tool-finished"] assert events[1][1]["delta"] == "hi" class TestConcurrentToolCalls: def test_parallel_tool_calls_do_not_bleed(self) -> None: @tool def streamer(marker: str, runtime: ToolRuntime) -> str: """emits marker twice.""" runtime.emit_output_delta(f"{marker}-1") runtime.emit_output_delta(f"{marker}-2") return marker def caller(state: _State) -> dict: return { "messages": [ AIMessage( content="", tool_calls=[ {"name": "streamer", "args": {"marker": "A"}, "id": "a"}, {"name": "streamer", "args": {"marker": "B"}, "id": "b"}, ], ) ] } graph = _build_graph(caller, [streamer]) events = _tool_events( graph.stream( {"messages": []}, stream_mode=["tools"], subgraphs=True, ) ) # Group deltas by tool_call_id. by_id: dict[str, list[str]] = {} for _, p in events: if p["event"] == "tool-output-delta": by_id.setdefault(p["tool_call_id"], []).append(p["delta"]) assert by_id["a"] == ["A-1", "A-2"] assert by_id["b"] == ["B-1", "B-2"] class TestSubgraphNamespacePropagation: def test_tool_inside_subgraph_emits_with_subgraph_ns(self) -> None: @tool def inner_tool(text: str) -> str: """inner tool.""" return text def sub_caller(state: _State) -> dict: return { "messages": [ AIMessage( content="", tool_calls=[ { "name": "inner_tool", "args": {"text": "x"}, "id": "tc1", } ], ) ] } inner = StateGraph(_State) inner.add_node("sub_caller", sub_caller) inner.add_node("sub_tools", ToolNode([inner_tool])) inner.add_edge(START, "sub_caller") inner.add_edge("sub_caller", "sub_tools") inner.add_edge("sub_tools", END) inner_graph = inner.compile() outer = StateGraph(_State) outer.add_node("sub", inner_graph) outer.add_edge(START, "sub") outer.add_edge("sub", END) graph = outer.compile() events = _tool_events( graph.stream( {"messages": []}, stream_mode=["tools"], subgraphs=True, ) ) # All `tools` events should carry a non-empty namespace rooted # at the `sub` node. assert events, "expected at least one tools event" for ns, _ in events: assert ns # non-empty assert ns[0].startswith("sub:")