222 lines
7.0 KiB
Python
222 lines
7.0 KiB
Python
"""Tests for ToolErrorHandlerMiddleware."""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from langchain_core.messages import ToolMessage
|
|
from langgraph.types import Command
|
|
|
|
from EvoScientist.middleware.tool_error_handler import (
|
|
ToolErrorHandlerMiddleware,
|
|
_build_error_message,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_request(tool_name: str = "my_mcp_tool", call_id: str = "tc_001"):
|
|
"""Create a minimal ToolCallRequest-like object."""
|
|
req = MagicMock()
|
|
req.tool_call = {"id": call_id, "name": tool_name, "args": {"query": "test"}}
|
|
return req
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_error_message
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildErrorMessage:
|
|
def test_returns_tool_message(self):
|
|
req = _make_request("broken_tool", "tc_123")
|
|
try:
|
|
raise RuntimeError("connection refused")
|
|
except RuntimeError:
|
|
msg = _build_error_message(req)
|
|
|
|
assert isinstance(msg, ToolMessage)
|
|
assert msg.tool_call_id == "tc_123"
|
|
assert msg.name == "broken_tool"
|
|
assert msg.status == "error"
|
|
|
|
def test_content_includes_tool_name(self):
|
|
req = _make_request("broken_tool")
|
|
try:
|
|
raise ValueError("bad value")
|
|
except ValueError:
|
|
msg = _build_error_message(req)
|
|
|
|
assert "broken_tool" in msg.content
|
|
|
|
def test_content_includes_traceback(self):
|
|
req = _make_request()
|
|
try:
|
|
raise RuntimeError("something went wrong")
|
|
except RuntimeError:
|
|
msg = _build_error_message(req)
|
|
|
|
assert "RuntimeError" in msg.content
|
|
assert "something went wrong" in msg.content
|
|
assert "Traceback" in msg.content
|
|
|
|
def test_content_includes_retry_guidance(self):
|
|
req = _make_request()
|
|
try:
|
|
raise Exception("fail")
|
|
except Exception:
|
|
msg = _build_error_message(req)
|
|
|
|
assert "retry" in msg.content.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync: wrap_tool_call
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWrapToolCallSync:
|
|
def setup_method(self):
|
|
self.mw = ToolErrorHandlerMiddleware()
|
|
|
|
def test_success_passes_through(self):
|
|
expected = ToolMessage(content="ok", tool_call_id="tc_001", name="t")
|
|
handler = MagicMock(return_value=expected)
|
|
req = _make_request()
|
|
|
|
result = self.mw.wrap_tool_call(req, handler)
|
|
|
|
assert result is expected
|
|
handler.assert_called_once_with(req)
|
|
|
|
def test_command_passes_through(self):
|
|
cmd = Command(update={"messages": []})
|
|
handler = MagicMock(return_value=cmd)
|
|
req = _make_request()
|
|
|
|
result = self.mw.wrap_tool_call(req, handler)
|
|
|
|
assert result is cmd
|
|
|
|
def test_exception_returns_error_tool_message(self):
|
|
handler = MagicMock(side_effect=RuntimeError("MCP server crashed"))
|
|
req = _make_request("flaky_tool", "tc_999")
|
|
|
|
result = self.mw.wrap_tool_call(req, handler)
|
|
|
|
assert isinstance(result, ToolMessage)
|
|
assert result.status == "error"
|
|
assert result.tool_call_id == "tc_999"
|
|
assert result.name == "flaky_tool"
|
|
assert "MCP server crashed" in result.content
|
|
|
|
def test_exception_does_not_propagate(self):
|
|
handler = MagicMock(side_effect=ConnectionError("refused"))
|
|
req = _make_request()
|
|
|
|
# Should NOT raise
|
|
result = self.mw.wrap_tool_call(req, handler)
|
|
assert isinstance(result, ToolMessage)
|
|
|
|
def test_keyboard_interrupt_propagates(self):
|
|
"""KeyboardInterrupt should NOT be caught (it's BaseException, not Exception)."""
|
|
handler = MagicMock(side_effect=KeyboardInterrupt())
|
|
req = _make_request()
|
|
|
|
with pytest.raises(KeyboardInterrupt):
|
|
self.mw.wrap_tool_call(req, handler)
|
|
|
|
def test_various_exception_types(self):
|
|
"""Different exception types are all caught and reported."""
|
|
for exc_cls in (ValueError, TypeError, OSError, TimeoutError, ConnectionError):
|
|
handler = MagicMock(side_effect=exc_cls(f"{exc_cls.__name__} happened"))
|
|
req = _make_request()
|
|
|
|
result = self.mw.wrap_tool_call(req, handler)
|
|
|
|
assert isinstance(result, ToolMessage)
|
|
assert result.status == "error"
|
|
assert exc_cls.__name__ in result.content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async: awrap_tool_call
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWrapToolCallAsync:
|
|
def setup_method(self):
|
|
self.mw = ToolErrorHandlerMiddleware()
|
|
|
|
@staticmethod
|
|
def _run(coro):
|
|
from tests.conftest import run_async
|
|
|
|
return run_async(coro)
|
|
|
|
def test_success_passes_through(self):
|
|
expected = ToolMessage(content="ok", tool_call_id="tc_001", name="t")
|
|
|
|
async def handler(req):
|
|
return expected
|
|
|
|
req = _make_request()
|
|
result = self._run(self.mw.awrap_tool_call(req, handler))
|
|
|
|
assert result is expected
|
|
|
|
def test_command_passes_through(self):
|
|
cmd = Command(update={"messages": []})
|
|
|
|
async def handler(req):
|
|
return cmd
|
|
|
|
req = _make_request()
|
|
result = self._run(self.mw.awrap_tool_call(req, handler))
|
|
|
|
assert result is cmd
|
|
|
|
def test_exception_returns_error_tool_message(self):
|
|
async def handler(req):
|
|
raise RuntimeError("MCP server timed out")
|
|
|
|
req = _make_request("slow_tool", "tc_async")
|
|
result = self._run(self.mw.awrap_tool_call(req, handler))
|
|
|
|
assert isinstance(result, ToolMessage)
|
|
assert result.status == "error"
|
|
assert result.tool_call_id == "tc_async"
|
|
assert result.name == "slow_tool"
|
|
assert "MCP server timed out" in result.content
|
|
|
|
def test_exception_does_not_propagate(self):
|
|
async def handler(req):
|
|
raise ConnectionError("connection lost")
|
|
|
|
req = _make_request()
|
|
result = self._run(self.mw.awrap_tool_call(req, handler))
|
|
assert isinstance(result, ToolMessage)
|
|
|
|
def test_keyboard_interrupt_propagates(self):
|
|
async def handler(req):
|
|
raise KeyboardInterrupt()
|
|
|
|
req = _make_request()
|
|
with pytest.raises(KeyboardInterrupt):
|
|
self._run(self.mw.awrap_tool_call(req, handler))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Middleware metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMiddlewareMeta:
|
|
def test_name(self):
|
|
assert ToolErrorHandlerMiddleware.name == "tool_error_handler"
|
|
|
|
def test_instantiation(self):
|
|
mw = ToolErrorHandlerMiddleware()
|
|
assert mw.name == "tool_error_handler"
|