import contextlib
import dataclasses
import json
import sys
from functools import partial
from typing import (
    Annotated,
    Any,
    NoReturn,
    TypeVar,
)
from unittest.mock import Mock

import pytest
from langchain_core.messages import (
    AIMessage,
    AnyMessage,
    HumanMessage,
    RemoveMessage,
    ToolCall,
    ToolMessage,
)
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 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.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.types import Command, Send
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import TypedDict

from langgraph.prebuilt import (
    InjectedState,
    InjectedStore,
    ToolNode,
)
from langgraph.prebuilt.tool_node import (
    TOOL_CALL_ERROR_TEMPLATE,
    ToolInvocationError,
    ToolRuntime,
    tools_condition,
)

from .messages import _AnyIdHumanMessage, _AnyIdToolMessage
from .model import FakeToolCallingModel

pytestmark = pytest.mark.anyio


def _create_mock_runtime(store: BaseStore | None = None) -> Mock:
    """Create a mock Runtime object for testing ToolNode outside of graph context.

    This helper is needed because ToolNode._func expects a Runtime parameter
    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
    mock_runtime.context = None
    mock_runtime.stream_writer = lambda *args, **kwargs: None
    mock_runtime.execution_info = ExecutionInfo(
        checkpoint_id="test-cp", checkpoint_ns="", task_id="test-task"
    )
    mock_runtime.server_info = None
    return mock_runtime


def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig:
    """Create a RunnableConfig with mock Runtime for testing ToolNode.

    Returns:
        RunnableConfig with __pregel_runtime in configurable dict.
    """
    return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}}


def tool1(some_val: int, some_other_val: str) -> str:
    """Tool 1 docstring."""
    if some_val == 0:
        msg = "Test error"
        raise ValueError(msg)
    return f"{some_val} - {some_other_val}"


async def tool2(some_val: int, some_other_val: str) -> str:
    """Tool 2 docstring."""
    if some_val == 0:
        msg = "Test error"
        raise ToolException(msg)
    return f"tool2: {some_val} - {some_other_val}"


async def tool3(some_val: int, some_other_val: str) -> str:
    """Tool 3 docstring."""
    return [
        {"key_1": some_val, "key_2": "foo"},
        {"key_1": some_other_val, "key_2": "baz"},
    ]


async def tool4(some_val: int, some_other_val: str) -> str:
    """Tool 4 docstring."""
    return [
        {"type": "image_url", "image_url": {"url": "abdc"}},
    ]


@dec_tool
def tool5(some_val: int) -> NoReturn:
    """Tool 5 docstring."""
    msg = "Test error"
    raise ToolException(msg)


tool5.handle_tool_error = "foo"


async def test_tool_node() -> None:
    """Test tool node."""
    result = ToolNode([tool1]).invoke(
        {
            "messages": [
                AIMessage(
                    "hi?",
                    tool_calls=[
                        {
                            "name": "tool1",
                            "args": {"some_val": 1, "some_other_val": "foo"},
                            "id": "some 0",
                        }
                    ],
                )
            ]
        },
        config=_create_config_with_runtime(),
    )

    tool_message: ToolMessage = result["messages"][-1]
    assert tool_message.type == "tool"
    assert tool_message.content == "1 - foo"
    assert tool_message.tool_call_id == "some 0"

    result2 = await ToolNode([tool2]).ainvoke(
        {
            "messages": [
                AIMessage(
                    "hi?",
                    tool_calls=[
                        {
                            "name": "tool2",
                            "args": {"some_val": 2, "some_other_val": "bar"},
                            "id": "some 1",
                        }
                    ],
                )
            ]
        },
        config=_create_config_with_runtime(),
    )

    tool_message: ToolMessage = result2["messages"][-1]
    assert tool_message.type == "tool"
    assert tool_message.content == "tool2: 2 - bar"

    # list of dicts tool content
    result3 = await ToolNode([tool3]).ainvoke(
        {
            "messages": [
                AIMessage(
                    "hi?",
                    tool_calls=[
                        {
                            "name": "tool3",
                            "args": {"some_val": 2, "some_other_val": "bar"},
                            "id": "some 2",
                        }
                    ],
                )
            ]
        },
        config=_create_config_with_runtime(),
    )
    tool_message: ToolMessage = result3["messages"][-1]
    assert tool_message.type == "tool"
    assert (
        tool_message.content
        == '[{"key_1": 2, "key_2": "foo"}, {"key_1": "bar", "key_2": "baz"}]'
    )
    assert tool_message.tool_call_id == "some 2"

    # list of content blocks tool content
    result4 = await ToolNode([tool4]).ainvoke(
        {
            "messages": [
                AIMessage(
                    "hi?",
                    tool_calls=[
                        {
                            "name": "tool4",
                            "args": {"some_val": 2, "some_other_val": "bar"},
                            "id": "some 3",
                        }
                    ],
                )
            ]
        },
        config=_create_config_with_runtime(),
    )
    tool_message: ToolMessage = result4["messages"][-1]
    assert tool_message.type == "tool"
    assert tool_message.content == [{"type": "image_url", "image_url": {"url": "abdc"}}]
    assert tool_message.tool_call_id == "some 3"


async def test_tool_node_tool_call_input() -> None:
    # Single tool call
    tool_call_1 = {
        "name": "tool1",
        "args": {"some_val": 1, "some_other_val": "foo"},
        "id": "some 0",
        "type": "tool_call",
    }
    result = ToolNode([tool1]).invoke(
        [tool_call_1], config=_create_config_with_runtime()
    )
    assert result["messages"] == [
        ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"),
    ]

    # Multiple tool calls
    tool_call_2 = {
        "name": "tool1",
        "args": {"some_val": 2, "some_other_val": "bar"},
        "id": "some 1",
        "type": "tool_call",
    }
    result = ToolNode([tool1]).invoke(
        [tool_call_1, tool_call_2], config=_create_config_with_runtime()
    )
    assert result["messages"] == [
        ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"),
        ToolMessage(content="2 - bar", tool_call_id="some 1", name="tool1"),
    ]

    # Test with unknown tool
    tool_call_3 = tool_call_1.copy()
    tool_call_3["name"] = "tool2"
    result = ToolNode([tool1]).invoke(
        [tool_call_1, tool_call_3], config=_create_config_with_runtime()
    )
    assert result["messages"] == [
        ToolMessage(content="1 - foo", tool_call_id="some 0", name="tool1"),
        ToolMessage(
            content="Error: tool2 is not a valid tool, try one of [tool1].",
            name="tool2",
            tool_call_id="some 0",
            status="error",
        ),
    ]


def test_tool_node_error_handling_default_invocation() -> None:
    tn = ToolNode([tool1])
    result = tn.invoke(
        {
            "messages": [
                AIMessage(
                    "hi?",
                    tool_calls=[
                        {
                            "name": "tool1",
                            "args": {"invalid": 0, "args": "foo"},
                            "id": "some id",
                        },
                    ],
                )
            ]
        },
        config=_create_config_with_runtime(),
    )

    assert all(m.type == "tool" for m in result["messages"])
    assert all(m.status == "error" for m in result["messages"])
    assert (
        "Error invoking tool 'tool1' with kwargs {'invalid': 0, 'args': 'foo'} with error:\n"
        in result["messages"][0].content
    )


def test_tool_node_error_handling_default_exception() -> None:
    tn = ToolNode([tool1])
    with pytest.raises(ValueError):
        tn.invoke(
            {
                "messages": [
                    AIMessage(
                        "hi?",
                        tool_calls=[
                            {
                                "name": "tool1",
                                "args": {"some_val": 0, "some_other_val": "foo"},
                                "id": "some id",
                            },
                        ],
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )


async def test_tool_node_error_handling() -> None:
    def handle_all(e: ValueError | ToolException | ToolInvocationError):
        return TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e))

    # test catching all exceptions, via:
    # - handle_tool_errors = True
    # - passing a tuple of all exceptions
    # - passing a callable with all exceptions in the signature
    for handle_tool_errors in (
        True,
        (ValueError, ToolException, ToolInvocationError),
        handle_all,
    ):
        result_error = await ToolNode(
            [tool1, tool2, tool3], handle_tool_errors=handle_tool_errors
        ).ainvoke(
            {
                "messages": [
                    AIMessage(
                        "hi?",
                        tool_calls=[
                            {
                                "name": "tool1",
                                "args": {"some_val": 0, "some_other_val": "foo"},
                                "id": "some id",
                            },
                            {
                                "name": "tool2",
                                "args": {"some_val": 0, "some_other_val": "bar"},
                                "id": "some other id",
                            },
                            {
                                "name": "tool3",
                                "args": {"some_val": 0},
                                "id": "another id",
                            },
                        ],
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )

        assert all(m.type == "tool" for m in result_error["messages"])
        assert all(m.status == "error" for m in result_error["messages"])
        assert (
            result_error["messages"][0].content
            == f"Error: {ValueError('Test error')!r}\n Please fix your mistakes."
        )
        assert (
            result_error["messages"][1].content
            == f"Error: {ToolException('Test error')!r}\n Please fix your mistakes."
        )
        # Check that the validation error contains the field name
        assert "some_other_val" in result_error["messages"][2].content

        assert result_error["messages"][0].tool_call_id == "some id"
        assert result_error["messages"][1].tool_call_id == "some other id"
        assert result_error["messages"][2].tool_call_id == "another id"


async def test_tool_node_error_handling_callable() -> None:
    def handle_value_error(e: ValueError) -> str:
        return "Value error"

    def handle_tool_exception(e: ToolException) -> str:
        return "Tool exception"

    for handle_tool_errors in ("Value error", handle_value_error):
        result_error = await ToolNode(
            [tool1], handle_tool_errors=handle_tool_errors
        ).ainvoke(
            {
                "messages": [
                    AIMessage(
                        "hi?",
                        tool_calls=[
                            {
                                "name": "tool1",
                                "args": {"some_val": 0, "some_other_val": "foo"},
                                "id": "some id",
                            },
                        ],
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )
        tool_message: ToolMessage = result_error["messages"][-1]
        assert tool_message.type == "tool"
        assert tool_message.status == "error"
        assert tool_message.content == "Value error"

    # test raising for an unhandled exception, via:
    # - passing a tuple of all exceptions
    # - passing a callable with all exceptions in the signature
    for handle_tool_errors in ((ValueError,), handle_value_error):
        with pytest.raises(ToolException) as exc_info:
            await ToolNode(
                [tool1, tool2], handle_tool_errors=handle_tool_errors
            ).ainvoke(
                {
                    "messages": [
                        AIMessage(
                            "hi?",
                            tool_calls=[
                                {
                                    "name": "tool1",
                                    "args": {"some_val": 0, "some_other_val": "foo"},
                                    "id": "some id",
                                },
                                {
                                    "name": "tool2",
                                    "args": {"some_val": 0, "some_other_val": "bar"},
                                    "id": "some other id",
                                },
                            ],
                        )
                    ]
                },
                config=_create_config_with_runtime(),
            )
        assert str(exc_info.value) == "Test error"

    for handle_tool_errors in ((ToolException,), handle_tool_exception):
        with pytest.raises(ValueError) as exc_info:
            await ToolNode(
                [tool1, tool2], handle_tool_errors=handle_tool_errors
            ).ainvoke(
                {
                    "messages": [
                        AIMessage(
                            "hi?",
                            tool_calls=[
                                {
                                    "name": "tool1",
                                    "args": {"some_val": 0, "some_other_val": "foo"},
                                    "id": "some id",
                                },
                                {
                                    "name": "tool2",
                                    "args": {"some_val": 0, "some_other_val": "bar"},
                                    "id": "some other id",
                                },
                            ],
                        )
                    ]
                },
                config=_create_config_with_runtime(),
            )
        assert str(exc_info.value) == "Test error"


async def test_tool_node_handle_tool_errors_false() -> None:
    with pytest.raises(ValueError) as exc_info:
        ToolNode([tool1], handle_tool_errors=False).invoke(
            {
                "messages": [
                    AIMessage(
                        "hi?",
                        tool_calls=[
                            {
                                "name": "tool1",
                                "args": {"some_val": 0, "some_other_val": "foo"},
                                "id": "some id",
                            }
                        ],
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )

    assert str(exc_info.value) == "Test error"

    with pytest.raises(ToolException):
        await ToolNode([tool2], handle_tool_errors=False).ainvoke(
            {
                "messages": [
                    AIMessage(
                        "hi?",
                        tool_calls=[
                            {
                                "name": "tool2",
                                "args": {"some_val": 0, "some_other_val": "bar"},
                                "id": "some id",
                            }
                        ],
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )

    assert str(exc_info.value) == "Test error"

    # test validation errors get raised if handle_tool_errors is False
    with pytest.raises(ToolInvocationError):
        ToolNode([tool1], handle_tool_errors=False).invoke(
            {
                "messages": [
                    AIMessage(
                        "hi?",
                        tool_calls=[
                            {
                                "name": "tool1",
                                "args": {"some_val": 0},
                                "id": "some id",
                            }
                        ],
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )


def test_tool_node_individual_tool_error_handling() -> None:
    # test error handling on individual tools (and that it overrides overall error handling!)
    result_individual_tool_error_handler = ToolNode(
        [tool5], handle_tool_errors="bar"
    ).invoke(
        {
            "messages": [
                AIMessage(
                    "hi?",
                    tool_calls=[
                        {
                            "name": "tool5",
                            "args": {"some_val": 0},
                            "id": "some 0",
                        }
                    ],
                )
            ]
        },
        config=_create_config_with_runtime(),
    )

    tool_message: ToolMessage = result_individual_tool_error_handler["messages"][-1]
    assert tool_message.type == "tool"
    assert tool_message.status == "error"
    assert tool_message.content == "foo"
    assert tool_message.tool_call_id == "some 0"


def test_tool_node_incorrect_tool_name() -> None:
    result_incorrect_name = ToolNode([tool1, tool2]).invoke(
        {
            "messages": [
                AIMessage(
                    "hi?",
                    tool_calls=[
                        {
                            "name": "tool3",
                            "args": {"some_val": 1, "some_other_val": "foo"},
                            "id": "some 0",
                        }
                    ],
                )
            ]
        },
        config=_create_config_with_runtime(),
    )

    tool_message: ToolMessage = result_incorrect_name["messages"][-1]
    assert tool_message.type == "tool"
    assert tool_message.status == "error"
    assert (
        tool_message.content
        == "Error: tool3 is not a valid tool, try one of [tool1, tool2]."
    )
    assert tool_message.tool_call_id == "some 0"


def test_tool_node_node_interrupt() -> None:
    def tool_interrupt(some_val: int) -> None:
        """Tool docstring."""
        msg = "foo"
        raise GraphBubbleUp(msg)

    def handle(e: GraphInterrupt) -> str:
        return "handled"

    for handle_tool_errors in (True, (GraphBubbleUp,), "handled", handle, False):
        node = ToolNode([tool_interrupt], handle_tool_errors=handle_tool_errors)
        with pytest.raises(GraphBubbleUp) as exc_info:
            node.invoke(
                {
                    "messages": [
                        AIMessage(
                            "hi?",
                            tool_calls=[
                                {
                                    "name": "tool_interrupt",
                                    "args": {"some_val": 0},
                                    "id": "some 0",
                                }
                            ],
                        )
                    ]
                },
                config=_create_config_with_runtime(),
            )
            assert exc_info.value == "foo"


@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]):
        """Transfer to Bob"""
        return Command(
            update={
                "messages": [
                    ToolMessage(content="Transferred to Bob", tool_call_id=tool_call_id)
                ]
            },
            goto="bob",
            graph=Command.PARENT,
        )

    @dec_tool
    async def async_transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]):
        """Transfer to Bob"""
        return Command(
            update={
                "messages": [
                    ToolMessage(content="Transferred to Bob", tool_call_id=tool_call_id)
                ]
            },
            goto="bob",
            graph=Command.PARENT,
        )

    class CustomToolSchema(BaseModel):
        tool_call_id: Annotated[str, InjectedToolCallId]

    class MyCustomTool(BaseTool):
        def _run(*args: Any, **kwargs: Any):
            return Command(
                update={
                    "messages": [
                        ToolMessage(
                            content="Transferred to Bob",
                            tool_call_id=kwargs["tool_call_id"],
                        )
                    ]
                },
                goto="bob",
                graph=Command.PARENT,
            )

        async def _arun(*args: Any, **kwargs: Any):
            return Command(
                update={
                    "messages": [
                        ToolMessage(
                            content="Transferred to Bob",
                            tool_call_id=kwargs["tool_call_id"],
                        )
                    ]
                },
                goto="bob",
                graph=Command.PARENT,
            )

    custom_tool = MyCustomTool(
        name="custom_transfer_to_bob",
        description="Transfer to bob",
        args_schema=CustomToolSchema,
    )
    async_custom_tool = MyCustomTool(
        name="async_custom_transfer_to_bob",
        description="Transfer to bob",
        args_schema=CustomToolSchema,
    )

    # test mixing regular tools and tools returning commands
    def add(a: int, b: int) -> int:
        """Add two numbers"""
        return a + b

    tool_calls = [
        {"args": {"a": 1, "b": 2}, "id": "1", "name": "add", "type": "tool_call"},
        {"args": {}, "id": "2", "name": "transfer_to_bob", "type": "tool_call"},
    ]
    if input_type == "dict":
        input_ = {"messages": [AIMessage("", tool_calls=tool_calls)]}
    elif input_type == "tool_calls":
        input_ = tool_calls
    result = ToolNode([add, transfer_to_bob]).invoke(
        input_, config=_create_config_with_runtime()
    )

    assert result == [
        {
            "messages": [
                ToolMessage(
                    content="3",
                    tool_call_id="1",
                    name="add",
                )
            ]
        },
        Command(
            update={
                "messages": [
                    ToolMessage(
                        content="Transferred to Bob",
                        tool_call_id="2",
                        name="transfer_to_bob",
                    )
                ]
            },
            goto="bob",
            graph=Command.PARENT,
        ),
    ]

    # test tools returning commands

    # test sync tools
    for tool in [transfer_to_bob, custom_tool]:
        result = ToolNode([tool]).invoke(
            {
                "messages": [
                    AIMessage(
                        "", tool_calls=[{"args": {}, "id": "1", "name": tool.name}]
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )
        assert result == [
            Command(
                update={
                    "messages": [
                        ToolMessage(
                            content="Transferred to Bob",
                            tool_call_id="1",
                            name=tool.name,
                        )
                    ]
                },
                goto="bob",
                graph=Command.PARENT,
            )
        ]

    # test async tools
    for tool in [async_transfer_to_bob, async_custom_tool]:
        result = await ToolNode([tool]).ainvoke(
            {
                "messages": [
                    AIMessage(
                        "", tool_calls=[{"args": {}, "id": "1", "name": tool.name}]
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )
        assert result == [
            Command(
                update={
                    "messages": [
                        ToolMessage(
                            content="Transferred to Bob",
                            tool_call_id="1",
                            name=tool.name,
                        )
                    ]
                },
                goto="bob",
                graph=Command.PARENT,
            )
        ]

    # test multiple commands
    result = ToolNode([transfer_to_bob, custom_tool]).invoke(
        {
            "messages": [
                AIMessage(
                    "",
                    tool_calls=[
                        {"args": {}, "id": "1", "name": "transfer_to_bob"},
                        {"args": {}, "id": "2", "name": "custom_transfer_to_bob"},
                    ],
                )
            ]
        },
        config=_create_config_with_runtime(),
    )
    assert result == [
        Command(
            update={
                "messages": [
                    ToolMessage(
                        content="Transferred to Bob",
                        tool_call_id="1",
                        name="transfer_to_bob",
                    )
                ]
            },
            goto="bob",
            graph=Command.PARENT,
        ),
        Command(
            update={
                "messages": [
                    ToolMessage(
                        content="Transferred to Bob",
                        tool_call_id="2",
                        name="custom_transfer_to_bob",
                    )
                ]
            },
            goto="bob",
            graph=Command.PARENT,
        ),
    ]

    # test validation (mismatch between input type and command.update type)
    with pytest.raises(ValueError):

        @dec_tool
        def list_update_tool(tool_call_id: Annotated[str, InjectedToolCallId]):
            """My tool"""
            return Command(
                update=[ToolMessage(content="foo", tool_call_id=tool_call_id)]
            )

        ToolNode([list_update_tool]).invoke(
            {
                "messages": [
                    AIMessage(
                        "",
                        tool_calls=[
                            {"args": {}, "id": "1", "name": "list_update_tool"}
                        ],
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )

    # test validation (missing tool message in the update for current graph)
    with pytest.raises(ValueError):

        @dec_tool
        def no_update_tool():
            """My tool"""
            return Command(update={"messages": []})

        ToolNode([no_update_tool]).invoke(
            {
                "messages": [
                    AIMessage(
                        "",
                        tool_calls=[{"args": {}, "id": "1", "name": "no_update_tool"}],
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )

    # test validation (tool message with a wrong tool call ID)
    with pytest.raises(ValueError):

        @dec_tool
        def mismatching_tool_call_id_tool():
            """My tool"""
            return Command(
                update={"messages": [ToolMessage(content="foo", tool_call_id="2")]}
            )

        ToolNode([mismatching_tool_call_id_tool]).invoke(
            {
                "messages": [
                    AIMessage(
                        "",
                        tool_calls=[
                            {
                                "args": {},
                                "id": "1",
                                "name": "mismatching_tool_call_id_tool",
                            }
                        ],
                    )
                ]
            },
            config=_create_config_with_runtime(),
        )

    # test validation (missing tool message in the update for parent graph is OK)
    @dec_tool
    def node_update_parent_tool():
        """No update"""
        return Command(update={"messages": []}, graph=Command.PARENT)

    assert ToolNode([node_update_parent_tool]).invoke(
        {
            "messages": [
                AIMessage(
                    "",
                    tool_calls=[
                        {"args": {}, "id": "1", "name": "node_update_parent_tool"}
                    ],
                )
            ]
        },
        config=_create_config_with_runtime(),
    ) == [Command(update={"messages": []}, graph=Command.PARENT)]


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]):
        """Transfer to Bob"""
        return Command(
            update=[
                ToolMessage(content="Transferred to Bob", tool_call_id=tool_call_id)
            ],
            goto="bob",
            graph=Command.PARENT,
        )

    @dec_tool
    async def async_transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]):
        """Transfer to Bob"""
        return Command(
            update=[
                ToolMessage(content="Transferred to Bob", tool_call_id=tool_call_id)
            ],
            goto="bob",
            graph=Command.PARENT,
        )

    class CustomToolSchema(BaseModel):
        tool_call_id: Annotated[str, InjectedToolCallId]

    class MyCustomTool(BaseTool):
        def _run(*args: Any, **kwargs: Any):
            return Command(
                update=[
                    ToolMessage(
                        content="Transferred to Bob",
                        tool_call_id=kwargs["tool_call_id"],
                    )
                ],
                goto="bob",
                graph=Command.PARENT,
            )

        async def _arun(*args: Any, **kwargs: Any):
            return Command(
                update=[
                    ToolMessage(
                        content="Transferred to Bob",
                        tool_call_id=kwargs["tool_call_id"],
                    )
                ],
                goto="bob",
                graph=Command.PARENT,
            )

    custom_tool = MyCustomTool(
        name="custom_transfer_to_bob",
        description="Transfer to bob",
        args_schema=CustomToolSchema,
    )
    async_custom_tool = MyCustomTool(
        name="async_custom_transfer_to_bob",
        description="Transfer to bob",
        args_schema=CustomToolSchema,
    )

    # test mixing regular tools and tools returning commands
    def add(a: int, b: int) -> int:
        """Add two numbers"""
        return a + b

    result = ToolNode([add, transfer_to_bob]).invoke(
        [
            AIMessage(
                "",
                tool_calls=[
                    {"args": {"a": 1, "b": 2}, "id": "1", "name": "add"},
                    {"args": {}, "id": "2", "name": "transfer_to_bob"},
                ],
            )
        ],
        config=_create_config_with_runtime(),
    )

    assert result == [
        [
            ToolMessage(
                content="3",
                tool_call_id="1",
                name="add",
            )
        ],
        Command(
            update=[
                ToolMessage(
                    content="Transferred to Bob",
                    tool_call_id="2",
                    name="transfer_to_bob",
                )
            ],
            goto="bob",
            graph=Command.PARENT,
        ),
    ]

    # test tools returning commands

    # test sync tools
    for tool in [transfer_to_bob, custom_tool]:
        result = ToolNode([tool]).invoke(
            [AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])],
            config=_create_config_with_runtime(),
        )
        assert result == [
            Command(
                update=[
                    ToolMessage(
                        content="Transferred to Bob",
                        tool_call_id="1",
                        name=tool.name,
                    )
                ],
                goto="bob",
                graph=Command.PARENT,
            )
        ]

    # test async tools
    for tool in [async_transfer_to_bob, async_custom_tool]:
        result = await ToolNode([tool]).ainvoke(
            [AIMessage("", tool_calls=[{"args": {}, "id": "1", "name": tool.name}])],
            config=_create_config_with_runtime(),
        )
        assert result == [
            Command(
                update=[
                    ToolMessage(
                        content="Transferred to Bob",
                        tool_call_id="1",
                        name=tool.name,
                    )
                ],
                goto="bob",
                graph=Command.PARENT,
            )
        ]

    # test multiple commands
    result = ToolNode([transfer_to_bob, custom_tool]).invoke(
        [
            AIMessage(
                "",
                tool_calls=[
                    {"args": {}, "id": "1", "name": "transfer_to_bob"},
                    {"args": {}, "id": "2", "name": "custom_transfer_to_bob"},
                ],
            )
        ],
        config=_create_config_with_runtime(),
    )
    assert result == [
        Command(
            update=[
                ToolMessage(
                    content="Transferred to Bob",
                    tool_call_id="1",
                    name="transfer_to_bob",
                )
            ],
            goto="bob",
            graph=Command.PARENT,
        ),
        Command(
            update=[
                ToolMessage(
                    content="Transferred to Bob",
                    tool_call_id="2",
                    name="custom_transfer_to_bob",
                )
            ],
            goto="bob",
            graph=Command.PARENT,
        ),
    ]

    # test validation (mismatch between input type and command.update type)
    with pytest.raises(ValueError):

        @dec_tool
        def list_update_tool(tool_call_id: Annotated[str, InjectedToolCallId]):
            """My tool"""
            return Command(
                update={
                    "messages": [ToolMessage(content="foo", tool_call_id=tool_call_id)]
                }
            )

        ToolNode([list_update_tool]).invoke(
            [
                AIMessage(
                    "",
                    tool_calls=[{"args": {}, "id": "1", "name": "list_update_tool"}],
                )
            ],
            config=_create_config_with_runtime(),
        )

    # test validation (missing tool message in the update for current graph)
    with pytest.raises(ValueError):

        @dec_tool
        def no_update_tool():
            """My tool"""
            return Command(update=[])

        ToolNode([no_update_tool]).invoke(
            [
                AIMessage(
                    "",
                    tool_calls=[{"args": {}, "id": "1", "name": "no_update_tool"}],
                )
            ],
            config=_create_config_with_runtime(),
        )

    # test validation (tool message with a wrong tool call ID)
    with pytest.raises(ValueError):

        @dec_tool
        def mismatching_tool_call_id_tool():
            """My tool"""
            return Command(update=[ToolMessage(content="foo", tool_call_id="2")])

        ToolNode([mismatching_tool_call_id_tool]).invoke(
            [
                AIMessage(
                    "",
                    tool_calls=[
                        {"args": {}, "id": "1", "name": "mismatching_tool_call_id_tool"}
                    ],
                )
            ],
            config=_create_config_with_runtime(),
        )

    # test validation (missing tool message in the update for parent graph is OK)
    @dec_tool
    def node_update_parent_tool():
        """No update"""
        return Command(update=[], graph=Command.PARENT)

    assert ToolNode([node_update_parent_tool]).invoke(
        [
            AIMessage(
                "",
                tool_calls=[{"args": {}, "id": "1", "name": "node_update_parent_tool"}],
            )
        ],
        config=_create_config_with_runtime(),
    ) == [Command(update=[], graph=Command.PARENT)]


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]):
        """Transfer to Alice"""
        return Command(
            goto=[
                Send(
                    "alice",
                    {
                        "messages": [
                            ToolMessage(
                                content="Transferred to Alice",
                                name="transfer_to_alice",
                                tool_call_id=tool_call_id,
                            )
                        ]
                    },
                )
            ],
            graph=Command.PARENT,
        )

    @dec_tool
    def transfer_to_bob(tool_call_id: Annotated[str, InjectedToolCallId]):
        """Transfer to Bob"""
        return Command(
            goto=[
                Send(
                    "bob",
                    {
                        "messages": [
                            ToolMessage(
                                content="Transferred to Bob",
                                name="transfer_to_bob",
                                tool_call_id=tool_call_id,
                            )
                        ]
                    },
                )
            ],
            graph=Command.PARENT,
        )

    tool_calls = [
        {"args": {}, "id": "1", "name": "transfer_to_alice", "type": "tool_call"},
        {"args": {}, "id": "2", "name": "transfer_to_bob", "type": "tool_call"},
    ]

    result = ToolNode([transfer_to_alice, transfer_to_bob]).invoke(
        [AIMessage("", tool_calls=tool_calls)],
        config=_create_config_with_runtime(),
    )

    assert result == [
        Command(
            goto=[
                Send(
                    "alice",
                    {
                        "messages": [
                            ToolMessage(
                                content="Transferred to Alice",
                                name="transfer_to_alice",
                                tool_call_id="1",
                            )
                        ]
                    },
                ),
                Send(
                    "bob",
                    {
                        "messages": [
                            ToolMessage(
                                content="Transferred to Bob",
                                name="transfer_to_bob",
                                tool_call_id="2",
                            )
                        ]
                    },
                ),
            ],
            graph=Command.PARENT,
        )
    ]


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]):
        """A tool that removes all messages."""
        return Command(update={"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]})

    tool_node = ToolNode([remove_all_messages_tool])
    tool_call = {
        "name": "remove_all_messages_tool",
        "args": {},
        "id": "tool_call_123",
    }
    result = await tool_node.ainvoke(
        {"messages": [AIMessage(content="", tool_calls=[tool_call])]},
        config=_create_config_with_runtime(),
    )

    assert isinstance(result, list)
    assert len(result) == 1
    command = result[0]
    assert isinstance(command, Command)
    assert command.update == {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}


class _InjectStateSchema(TypedDict):
    messages: list
    foo: str


class _InjectedStatePydanticV2Schema(BaseModel):
    messages: list
    foo: str


@dataclasses.dataclass
class _InjectedStateDataclassSchema:
    messages: list
    foo: str


_INJECTED_STATE_SCHEMAS = [
    _InjectStateSchema,
    _InjectedStatePydanticV2Schema,
    _InjectedStateDataclassSchema,
]

if sys.version_info < (3, 14):

    class _InjectedStatePydanticSchema(BaseModelV1):
        messages: list
        foo: str

    _INJECTED_STATE_SCHEMAS.append(_InjectedStatePydanticSchema)

T = TypeVar("T")


@pytest.mark.parametrize("schema_", _INJECTED_STATE_SCHEMAS)
def test_tool_node_inject_state(schema_: type[T]) -> None:
    def tool1(some_val: int, state: Annotated[T, InjectedState]) -> str:
        """Tool 1 docstring."""
        if isinstance(state, dict):
            return state["foo"]
        return state.foo

    def tool2(some_val: int, state: Annotated[T, InjectedState()]) -> str:
        """Tool 2 docstring."""
        if isinstance(state, dict):
            return state["foo"]
        return state.foo

    def tool3(
        some_val: int,
        foo: Annotated[str, InjectedState("foo")],
        msgs: Annotated[list[AnyMessage], InjectedState("messages")],
    ) -> str:
        """Tool 1 docstring."""
        return foo

    def tool4(
        some_val: int, msgs: Annotated[list[AnyMessage], InjectedState("messages")]
    ) -> str:
        """Tool 1 docstring."""
        return msgs[0].content

    node = ToolNode([tool1, tool2, tool3, tool4], handle_tool_errors=True)
    for tool_name in ("tool1", "tool2", "tool3"):
        tool_call = {
            "name": tool_name,
            "args": {"some_val": 1},
            "id": "some 0",
            "type": "tool_call",
        }
        msg = AIMessage("hi?", tool_calls=[tool_call])
        result = node.invoke(
            schema_(messages=[msg], foo="bar"), config=_create_config_with_runtime()
        )
        tool_message = result["messages"][-1]
        assert tool_message.content == "bar", f"Failed for tool={tool_name}"

        if tool_name == "tool3":
            failure_input = None
            with contextlib.suppress(Exception):
                failure_input = schema_(messages=[msg], notfoo="bar")
            if failure_input is not None:
                with pytest.raises(KeyError):
                    node.invoke(failure_input, config=_create_config_with_runtime())

                with pytest.raises(ValueError):
                    node.invoke([msg], config=_create_config_with_runtime())
        else:
            failure_input = None
            try:
                failure_input = schema_(messages=[msg], notfoo="bar")
            except Exception:
                # We'd get a validation error from pydantic state and wouldn't make it to the node
                # anyway
                pass
            if failure_input is not None:
                messages_ = node.invoke(
                    failure_input, config=_create_config_with_runtime()
                )
                tool_message = messages_["messages"][-1]
                assert "KeyError" in tool_message.content
                tool_message = node.invoke([msg], config=_create_config_with_runtime())[
                    -1
                ]
                assert "KeyError" in tool_message.content

    tool_call = {
        "name": "tool4",
        "args": {"some_val": 1},
        "id": "some 0",
        "type": "tool_call",
    }
    msg = AIMessage("hi?", tool_calls=[tool_call])
    result = node.invoke(
        schema_(messages=[msg], foo=""), config=_create_config_with_runtime()
    )
    tool_message = result["messages"][-1]
    assert tool_message.content == "hi?"

    result = node.invoke([msg], config=_create_config_with_runtime())
    tool_message = result[-1]
    assert tool_message.content == "hi?"


def test_tool_node_inject_store() -> None:
    store = InMemoryStore()
    namespace = ("test",)

    def tool1(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str:
        """Tool 1 docstring."""
        store_val = store.get(namespace, "test_key").value["foo"]
        return f"Some val: {some_val}, store val: {store_val}"

    def tool2(some_val: int, store: Annotated[BaseStore, InjectedStore()]) -> str:
        """Tool 2 docstring."""
        store_val = store.get(namespace, "test_key").value["foo"]
        return f"Some val: {some_val}, store val: {store_val}"

    def tool3(
        some_val: int,
        bar: Annotated[str, InjectedState("bar")],
        store: Annotated[BaseStore, InjectedStore()],
    ) -> str:
        """Tool 3 docstring."""
        store_val = store.get(namespace, "test_key").value["foo"]
        return f"Some val: {some_val}, store val: {store_val}, state val: {bar}"

    node = ToolNode([tool1, tool2, tool3], handle_tool_errors=True)
    store.put(namespace, "test_key", {"foo": "bar"})

    class State(MessagesState):
        bar: str

    builder = StateGraph(State)
    builder.add_node("tools", node)
    builder.add_edge(START, "tools")
    graph = builder.compile(store=store)

    for tool_name in ("tool1", "tool2"):
        tool_call = {
            "name": tool_name,
            "args": {"some_val": 1},
            "id": "some 0",
            "type": "tool_call",
        }
        msg = AIMessage("hi?", tool_calls=[tool_call])
        node_result = node.invoke(
            {"messages": [msg]}, config=_create_config_with_runtime(store=store)
        )
        graph_result = graph.invoke({"messages": [msg]})
        for result in (node_result, graph_result):
            result["messages"][-1]
            tool_message = result["messages"][-1]
            assert tool_message.content == "Some val: 1, store val: bar", (
                f"Failed for tool={tool_name}"
            )

    tool_call = {
        "name": "tool3",
        "args": {"some_val": 1},
        "id": "some 0",
        "type": "tool_call",
    }
    msg = AIMessage("hi?", tool_calls=[tool_call])
    node_result = node.invoke(
        {"messages": [msg], "bar": "baz"},
        config=_create_config_with_runtime(store=store),
    )
    graph_result = graph.invoke({"messages": [msg], "bar": "baz"})
    for result in (node_result, graph_result):
        result["messages"][-1]
        tool_message = result["messages"][-1]
        assert tool_message.content == "Some val: 1, store val: bar, state val: baz", (
            f"Failed for tool={tool_name}"
        )

    # test injected store without passing store to compiled graph
    failing_graph = builder.compile()
    with pytest.raises(ValueError):
        failing_graph.invoke({"messages": [msg], "bar": "baz"})


def test_tool_node_ensure_utf8() -> None:
    @dec_tool
    def get_day_list(days: list[str]) -> list[str]:
        """choose days"""
        return days

    data = ["星期一", "水曜日", "목요일", "Friday"]
    tools = [get_day_list]
    tool_calls = [ToolCall(name=get_day_list.name, args={"days": data}, id="test_id")]
    outputs: list[ToolMessage] = ToolNode(tools).invoke(
        [AIMessage(content="", tool_calls=tool_calls)],
        config=_create_config_with_runtime(),
    )
    assert outputs[0].content == json.dumps(data, ensure_ascii=False)


def test_tool_node_messages_key() -> None:
    @dec_tool
    def add(a: int, b: int) -> int:
        """Adds a and b."""
        return a + b

    model = FakeToolCallingModel(
        tool_calls=[[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")]]
    )

    class State(TypedDict):
        subgraph_messages: Annotated[list[AnyMessage], add_messages]

    def call_model(state: State) -> dict[str, Any]:
        response = model.invoke(state["subgraph_messages"])
        model.tool_calls = []
        return {"subgraph_messages": response}

    builder = StateGraph(State)
    builder.add_node("agent", call_model)
    builder.add_node("tools", ToolNode([add], messages_key="subgraph_messages"))
    builder.add_conditional_edges(
        "agent", partial(tools_condition, messages_key="subgraph_messages")
    )
    builder.add_edge(START, "agent")
    builder.add_edge("tools", "agent")

    graph = builder.compile()
    result = graph.invoke({"subgraph_messages": [HumanMessage(content="hi")]})
    assert result["subgraph_messages"] == [
        _AnyIdHumanMessage(content="hi"),
        AIMessage(
            content="hi",
            id="0",
            tool_calls=[ToolCall(name=add.name, args={"a": 1, "b": 2}, id="test_id")],
        ),
        _AnyIdToolMessage(content="3", name=add.name, tool_call_id="test_id"),
        AIMessage(content="hi-hi-3", id="1"),
    ]


def test_tool_node_stream_writer() -> None:
    @dec_tool
    def streaming_tool(x: int) -> str:
        """Do something with writer."""
        my_writer = get_stream_writer()
        for value in ["foo", "bar", "baz"]:
            my_writer({"custom_tool_value": value})

        return x

    tool_node = ToolNode([streaming_tool])
    graph = (
        StateGraph(MessagesState)
        .add_node("tools", tool_node)
        .add_edge(START, "tools")
        .compile()
    )

    tool_call = {
        "name": "streaming_tool",
        "args": {"x": 1},
        "id": "1",
        "type": "tool_call",
    }
    inputs = {
        "messages": [AIMessage("", tool_calls=[tool_call])],
    }

    assert list(graph.stream(inputs, stream_mode="custom")) == [
        {"custom_tool_value": "foo"},
        {"custom_tool_value": "bar"},
        {"custom_tool_value": "baz"},
    ]
    assert list(graph.stream(inputs, stream_mode=["custom", "updates"])) == [
        ("custom", {"custom_tool_value": "foo"}),
        ("custom", {"custom_tool_value": "bar"}),
        ("custom", {"custom_tool_value": "baz"}),
        (
            "updates",
            {
                "tools": {
                    "messages": [
                        _AnyIdToolMessage(
                            content="1",
                            name="streaming_tool",
                            tool_call_id="1",
                        ),
                    ],
                },
            },
        ),
    ]


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"}

    # Create a ToolCallRequest
    request = ToolCallRequest(
        tool_call=tool_call,
        tool=None,
        state={"messages": []},
        runtime=None,
    )

    # Test 1: Direct attribute assignment should raise deprecation warning but still work
    with pytest.warns(DeprecationWarning, match="deprecated.*override"):
        request.tool_call = {"name": "other", "args": {}, "id": "call_2"}

    # Verify the attribute was actually modified
    assert request.tool_call == {"name": "other", "args": {}, "id": "call_2"}

    # Reset for further tests
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        request.tool_call = tool_call

    # Test 2: override method should work without warnings
    with warnings.catch_warnings(record=True) as w:
        warnings.simplefilter("always")
        new_tool_call = {
            "name": "new_tool",
            "args": {"b": 2},
            "id": "call_3",
            "type": "tool_call",
        }
        new_request = request.override(tool_call=new_tool_call)

        # Verify no warning was raised
        assert len(w) == 0

    # Verify original is unchanged
    assert request.tool_call == tool_call

    # Verify new request has updated values
    assert new_request.tool_call == new_tool_call

    # Test 3: Initialization should not trigger warning
    with warnings.catch_warnings(record=True) as w:
        warnings.simplefilter("always")
        ToolCallRequest(
            tool_call=tool_call,
            tool=None,
            state={"messages": []},
            runtime=None,
        )
        # Verify no warning was raised during initialization
        assert len(w) == 0


async def test_tool_node_inject_async_all_types_signature_only() -> None:
    """Test all injection types without @tool decorator."""
    store = InMemoryStore()
    namespace = ("test",)
    store.put(namespace, "test_key", {"store_data": "from_store"})

    class TestState(TypedDict):
        messages: list
        foo: str
        bar: int

    async def comprehensive_async_tool(
        x: int,
        whole_state: Annotated[TestState, InjectedState],
        foo_field: Annotated[str, InjectedState("foo")],
        store: Annotated[BaseStore, InjectedStore()],
        runtime: ToolRuntime,
    ) -> str:
        """Async tool that uses all injection types."""
        bar_from_whole = whole_state["bar"]
        foo_value = foo_field
        store_val = store.get(namespace, "test_key").value["store_data"]
        foo_from_runtime = runtime.state["foo"]
        tool_call_id = runtime.tool_call_id

        return (
            f"x={x}, "
            f"bar_from_whole={bar_from_whole}, "
            f"foo_field={foo_value}, "
            f"store={store_val}, "
            f"foo_from_runtime={foo_from_runtime}, "
            f"tool_call_id={tool_call_id}"
        )

    node = ToolNode([comprehensive_async_tool], handle_tool_errors=True)
    tool_call = {
        "name": "comprehensive_async_tool",
        "args": {"x": 42},
        "id": "test_call_123",
        "type": "tool_call",
    }
    msg = AIMessage("hi?", tool_calls=[tool_call])

    config = _create_config_with_runtime(store=store)
    result = await node.ainvoke(
        {"messages": [msg], "foo": "foo_value", "bar": 99}, config=config
    )

    tool_message = result["messages"][-1]
    assert tool_message.content == (
        "x=42, "
        "bar_from_whole=99, "
        "foo_field=foo_value, "
        "store=from_store, "
        "foo_from_runtime=foo_value, "
        "tool_call_id=test_call_123"
    )


async def test_tool_node_inject_async_all_types_with_decorator() -> None:
    """Test all injection types with @tool decorator."""
    store = InMemoryStore()
    namespace = ("test",)
    store.put(namespace, "test_key", {"store_data": "from_store"})

    class TestState(TypedDict):
        messages: list
        foo: str
        bar: int

    @dec_tool
    async def comprehensive_async_tool(
        x: int,
        whole_state: Annotated[TestState, InjectedState],
        foo_field: Annotated[str, InjectedState("foo")],
        store: Annotated[BaseStore, InjectedStore()],
        runtime: ToolRuntime,
    ) -> str:
        """Async tool that uses all injection types."""
        bar_from_whole = whole_state["bar"]
        foo_value = foo_field
        store_val = store.get(namespace, "test_key").value["store_data"]
        foo_from_runtime = runtime.state["foo"]
        tool_call_id = runtime.tool_call_id

        return (
            f"x={x}, "
            f"bar_from_whole={bar_from_whole}, "
            f"foo_field={foo_value}, "
            f"store={store_val}, "
            f"foo_from_runtime={foo_from_runtime}, "
            f"tool_call_id={tool_call_id}"
        )

    node = ToolNode([comprehensive_async_tool], handle_tool_errors=True)
    tool_call = {
        "name": "comprehensive_async_tool",
        "args": {"x": 42},
        "id": "test_call_456",
        "type": "tool_call",
    }
    msg = AIMessage("hi?", tool_calls=[tool_call])

    config = _create_config_with_runtime(store=store)
    result = await node.ainvoke(
        {"messages": [msg], "foo": "foo_value", "bar": 99}, config=config
    )

    tool_message = result["messages"][-1]
    assert tool_message.content == (
        "x=42, "
        "bar_from_whole=99, "
        "foo_field=foo_value, "
        "store=from_store, "
        "foo_from_runtime=foo_value, "
        "tool_call_id=test_call_456"
    )


async def test_tool_node_inject_async_all_types_with_schema() -> None:
    """Test all injection types with explicit schema."""
    store = InMemoryStore()
    namespace = ("test",)
    store.put(namespace, "test_key", {"store_data": "from_store"})

    class TestState(TypedDict):
        messages: list
        foo: str
        bar: int

    class ComprehensiveToolSchema(BaseModel):
        model_config = {"arbitrary_types_allowed": True}
        x: int
        whole_state: Annotated[TestState, InjectedState]
        foo_field: Annotated[str, InjectedState("foo")]
        store: Annotated[BaseStore, InjectedStore()]
        runtime: ToolRuntime

    @dec_tool(args_schema=ComprehensiveToolSchema)
    async def comprehensive_async_tool(
        x: int,
        whole_state: Annotated[TestState, InjectedState],
        foo_field: Annotated[str, InjectedState("foo")],
        store: Annotated[BaseStore, InjectedStore()],
        runtime: ToolRuntime,
    ) -> str:
        """Async tool that uses all injection types."""
        bar_from_whole = whole_state["bar"]
        foo_value = foo_field
        store_val = store.get(namespace, "test_key").value["store_data"]
        foo_from_runtime = runtime.state["foo"]
        tool_call_id = runtime.tool_call_id

        return (
            f"x={x}, "
            f"bar_from_whole={bar_from_whole}, "
            f"foo_field={foo_value}, "
            f"store={store_val}, "
            f"foo_from_runtime={foo_from_runtime}, "
            f"tool_call_id={tool_call_id}"
        )

    node = ToolNode([comprehensive_async_tool], handle_tool_errors=True)
    tool_call = {
        "name": "comprehensive_async_tool",
        "args": {"x": 42},
        "id": "test_call_789",
        "type": "tool_call",
    }
    msg = AIMessage("hi?", tool_calls=[tool_call])

    config = _create_config_with_runtime(store=store)
    result = await node.ainvoke(
        {"messages": [msg], "foo": "foo_value", "bar": 99}, config=config
    )

    tool_message = result["messages"][-1]
    assert tool_message.content == (
        "x=42, "
        "bar_from_whole=99, "
        "foo_field=foo_value, "
        "store=from_store, "
        "foo_from_runtime=foo_value, "
        "tool_call_id=test_call_789"
    )


async def test_tool_node_tool_runtime_generic() -> None:
    """Test that ToolRuntime with generic type arguments is correctly injected."""

    @dataclasses.dataclass
    class MyContext:
        some_info: str

    @dec_tool
    def get_info(rt: ToolRuntime[MyContext]):
        """This tool returns info from context."""
        return rt.context.some_info

    # Create a mock runtime with context
    mock_runtime = _create_mock_runtime()
    mock_runtime.context = MyContext(some_info="test_info")

    config = {"configurable": {"__pregel_runtime": mock_runtime}}

    result = await ToolNode([get_info]).ainvoke(
        {
            "messages": [
                AIMessage(
                    "call tool",
                    tool_calls=[
                        {
                            "name": "get_info",
                            "args": {},
                            "id": "call_1",
                        }
                    ],
                )
            ]
        },
        config=config,
    )

    tool_message = result["messages"][-1]
    assert tool_message.type == "tool"
    assert tool_message.content == "test_info"
    assert tool_message.tool_call_id == "call_1"


def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call() -> None:
    """Test that ToolRuntime is injected for dynamically registered tools.

    Regression test for https://github.com/langchain-ai/langchain/issues/35305.
    When a tool is dynamically provided via wrap_tool_call (not registered at
    ToolNode init time), ToolRuntime should still be injected into the tool.
    """

    @dec_tool
    def static_tool(x: int) -> str:
        """A static tool registered at init."""
        return f"static: {x}"

    @dec_tool
    def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str:
        """A dynamic tool that needs ToolRuntime injection."""
        return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}"

    def wrap_tool_call(request, execute):
        """Middleware that swaps in a dynamic tool."""
        if request.tool_call["name"] == "dynamic_tool_with_runtime":
            # Override tool to the dynamic one (not registered at init)
            new_request = request.override(tool=dynamic_tool_with_runtime)
            return execute(new_request)
        return execute(request)

    # ToolNode only knows about static_tool at init time
    tool_node = ToolNode(
        [static_tool],
        wrap_tool_call=wrap_tool_call,
    )

    # Verify the dynamic tool is NOT in the tool node's registered tools
    assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name

    # Call the dynamic tool
    tool_call = {
        "name": "dynamic_tool_with_runtime",
        "args": {"x": 42},
        "id": "call_dynamic_1",
        "type": "tool_call",
    }
    msg = AIMessage("", tool_calls=[tool_call])
    result = tool_node.invoke(
        {"messages": [msg]},
        config=_create_config_with_runtime(),
    )

    # ToolRuntime should be injected and the tool should execute successfully
    tool_message = result["messages"][-1]
    assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_1"
    assert tool_message.tool_call_id == "call_dynamic_1"


async def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call_async() -> None:
    """Test that ToolRuntime is injected for dynamically registered tools (async).

    Async version of the regression test for
    https://github.com/langchain-ai/langchain/issues/35305.
    """

    @dec_tool
    def static_tool(x: int) -> str:
        """A static tool registered at init."""
        return f"static: {x}"

    @dec_tool
    async def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str:
        """A dynamic async tool that needs ToolRuntime injection."""
        return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}"

    async def awrap_tool_call(request, execute):
        """Async middleware that swaps in a dynamic tool."""
        if request.tool_call["name"] == "dynamic_tool_with_runtime":
            new_request = request.override(tool=dynamic_tool_with_runtime)
            return await execute(new_request)
        return await execute(request)

    # ToolNode only knows about static_tool at init time
    tool_node = ToolNode(
        [static_tool],
        awrap_tool_call=awrap_tool_call,
    )

    # Verify the dynamic tool is NOT in the tool node's registered tools
    assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name

    # Call the dynamic tool
    tool_call = {
        "name": "dynamic_tool_with_runtime",
        "args": {"x": 42},
        "id": "call_dynamic_2",
        "type": "tool_call",
    }
    msg = AIMessage("", tool_calls=[tool_call])
    result = await tool_node.ainvoke(
        {"messages": [msg]},
        config=_create_config_with_runtime(),
    )

    # ToolRuntime should be injected and the tool should execute successfully
    tool_message = result["messages"][-1]
    assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_2"
    assert tool_message.tool_call_id == "call_dynamic_2"


def test_tool_runtime_defaults_tools_to_empty_list() -> None:
    runtime = ToolRuntime(
        state={},
        context=None,
        config={},
        stream_writer=lambda *args, **kwargs: None,
        tool_call_id=None,
        store=None,
    )

    assert runtime.tools == []


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",
        checkpoint_id="cp-1",
        checkpoint_ns="",
        task_id="tk-1",
        run_id="r-1",
    )
    server_info = ServerInfo(assistant_id="asst-1", graph_id="graph-1")

    mock_runtime = Mock()
    mock_runtime.store = None
    mock_runtime.context = None
    mock_runtime.stream_writer = lambda *args, **kwargs: None
    mock_runtime.execution_info = exec_info
    mock_runtime.server_info = server_info

    captured: dict = {}

    @dec_tool
    def info_tool(x: int, runtime: ToolRuntime) -> str:
        """Tool that captures runtime info."""
        captured["execution_info"] = runtime.execution_info
        captured["server_info"] = runtime.server_info
        captured["tools"] = runtime.tools
        return "ok"

    @dec_tool
    def other_tool(y: int) -> str:
        """Another tool available to the runtime."""
        return str(y)

    node = ToolNode([info_tool, other_tool])
    tool_call = {
        "name": "info_tool",
        "args": {"x": 1},
        "id": "call-1",
        "type": "tool_call",
    }
    msg = AIMessage("", tool_calls=[tool_call])
    config: RunnableConfig = {"configurable": {"__pregel_runtime": mock_runtime}}
    result = node.invoke({"messages": [msg]}, config=config)

    assert result["messages"][-1].content == "ok"
    assert captured["execution_info"] is exec_info
    assert captured["execution_info"].thread_id == "t-1"
    assert captured["execution_info"].task_id == "tk-1"
    assert captured["server_info"] is server_info
    assert captured["server_info"].assistant_id == "asst-1"
    assert [tool.name for tool in captured["tools"]] == ["info_tool", "other_tool"]


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",
        checkpoint_id="cp-2",
        checkpoint_ns="",
        task_id="tk-2",
        run_id="r-2",
    )
    server_info = ServerInfo(assistant_id="asst-2", graph_id="graph-2")

    mock_runtime = Mock()
    mock_runtime.store = None
    mock_runtime.context = None
    mock_runtime.stream_writer = lambda *args, **kwargs: None
    mock_runtime.execution_info = exec_info
    mock_runtime.server_info = server_info

    captured: dict = {}

    @dec_tool
    async def info_tool_async(x: int, runtime: ToolRuntime) -> str:
        """Async tool that captures runtime info."""
        captured["execution_info"] = runtime.execution_info
        captured["server_info"] = runtime.server_info
        captured["tools"] = runtime.tools
        return "ok"

    @dec_tool
    async def other_tool_async(y: int) -> str:
        """Another async tool available to the runtime."""
        return str(y)

    node = ToolNode([info_tool_async, other_tool_async])
    tool_call = {
        "name": "info_tool_async",
        "args": {"x": 1},
        "id": "call-2",
        "type": "tool_call",
    }
    msg = AIMessage("", tool_calls=[tool_call])
    config: RunnableConfig = {"configurable": {"__pregel_runtime": mock_runtime}}
    result = await node.ainvoke({"messages": [msg]}, config=config)

    assert result["messages"][-1].content == "ok"
    assert captured["execution_info"] is exec_info
    assert captured["execution_info"].thread_id == "t-2"
    assert captured["server_info"] is server_info
    assert captured["server_info"].graph_id == "graph-2"
    assert [tool.name for tool in captured["tools"]] == [
        "info_tool_async",
        "other_tool_async",
    ]


# --- InjectedToolArg security tests ---


def test_tool_node_strips_plain_injected_tool_arg() -> None:
    """Plain InjectedToolArg values supplied by the LLM should be stripped."""

    @dec_tool
    def read_secret(
        query: str,
        auth: Annotated[dict, InjectedToolArg()],
    ) -> str:
        """Return secret data based on auth role."""
        if auth.get("role") == "admin":
            return "ADMIN_SECRET"
        return "PUBLIC_DATA"

    node = ToolNode([read_secret], handle_tool_errors=True)

    # LLM tries to supply the hidden 'auth' field
    tool_call = {
        "name": "read_secret",
        "args": {"query": "hello", "auth": {"role": "admin"}},
        "id": "call-1",
        "type": "tool_call",
    }
    msg = AIMessage("", tool_calls=[tool_call])
    result = node.invoke({"messages": [msg]}, config=_create_config_with_runtime())
    tool_message = result["messages"][-1]
    # auth should have been stripped, so tool should fail (missing required arg)
    assert "ADMIN_SECRET" not in tool_message.content


def test_tool_node_strips_custom_injected_tool_arg_subclass() -> None:
    """Custom InjectedToolArg subclasses should also be stripped."""

    class InjectedAuth(InjectedToolArg):
        pass

    @dec_tool
    def read_secret(
        query: str,
        auth: Annotated[dict, InjectedAuth()],
    ) -> str:
        """Return secret data based on auth role."""
        if auth.get("role") == "admin":
            return "ADMIN_SECRET"
        return "PUBLIC_DATA"

    node = ToolNode([read_secret], handle_tool_errors=True)

    tool_call = {
        "name": "read_secret",
        "args": {"query": "hello", "auth": {"role": "admin"}},
        "id": "call-1",
        "type": "tool_call",
    }
    msg = AIMessage("", tool_calls=[tool_call])
    result = node.invoke({"messages": [msg]}, config=_create_config_with_runtime())
    tool_message = result["messages"][-1]
    assert "ADMIN_SECRET" not in tool_message.content


def test_tool_node_injected_state_overwrites_llm_value() -> None:
    """InjectedState should use graph state, not LLM-supplied values."""

    @dec_tool
    def read_secret(
        query: str,
        auth: Annotated[dict, InjectedState("auth")],
    ) -> str:
        """Return secret data based on auth from graph state."""
        if auth.get("role") == "admin":
            return "ADMIN_SECRET"
        return "PUBLIC_DATA"

    node = ToolNode([read_secret])

    # LLM tries to supply auth as admin
    tool_call = {
        "name": "read_secret",
        "args": {"query": "hello", "auth": {"role": "admin"}},
        "id": "call-1",
        "type": "tool_call",
    }
    msg = AIMessage("", tool_calls=[tool_call])

    # Graph state has auth as viewer
    result = node.invoke(
        {"messages": [msg], "auth": {"role": "viewer"}},
        config=_create_config_with_runtime(),
    )
    tool_message = result["messages"][-1]
    assert tool_message.content == "PUBLIC_DATA"


class _ReturningTool(BaseTool):
    """A tool that returns a configured value verbatim."""

    name: str = "list_tool"
    description: str = "Returns a configured value"
    return_value: Any = None

    def _run(self, **kwargs: Any) -> Any:
        return self.return_value

    async def _arun(self, **kwargs: Any) -> Any:
        return self.return_value


def _list_tool_call(outer_id: str = "call-1") -> dict[str, Any]:
    return {"name": "list_tool", "args": {}, "id": outer_id, "type": "tool_call"}


def _invoke_returning(
    return_value: Any,
    *,
    outer_id: str = "call-1",
    handle_tool_errors: bool = True,
) -> Any:
    node = ToolNode(
        [_ReturningTool(return_value=return_value)],
        handle_tool_errors=handle_tool_errors,
    )
    return node.invoke(
        {"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]},
        config=_create_config_with_runtime(),
    )


def test_tool_node_list_return_command_and_tool_message() -> None:
    """Valid: tool returns [Command(update={...}), ToolMessage(...)]."""
    outer_id = "call-1"
    result = _invoke_returning(
        [
            Command(update={"foo": "bar"}),
            ToolMessage(content="done", tool_call_id=outer_id),
        ]
    )
    assert isinstance(result, list)
    commands = [r for r in result if isinstance(r, Command)]
    assert len(commands) == 1
    assert commands[0].update == {"foo": "bar"}
    non_commands = [r for r in result if not isinstance(r, Command)]
    assert len(non_commands) == 1
    assert isinstance(non_commands[0], dict)
    msgs = non_commands[0]["messages"]
    assert len(msgs) == 1
    assert isinstance(msgs[0], ToolMessage)
    assert msgs[0].content == "done"
    assert msgs[0].tool_call_id == outer_id


def test_tool_node_list_return_nested_terminator() -> None:
    """Valid: terminator nested inside Command.update['messages']."""
    outer_id = "call-1"
    result = _invoke_returning(
        [
            Command(update={"foo": "bar"}),
            Command(
                update={
                    "messages": [ToolMessage(content="done", tool_call_id=outer_id)]
                }
            ),
        ]
    )
    assert isinstance(result, list)
    commands = [r for r in result if isinstance(r, Command)]
    assert len(commands) == 2
    updates = [c.update for c in commands]
    assert {"foo": "bar"} in updates
    msgs_update = next(u for u in updates if "messages" in (u or {}))
    assert any(
        isinstance(m, ToolMessage) and m.tool_call_id == outer_id
        for m in msgs_update["messages"]
    )


def test_tool_node_list_return_parent_goto_with_terminator() -> None:
    """Valid: [Command(graph=PARENT, goto=[Send(...)]), ToolMessage(...)]."""
    outer_id = "call-1"
    result = _invoke_returning(
        [
            Command(graph=Command.PARENT, goto=[Send("child", {})]),
            ToolMessage(content="ok", tool_call_id=outer_id),
        ]
    )
    assert isinstance(result, list)
    parent_cmds = [
        r for r in result if isinstance(r, Command) and r.graph is Command.PARENT
    ]
    assert len(parent_cmds) == 1
    assert isinstance(parent_cmds[0].goto, list)
    assert any(isinstance(s, Send) for s in parent_cmds[0].goto)
    non_commands = [r for r in result if not isinstance(r, Command)]
    assert len(non_commands) == 1


def test_tool_node_list_return_no_terminator_raises() -> None:
    """Invalid: list with no terminating ToolMessage."""
    with pytest.raises(ValueError, match="0 messages bound to tool_call_id"):
        _invoke_returning([Command(update={"foo": "bar"})], handle_tool_errors=False)


def test_tool_node_list_return_multiple_terminators_raises() -> None:
    """Invalid: list with two terminating ToolMessages."""
    outer_id = "call-1"
    with pytest.raises(ValueError, match="2 messages bound to tool_call_id"):
        _invoke_returning(
            [
                ToolMessage(content="a", tool_call_id=outer_id),
                ToolMessage(content="b", tool_call_id=outer_id),
            ],
            handle_tool_errors=False,
        )


def test_tool_node_list_return_validation_error_handled() -> None:
    """handle_tool_errors=True converts validation errors to an error ToolMessage."""
    result = _invoke_returning([Command(update={"foo": "bar"})])
    assert isinstance(result, dict)
    msg = result["messages"][0]
    assert isinstance(msg, ToolMessage)
    assert msg.status == "error"
    assert "0 messages bound to tool_call_id" in msg.content


async def test_tool_node_list_return_async_smoke() -> None:
    """Async path parallels sync for the happy case."""
    outer_id = "call-1"
    node = ToolNode(
        [
            _ReturningTool(
                return_value=[
                    Command(update={"foo": "bar"}),
                    ToolMessage(content="done", tool_call_id=outer_id),
                ]
            )
        ]
    )
    result = await node.ainvoke(
        {"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]},
        config=_create_config_with_runtime(),
    )
    assert isinstance(result, list)
    commands = [r for r in result if isinstance(r, Command)]
    assert len(commands) == 1 and commands[0].update == {"foo": "bar"}


def test_tool_node_list_return_mixed_with_regular_tool() -> None:
    """List-returning tool and a regular tool dispatched from the same AIMessage."""
    list_tool_id = "call-list"
    regular_tool_id = "call-regular"
    list_tool = _ReturningTool(
        return_value=[
            Command(update={"foo": "bar"}),
            ToolMessage(content="list done", tool_call_id=list_tool_id),
        ]
    )

    def regular_tool(x: int) -> str:
        """A normal tool."""
        return f"regular: {x}"

    tool_calls = [
        {"name": "list_tool", "args": {}, "id": list_tool_id, "type": "tool_call"},
        {
            "name": "regular_tool",
            "args": {"x": 7},
            "id": regular_tool_id,
            "type": "tool_call",
        },
    ]
    node = ToolNode([list_tool, regular_tool])
    result = node.invoke(
        {"messages": [AIMessage("", tool_calls=tool_calls)]},
        config=_create_config_with_runtime(),
    )
    assert isinstance(result, list)
    commands = [r for r in result if isinstance(r, Command)]
    assert len(commands) == 1
    assert commands[0].update == {"foo": "bar"}
    all_msgs = [m for r in result if isinstance(r, dict) for m in r["messages"]]
    tool_call_ids = {m.tool_call_id for m in all_msgs}
    assert list_tool_id in tool_call_ids
    assert regular_tool_id in tool_call_ids
