"""Tests for RemoteAgent, _convert_message_data, and helpers."""

import uuid
from collections.abc import Sequence
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from langchain_core.messages import AIMessageChunk, HumanMessage, ToolMessage

from deepagents_code._env_vars import LANGSMITH_REPLICA_PROJECTS
from deepagents_code.remote_client import (
    RemoteAgent,
    _convert_ai_message,
    _convert_human_message,
    _convert_interrupts,
    _convert_message_data,
    _convert_tool_message,
    _prepare_config,
    agent_error_type,
    format_agent_exception,
)

_TEST_THREAD_ID = "01966f3a-0000-7000-8000-000000000001"


# ---------------------------------------------------------------------------
# _prepare_config
# ---------------------------------------------------------------------------


class TestPrepareConfig:
    def test_preserves_thread_id(self) -> None:
        config = {"configurable": {"thread_id": _TEST_THREAD_ID}}
        result = _prepare_config(config)
        assert result["configurable"]["thread_id"] == _TEST_THREAD_ID

    def test_none_config(self) -> None:
        result = _prepare_config(None)
        assert result == {"configurable": {}}

    def test_does_not_mutate_original(self) -> None:
        tid = str(uuid.uuid4())
        config = {"configurable": {"thread_id": tid}}
        _prepare_config(config)
        assert config["configurable"]["thread_id"] == tid

    def test_missing_configurable_key(self) -> None:
        result = _prepare_config({"other": "value"})
        assert result["configurable"] == {}

    def test_empty_string_thread_id_not_converted(self) -> None:
        result = _prepare_config({"configurable": {"thread_id": ""}})
        assert result["configurable"]["thread_id"] == ""


# ---------------------------------------------------------------------------
# _convert_message_data
# ---------------------------------------------------------------------------


class TestConvertMessageData:
    def test_ai_message_text(self) -> None:
        msg = _convert_message_data({"type": "ai", "content": "Hello", "id": "m1"})
        assert isinstance(msg, AIMessageChunk)
        assert msg.content == "Hello"
        assert msg.id == "m1"

    def test_ai_message_with_tool_call_chunks(self) -> None:
        msg = _convert_message_data(
            {
                "type": "AIMessageChunk",
                "content": "",
                "id": "m1",
                "tool_call_chunks": [
                    {"name": "search", "args": '{"q":', "id": "tc1", "index": 0}
                ],
            }
        )
        assert isinstance(msg, AIMessageChunk)
        tc_blocks = [
            b for b in msg.content_blocks if b.get("type") == "tool_call_chunk"
        ]
        assert len(tc_blocks) == 1
        assert tc_blocks[0]["name"] == "search"
        assert tc_blocks[0]["args"] == '{"q":'

    def test_ai_message_with_string_args_tool_calls(self) -> None:
        msg = _convert_message_data(
            {
                "type": "ai",
                "content": "",
                "id": "m1",
                "tool_calls": [{"name": "ls", "args": '{"path":"/"', "id": "tc1"}],
            }
        )
        assert isinstance(msg, AIMessageChunk)
        tc_blocks = [
            b for b in msg.content_blocks if b.get("type") == "tool_call_chunk"
        ]
        assert len(tc_blocks) == 1

    def test_ai_message_with_dict_args_tool_calls(self) -> None:
        msg = _convert_message_data(
            {
                "type": "ai",
                "content": "",
                "id": "m1",
                "tool_calls": [{"name": "search", "args": {"q": "test"}, "id": "tc1"}],
            }
        )
        assert isinstance(msg, AIMessageChunk)
        assert msg.tool_calls[0]["name"] == "search"

    def test_ai_message_usage_metadata(self) -> None:
        msg = _convert_message_data(
            {
                "type": "ai",
                "content": "",
                "id": "m1",
                "usage_metadata": {
                    "input_tokens": 10,
                    "output_tokens": 20,
                    "total_tokens": 30,
                },
            }
        )
        assert msg.usage_metadata["input_tokens"] == 10

    def test_ai_message_type_alias(self) -> None:
        msg = _convert_message_data({"type": "AIMessage", "content": "Hi", "id": "m1"})
        assert isinstance(msg, AIMessageChunk)
        assert msg.content == "Hi"

    def test_human_message(self) -> None:
        msg = _convert_message_data({"type": "human", "content": "Hi", "id": "m1"})
        assert isinstance(msg, HumanMessage)
        assert msg.content == "Hi"

    def test_human_message_type_alias(self) -> None:
        msg = _convert_message_data(
            {"type": "HumanMessage", "content": "Hey", "id": "m1"}
        )
        assert isinstance(msg, HumanMessage)
        assert msg.content == "Hey"

    def test_tool_message(self) -> None:
        msg = _convert_message_data(
            {
                "type": "tool",
                "content": "Sunny",
                "tool_call_id": "tc1",
                "name": "weather",
                "id": "m2",
            }
        )
        assert isinstance(msg, ToolMessage)
        assert msg.content == "Sunny"
        assert msg.tool_call_id == "tc1"

    def test_tool_message_type_alias(self) -> None:
        msg = _convert_message_data(
            {
                "type": "ToolMessage",
                "content": "result",
                "tool_call_id": "tc1",
                "name": "search",
                "id": "m3",
            }
        )
        assert isinstance(msg, ToolMessage)
        assert msg.content == "result"

    def test_tool_message_defaults(self) -> None:
        msg = _convert_message_data({"type": "tool", "id": "m1"})
        assert isinstance(msg, ToolMessage)
        assert msg.content == ""
        assert msg.tool_call_id == ""
        assert msg.name == ""
        assert msg.status == "success"

    def test_unknown_type_returns_none(self) -> None:
        assert _convert_message_data({"type": "unknown"}) is None


# ---------------------------------------------------------------------------
# _convert_interrupts
# ---------------------------------------------------------------------------


class TestConvertInterrupts:
    def test_dicts_to_interrupt_objects(self) -> None:
        from langgraph.types import Interrupt

        result = _convert_interrupts([{"value": {"type": "ask_user"}, "id": "int-1"}])
        assert len(result) == 1
        assert isinstance(result[0], Interrupt)
        assert result[0].value == {"type": "ask_user"}
        assert result[0].id == "int-1"

    def test_interrupt_objects_passed_through(self) -> None:
        from langgraph.types import Interrupt

        obj = Interrupt(value="test", id="int-2")
        result = _convert_interrupts([obj])
        assert result[0] is obj

    def test_non_list_wraps_value(self) -> None:
        assert _convert_interrupts("not a list") == ["not a list"]

    def test_none_returns_empty(self) -> None:
        assert _convert_interrupts(None) == []

    def test_dict_without_value_passed_through(self) -> None:
        raw = [{"id": "x", "other": 123}]
        result = _convert_interrupts(raw)
        assert result[0] == {"id": "x", "other": 123}

    def test_interrupt_dict_missing_id_defaults_to_empty(self) -> None:
        from langgraph.types import Interrupt

        result = _convert_interrupts([{"value": "confirm"}])
        assert isinstance(result[0], Interrupt)
        assert result[0].value == "confirm"
        assert result[0].id == ""


# ---------------------------------------------------------------------------
# Helpers for RemoteAgent tests
# ---------------------------------------------------------------------------


def _make_agent(
    events: Sequence[tuple[tuple[str, ...], str, Any]],
) -> RemoteAgent:
    """Create a RemoteAgent with a mock RemoteGraph yielding events."""
    agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
    mock_graph = MagicMock()

    async def fake_astream(  # noqa: RUF029
        input: Any,  # noqa: A002, ANN401, ARG001
        **kwargs: Any,  # noqa: ARG001
    ) -> Any:  # noqa: ANN401
        for ev in events:
            yield ev

    mock_graph.astream = fake_astream
    agent._graph = mock_graph
    return agent


def _config() -> dict[str, Any]:
    return {"configurable": {"thread_id": _TEST_THREAD_ID}}


def _make_capturing_agent() -> tuple[RemoteAgent, dict[str, Any]]:
    """RemoteAgent whose mock graph records the kwargs passed to `astream`."""
    agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
    captured: dict[str, Any] = {}
    mock_graph = MagicMock()

    async def fake_astream(  # noqa: RUF029
        input: Any,  # noqa: A002, ANN401, ARG001
        **kwargs: Any,
    ) -> Any:  # noqa: ANN401
        captured.update(kwargs)
        for ev in ():  # async generator that yields nothing
            yield ev

    mock_graph.astream = fake_astream
    agent._graph = mock_graph
    return agent, captured


class TestRemoteAgentReplicaForwarding:
    """`astream` forwards the LangSmith replica project to the server SDK.

    The server mirrors a run to an extra project only via the SDK's
    `langsmith_tracing` field, so these lock the exact kwarg name and payload
    shape `RemoteGraph.astream` (and thus `client.runs.stream`) expects.

    `test_forwards_replica_project` / `test_no_kwarg_when_unset` assert the
    payload against a mock graph that swallows any kwarg, so they verify only
    the `RemoteAgent` side of the contract. `test_sdk_accepts_langsmith_tracing`
    pins the *other* side — that the real SDK still accepts the kwarg and shape —
    so a future SDK rename surfaces here rather than silently dropping replicas.
    """

    async def test_forwards_replica_project(self, monkeypatch) -> None:
        """A configured replica is passed through as `langsmith_tracing`."""
        monkeypatch.setenv(LANGSMITH_REPLICA_PROJECTS, "mason-dual-trace")
        agent, captured = _make_capturing_agent()
        async for _ in agent.astream({"messages": []}, config=_config()):
            pass
        assert captured["langsmith_tracing"] == {"project_name": "mason-dual-trace"}

    async def test_no_kwarg_when_unset(self, monkeypatch) -> None:
        """Without a replica, `langsmith_tracing` is not passed at all."""
        monkeypatch.delenv(LANGSMITH_REPLICA_PROJECTS, raising=False)
        agent, captured = _make_capturing_agent()
        async for _ in agent.astream({"messages": []}, config=_config()):
            pass
        assert "langsmith_tracing" not in captured

    def test_sdk_accepts_langsmith_tracing(self) -> None:
        """The real SDK still accepts the kwarg name and `project_name` shape.

        `RemoteGraph.astream` forwards unknown kwargs to `client.runs.stream`, so
        a silent drop would happen if either the parameter or the payload key
        were renamed upstream. This guards both.
        """
        import inspect

        from langgraph_sdk.client import RunsClient
        from langgraph_sdk.schema import LangSmithTracing

        params = inspect.signature(RunsClient.stream).parameters
        assert "langsmith_tracing" in params
        assert "project_name" in LangSmithTracing.__annotations__


# ---------------------------------------------------------------------------
# RemoteAgent — astream delegation
# ---------------------------------------------------------------------------


class TestRemoteAgentAstream:
    async def test_text_message_converted(self) -> None:
        """Messages-tuple text chunks are converted to AIMessageChunk."""
        events = [((), "messages", ({"type": "ai", "content": "Hi", "id": "m1"}, {}))]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert len(results) == 1
        ns, mode, (msg, _meta) = results[0]
        assert ns == ()
        assert mode == "messages"
        assert isinstance(msg, AIMessageChunk)
        assert msg.content == "Hi"

    async def test_tool_message_converted(self) -> None:
        """Tool messages are converted to ToolMessage."""
        events = [
            (
                (),
                "messages",
                (
                    {
                        "type": "tool",
                        "content": "Sunny",
                        "tool_call_id": "tc1",
                        "name": "weather",
                        "id": "m2",
                    },
                    {},
                ),
            )
        ]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert len(results) == 1
        assert isinstance(results[0][2][0], ToolMessage)

    async def test_updates_with_interrupt_converted(self) -> None:
        """Interrupt dicts in updates events are converted to Interrupt."""
        from langgraph.types import Interrupt

        events = [
            (
                (),
                "updates",
                {"__interrupt__": [{"value": {"type": "ask_user"}, "id": "int-1"}]},
            )
        ]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert len(results) == 1
        interrupts = results[0][2]["__interrupt__"]
        assert isinstance(interrupts[0], Interrupt)

    async def test_updates_without_interrupt_passed_through(self) -> None:
        """Regular updates events pass through unchanged."""
        events = [((), "updates", {"agent": {"messages": []}})]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert len(results) == 1
        assert results[0][1] == "updates"
        assert results[0][2] == {"agent": {"messages": []}}

    async def test_namespace_preserved(self) -> None:
        """Namespace from RemoteGraph is preserved in output."""
        events = [
            (
                ("sub", "inner"),
                "messages",
                ({"type": "ai", "content": "Hi", "id": "m1"}, {}),
            )
        ]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert results[0][0] == ("sub", "inner")

    async def test_unknown_message_type_skipped(self) -> None:
        """Unknown message types don't produce output."""
        events = [((), "messages", ({"type": "unknown", "content": "?"}, {}))]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert results == []

    async def test_missing_thread_id_raises(self) -> None:
        """Raises ValueError if thread_id is missing."""
        agent = _make_agent([])
        with pytest.raises(ValueError, match="thread_id"):
            async for _ in agent.astream({"messages": []}, config={"configurable": {}}):
                pass

    async def test_rapid_streaming(self) -> None:
        """Many rapid text events all arrive (no dropped tokens)."""
        events = [
            (
                (),
                "messages",
                ({"type": "ai", "content": f"tok{i}", "id": "m1"}, {}),
            )
            for i in range(100)
        ]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        combined = "".join(r[2][0].content for r in results)
        assert combined == "".join(f"tok{i}" for i in range(100))
        assert len(results) == 100

    async def test_non_dict_message_object_passed_through(self) -> None:
        """Pre-deserialized LangChain message objects are yielded as-is."""
        chunk = AIMessageChunk(content="pre-built", id="m1")
        events = [((), "messages", (chunk, {"run_id": "r1"}))]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert len(results) == 1
        assert results[0][2][0] is chunk
        assert results[0][2][1] == {"run_id": "r1"}

    async def test_meta_none_defaults_to_empty_dict(self) -> None:
        """None metadata is normalized to empty dict."""
        events = [((), "messages", ({"type": "ai", "content": "x", "id": "m1"}, None))]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert results[0][2][1] == {}

    async def test_unknown_mode_passed_through(self) -> None:
        """Events with unknown modes are yielded unchanged."""
        events = [((), "values", {"key": "val"})]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert len(results) == 1
        assert results[0] == ((), "values", {"key": "val"})

    async def test_non_dict_updates_falls_through(self) -> None:
        """Non-dict updates data passes through the generic yield."""
        events = [((), "updates", "string_data")]
        agent = _make_agent(events)
        results = [
            item async for item in agent.astream({"messages": []}, config=_config())
        ]
        assert len(results) == 1
        assert results[0] == ((), "updates", "string_data")


# ---------------------------------------------------------------------------
# RemoteAgent — aget_state
# ---------------------------------------------------------------------------


class TestRemoteAgentGetState:
    async def test_returns_state_on_success(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_graph = MagicMock()
        state = MagicMock(values={"messages": []}, next=())
        mock_graph.aget_state = AsyncMock(return_value=state)
        agent._graph = mock_graph

        result = await agent.aget_state(_config())
        assert result is state

    async def test_raises_when_thread_id_missing(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        with pytest.raises(ValueError, match="thread_id"):
            await agent.aget_state({"configurable": {}})

    async def test_returns_none_on_not_found(self) -> None:
        from langgraph_sdk.errors import NotFoundError

        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_graph = MagicMock()
        request = MagicMock()
        response = MagicMock(status_code=404, headers={})
        exc = NotFoundError("not found", response=response, body=None)
        exc.request = request
        mock_graph.aget_state = AsyncMock(side_effect=exc)
        agent._graph = mock_graph

        result = await agent.aget_state(_config())
        assert result is None

    async def test_propagates_non_404_exception(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_graph = MagicMock()
        mock_graph.aget_state = AsyncMock(side_effect=ConnectionError("down"))
        agent._graph = mock_graph

        with pytest.raises(ConnectionError, match="down"):
            await agent.aget_state(_config())

    async def test_normalizes_config(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_graph = MagicMock()
        mock_graph.aget_state = AsyncMock(return_value=None)
        agent._graph = mock_graph

        await agent.aget_state({"configurable": {"thread_id": _TEST_THREAD_ID}})
        call_config = mock_graph.aget_state.call_args[0][0]
        uuid.UUID(call_config["configurable"]["thread_id"])


# ---------------------------------------------------------------------------
# RemoteAgent — aupdate_state
# ---------------------------------------------------------------------------


class TestRemoteAgentUpdateState:
    async def test_delegates_to_graph(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_graph = MagicMock()
        mock_graph.aupdate_state = AsyncMock()
        agent._graph = mock_graph

        await agent.aupdate_state(_config(), {"key": "val"})
        mock_graph.aupdate_state.assert_called_once()

    async def test_raises_when_thread_id_missing(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        with pytest.raises(ValueError, match="thread_id"):
            await agent.aupdate_state({"configurable": {}}, {"key": "val"})

    async def test_propagates_exception(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_graph = MagicMock()
        mock_graph.aupdate_state = AsyncMock(side_effect=ConnectionError("down"))
        agent._graph = mock_graph

        with pytest.raises(ConnectionError, match="down"):
            await agent.aupdate_state(_config(), {"key": "val"})

    async def test_normalizes_config(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_graph = MagicMock()
        mock_graph.aupdate_state = AsyncMock()
        agent._graph = mock_graph

        await agent.aupdate_state(
            {"configurable": {"thread_id": _TEST_THREAD_ID}}, {"key": "val"}
        )
        call_config = mock_graph.aupdate_state.call_args[0][0]
        uuid.UUID(call_config["configurable"]["thread_id"])


class TestRemoteAgentCancelActiveRuns:
    """`acancel_active_runs` exposes best-effort remote run cancellation."""

    async def test_cancels_running_and_pending_runs(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        runs_list = AsyncMock(
            side_effect=[
                [{"run_id": "run-1"}],
                [{"run_id": "run-2"}],
            ]
        )
        runs_cancel = AsyncMock()
        mock_runs = MagicMock()
        mock_runs.list = runs_list
        mock_runs.cancel = runs_cancel
        mock_client = MagicMock()
        mock_client.runs = mock_runs
        mock_graph = MagicMock()
        mock_graph._validate_client.return_value = mock_client
        agent._graph = mock_graph

        await agent.acancel_active_runs(_config())

        assert runs_list.await_count == 2
        assert runs_cancel.await_count == 2
        assert {call.args[1] for call in runs_cancel.await_args_list} == {
            "run-1",
            "run-2",
        }

    async def test_raises_when_thread_id_missing(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        with pytest.raises(ValueError, match="thread_id"):
            await agent.acancel_active_runs({"configurable": {}})


def _conflict_error() -> Exception:
    """Build a `ConflictError` (HTTP 409) for tests."""
    import httpx
    from langgraph_sdk.errors import ConflictError

    request = httpx.Request("POST", "http://localhost:8123/threads/x/state")
    response = httpx.Response(409, request=request)
    return ConflictError("Thread busy", response=response, body=None)


class TestRemoteAgentUpdateStateConflictRecovery:
    """`aupdate_state` cancels in-flight runs on 409 and retries once."""

    def _agent_with_client(
        self,
        *,
        runs_list: AsyncMock,
        runs_cancel: AsyncMock,
        update_side_effect: list[Any],
    ) -> tuple[RemoteAgent, MagicMock]:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_graph = MagicMock()
        mock_graph.aupdate_state = AsyncMock(side_effect=update_side_effect)
        mock_runs = MagicMock()
        mock_runs.list = runs_list
        mock_runs.cancel = runs_cancel
        mock_client = MagicMock()
        mock_client.runs = mock_runs
        mock_graph._validate_client.return_value = mock_client
        agent._graph = mock_graph
        return agent, mock_graph

    async def test_cancels_all_active_runs_then_retries(self) -> None:
        runs_list = AsyncMock(
            side_effect=[
                [{"run_id": "run-1"}, {"run_id": "run-2"}],  # running
                [{"run_id": "run-3"}],  # pending
            ]
        )
        runs_cancel = AsyncMock()
        agent, mock_graph = self._agent_with_client(
            runs_list=runs_list,
            runs_cancel=runs_cancel,
            update_side_effect=[_conflict_error(), None],
        )

        await agent.aupdate_state(_config(), {"messages": []})

        assert runs_list.await_count == 2
        assert runs_cancel.await_count == 3
        cancelled_ids = {call.args[1] for call in runs_cancel.await_args_list}
        assert cancelled_ids == {"run-1", "run-2", "run-3"}
        # wait=True + action="interrupt" are contractual — `wait` is what
        # actually settles the thread before the retry.
        for call in runs_cancel.await_args_list:
            assert call.kwargs == {"wait": True, "action": "interrupt"}
        assert mock_graph.aupdate_state.await_count == 2

    async def test_no_active_runs_still_retries(self) -> None:
        runs_list = AsyncMock(return_value=[])
        runs_cancel = AsyncMock()
        agent, mock_graph = self._agent_with_client(
            runs_list=runs_list,
            runs_cancel=runs_cancel,
            update_side_effect=[_conflict_error(), None],
        )

        await agent.aupdate_state(_config(), {"messages": []})

        assert runs_cancel.await_count == 0
        assert mock_graph.aupdate_state.await_count == 2

    async def test_retry_still_conflict_raises(self) -> None:
        runs_list = AsyncMock(return_value=[])
        runs_cancel = AsyncMock()
        agent, mock_graph = self._agent_with_client(
            runs_list=runs_list,
            runs_cancel=runs_cancel,
            update_side_effect=[_conflict_error(), _conflict_error()],
        )

        from langgraph_sdk.errors import ConflictError

        with pytest.raises(ConflictError):
            await agent.aupdate_state(_config(), {"messages": []})
        assert mock_graph.aupdate_state.await_count == 2

    async def test_cancel_timeout_still_retries(self) -> None:
        import asyncio

        async def slow_cancel(*_args: Any, **_kwargs: Any) -> None:
            await asyncio.sleep(60)  # exceeds wait_for timeout

        runs_list = AsyncMock(return_value=[{"run_id": "run-1"}])
        runs_cancel = AsyncMock(side_effect=slow_cancel)
        agent, mock_graph = self._agent_with_client(
            runs_list=runs_list,
            runs_cancel=runs_cancel,
            update_side_effect=[_conflict_error(), None],
        )

        with patch("deepagents_code.remote_client._RUN_CANCEL_WAIT_SECONDS", 0.01):
            await agent.aupdate_state(_config(), {"messages": []})

        assert mock_graph.aupdate_state.await_count == 2

    async def test_cancel_non_timeout_exception_is_swallowed(self) -> None:
        runs_list = AsyncMock(side_effect=[[{"run_id": "run-1"}], []])
        runs_cancel = AsyncMock(side_effect=RuntimeError("server hiccup"))
        agent, mock_graph = self._agent_with_client(
            runs_list=runs_list,
            runs_cancel=runs_cancel,
            update_side_effect=[_conflict_error(), None],
        )

        await agent.aupdate_state(_config(), {"messages": []})

        assert runs_cancel.await_count == 1
        assert mock_graph.aupdate_state.await_count == 2

    async def test_runs_list_partial_failure_still_retries(self) -> None:
        # First status list raises; second returns runs. Recovery should still
        # cancel what it can find and retry.
        runs_list = AsyncMock(side_effect=[RuntimeError("boom"), [{"run_id": "run-2"}]])
        runs_cancel = AsyncMock()
        agent, mock_graph = self._agent_with_client(
            runs_list=runs_list,
            runs_cancel=runs_cancel,
            update_side_effect=[_conflict_error(), None],
        )

        await agent.aupdate_state(_config(), {"messages": []})

        assert runs_list.await_count == 2
        assert runs_cancel.await_count == 1
        assert runs_cancel.await_args_list[0].args[1] == "run-2"
        assert mock_graph.aupdate_state.await_count == 2

    async def test_runs_list_total_failure_skips_cancel(self) -> None:
        # Both status calls raise. With nothing listed, no cancels happen and
        # the retry surfaces the persistent conflict.
        runs_list = AsyncMock(side_effect=[RuntimeError("boom"), RuntimeError("boom")])
        runs_cancel = AsyncMock()
        agent, mock_graph = self._agent_with_client(
            runs_list=runs_list,
            runs_cancel=runs_cancel,
            update_side_effect=[_conflict_error(), _conflict_error()],
        )

        from langgraph_sdk.errors import ConflictError

        with pytest.raises(ConflictError):
            await agent.aupdate_state(_config(), {"messages": []})
        runs_cancel.assert_not_called()
        assert mock_graph.aupdate_state.await_count == 2

    async def test_validate_client_raises_skips_cancel_and_retries(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_graph = MagicMock()
        mock_graph.aupdate_state = AsyncMock(
            side_effect=[_conflict_error(), _conflict_error()]
        )
        mock_graph._validate_client.side_effect = RuntimeError("no client")
        agent._graph = mock_graph

        from langgraph_sdk.errors import ConflictError

        with pytest.raises(ConflictError):
            await agent.aupdate_state(_config(), {"messages": []})
        assert mock_graph.aupdate_state.await_count == 2

    async def test_runs_without_run_id_are_skipped(self) -> None:
        runs_list = AsyncMock(
            side_effect=[
                # Mixed shapes: missing key, None id, non-dict — all skipped.
                [{"run_id": "ok"}, {"run_id": None}, {"status": "running"}, "garbage"],
                [],
            ]
        )
        runs_cancel = AsyncMock()
        agent, mock_graph = self._agent_with_client(
            runs_list=runs_list,
            runs_cancel=runs_cancel,
            update_side_effect=[_conflict_error(), None],
        )

        await agent.aupdate_state(_config(), {"messages": []})

        assert runs_cancel.await_count == 1
        assert runs_cancel.await_args_list[0].args[1] == "ok"
        assert mock_graph.aupdate_state.await_count == 2

    async def test_non_conflict_exception_does_not_retry(self) -> None:
        runs_list = AsyncMock()
        runs_cancel = AsyncMock()
        agent, mock_graph = self._agent_with_client(
            runs_list=runs_list,
            runs_cancel=runs_cancel,
            update_side_effect=[ConnectionError("down")],
        )

        with pytest.raises(ConnectionError, match="down"):
            await agent.aupdate_state(_config(), {"messages": []})
        assert mock_graph.aupdate_state.await_count == 1
        runs_list.assert_not_called()
        runs_cancel.assert_not_called()


class TestRemoteAgentStore:
    async def test_aput_store_item_uses_unindexed_put(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        store = SimpleNamespace(put_item=AsyncMock())
        client = SimpleNamespace(store=store)
        graph = MagicMock()
        graph._validate_client.return_value = client
        agent._graph = graph

        await agent.aput_store_item(("ns",), "key", {"auto_approve": True})

        store.put_item.assert_awaited_once_with(
            ("ns",),
            "key",
            {"auto_approve": True},
            index=False,
        )

    async def test_aput_store_item_logs_and_reraises(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        store = SimpleNamespace(put_item=AsyncMock(side_effect=RuntimeError("boom")))
        client = SimpleNamespace(store=store)
        graph = MagicMock()
        graph._validate_client.return_value = client
        agent._graph = graph

        with (
            caplog.at_level("DEBUG", logger="deepagents_code.remote_client"),
            pytest.raises(RuntimeError, match="boom"),
        ):
            await agent.aput_store_item(("ns",), "key", {"auto_approve": True})

        assert "Failed to write store item ns/key" in caplog.text


class TestRemoteAgentEnsureThread:
    """Verify remote thread registration before state writes."""

    async def test_creates_thread_with_do_nothing(self) -> None:
        """Creates the remote thread idempotently before cold-resume updates."""
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        mock_threads = MagicMock()
        mock_threads.create = AsyncMock()
        mock_client = MagicMock()
        mock_client.threads = mock_threads
        mock_graph = MagicMock()
        mock_graph._validate_client.return_value = mock_client
        agent._graph = mock_graph

        await agent.aensure_thread(
            {
                "configurable": {"thread_id": _TEST_THREAD_ID},
                "metadata": {"assistant_id": "agent"},
            }
        )

        kwargs = mock_threads.create.call_args.kwargs
        uuid.UUID(kwargs["thread_id"])
        assert kwargs["if_exists"] == "do_nothing"
        assert kwargs["metadata"] == {"assistant_id": "agent"}
        assert kwargs["graph_id"] == "agent"

    async def test_raises_when_thread_id_missing(self) -> None:
        """Rejects ensure-thread calls that omit `configurable.thread_id`."""
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")

        with pytest.raises(ValueError, match="thread_id"):
            await agent.aensure_thread({"configurable": {}})


# ---------------------------------------------------------------------------
# RemoteAgent — with_config
# ---------------------------------------------------------------------------


class TestRemoteAgentInit:
    def test_api_key_passed_to_remote_graph(self) -> None:
        """api_key kwarg is forwarded to RemoteGraph."""
        agent = RemoteAgent(
            url="http://localhost:8123",
            graph_name="agent",
            api_key="sk-test-123",
        )
        with patch("langgraph.pregel.remote.RemoteGraph") as mock_cls:
            agent._get_graph()
            mock_cls.assert_called_once_with(
                "agent",
                url="http://localhost:8123",
                api_key="sk-test-123",
                headers=None,
            )

    def test_headers_passed_to_remote_graph(self) -> None:
        """Headers kwarg is forwarded to RemoteGraph."""
        hdrs = {"Authorization": "Bearer tok", "X-Custom": "val"}
        agent = RemoteAgent(
            url="http://localhost:8123",
            graph_name="agent",
            headers=hdrs,
        )
        with patch("langgraph.pregel.remote.RemoteGraph") as mock_cls:
            agent._get_graph()
            mock_cls.assert_called_once_with(
                "agent",
                url="http://localhost:8123",
                api_key=None,
                headers=hdrs,
            )

    def test_defaults_no_auth(self) -> None:
        """Default construction passes None for api_key and headers."""
        agent = RemoteAgent(url="http://localhost:8123")
        with patch("langgraph.pregel.remote.RemoteGraph") as mock_cls:
            agent._get_graph()
            mock_cls.assert_called_once_with(
                "agent",
                url="http://localhost:8123",
                api_key=None,
                headers=None,
            )

    def test_graph_lazy_singleton(self) -> None:
        """_get_graph creates RemoteGraph once and caches it."""
        agent = RemoteAgent(url="http://localhost:8123")
        with patch("langgraph.pregel.remote.RemoteGraph") as mock_cls:
            g1 = agent._get_graph()
            g2 = agent._get_graph()
            assert g1 is g2
            mock_cls.assert_called_once()


class TestRemoteAgentWithConfig:
    def test_returns_self(self) -> None:
        agent = RemoteAgent(url="http://localhost:8123", graph_name="agent")
        assert agent.with_config({"configurable": {}}) is agent


class TestFormatAgentException:
    """Cover the rendering helper for agent-stream exceptions."""

    def test_remote_exception_dict_payload(self) -> None:
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException(
            {"error": "ToolException", "message": "An internal error occurred"}
        )
        assert (
            format_agent_exception(exc) == "ToolException: An internal error occurred"
        )

    def test_remote_exception_dict_payload_no_message(self) -> None:
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException({"error": "ToolException"})
        assert format_agent_exception(exc) == "ToolException"

    def test_remote_exception_dict_payload_empty_message(self) -> None:
        """Falsy `message` still falls through to the error-type-only branch."""
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException({"error": "ToolException", "message": ""})
        assert format_agent_exception(exc) == "ToolException"

    def test_remote_exception_dict_payload_non_string_error(self) -> None:
        """Non-string `error` keys must not crash; class name stands in."""
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException({"error": 500, "message": "boom"})
        # `agent_error_type` ignores the non-string `error` and uses the class
        # name, so the message still renders cleanly.
        assert format_agent_exception(exc) == "RemoteException: boom"

    def test_remote_exception_dict_payload_empty_dict(self) -> None:
        """Empty payload dict resolves `error` to the exception class name."""
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException({})
        # `payload.get("error") or type(exc).__name__` → "RemoteException",
        # and `message` is None so the err-only branch returns the class.
        assert format_agent_exception(exc) == "RemoteException"

    def test_remote_exception_non_dict_payload(self) -> None:
        """`RemoteException("string")` is not the dict shape; uses `str(exc)`."""
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException("just a string")
        assert format_agent_exception(exc) == "just a string"

    def test_plain_exception_uses_str(self) -> None:
        assert format_agent_exception(ValueError("bad thing")) == "bad thing"

    def test_exception_without_message_falls_back_to_type(self) -> None:
        class _BoomError(Exception):
            pass

        assert format_agent_exception(_BoomError()) == "_BoomError"


class TestAgentErrorType:
    """Cover the shared error-type extraction used for UI dispatch."""

    def test_dict_payload_error_key_wins(self) -> None:
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException({"error": "PermissionDeniedError", "message": "x"})
        assert agent_error_type(exc) == "PermissionDeniedError"

    def test_empty_args_uses_class_name(self) -> None:
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException()
        assert exc.args == ()
        assert agent_error_type(exc) == "RemoteException"

    def test_dict_without_error_key_uses_class_name(self) -> None:
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException({"message": "x"})
        assert agent_error_type(exc) == "RemoteException"

    def test_non_string_error_key_uses_class_name(self) -> None:
        from langgraph.pregel.remote import RemoteException

        exc = RemoteException({"error": 500})
        assert agent_error_type(exc) == "RemoteException"

    def test_non_dict_payload_uses_class_name(self) -> None:
        assert agent_error_type(ValueError("boom")) == "ValueError"
