"""Tests for ToolErrorHandlerMiddleware and _friendly_error_summary.""" import traceback from unittest.mock import MagicMock import httpx import pytest from EvoScientist.middleware.tool_error_handler import ( ToolErrorHandlerMiddleware, _friendly_error_summary, ) # --------------------------------------------------------------------------- # _friendly_error_summary — unit tests # --------------------------------------------------------------------------- def _make_tb(exc: BaseException) -> str: """Capture the traceback string for *exc* as if it were unhandled.""" try: raise exc except type(exc): return traceback.format_exc() class TestFriendlyErrorSummary: """Verify each pattern recognised by _friendly_error_summary.""" def test_connect_error_generic(self): tb = _make_tb(httpx.ConnectError("Connection refused")) result = _friendly_error_summary(tb, "my_tool") assert "Network connection failed" in result assert "my_tool" in result def test_connect_error_with_api_hint(self): tb = _make_tb(httpx.ConnectError("mineru.net refused")) result = _friendly_error_summary(tb, "parse_documents") assert "upstream API server" in result assert "parse_documents" in result def test_timeout_exception(self): tb = _make_tb(httpx.TimeoutException("read timed out")) result = _friendly_error_summary(tb, "slow_tool") assert "timed out" in result assert "slow_tool" in result def test_mcp_tool_exception_wrapper(self): """Simulate the MCP adapter wrapper: 'Error calling tool 'xxx': msg'.""" tb = _make_tb( RuntimeError("Error calling tool 'parse_documents': File too large") ) result = _friendly_error_summary(tb, "parse_documents") assert "File too large" in result def test_mcp_tool_exception_empty_inner(self): """MCP wrapper with no inner message falls through to next pattern.""" tb = _make_tb(RuntimeError("Error calling tool 'x': ")) # Should fall through to the last-line fallback result = _friendly_error_summary(tb, "x") assert result # not empty def test_generic_exception_last_line(self): tb = _make_tb(ValueError("bad argument 'foo'")) result = _friendly_error_summary(tb, "my_tool") assert "bad argument" in result or "ValueError" in result def test_empty_traceback_fallback(self): result = _friendly_error_summary("", "mystery_tool") assert "unexpected error" in result assert "mystery_tool" in result # --------------------------------------------------------------------------- # ToolErrorHandlerMiddleware — integration tests # --------------------------------------------------------------------------- class TestToolErrorHandlerMiddleware: """Verify wrap_tool_call / awrap_tool_call convert exceptions to ToolMessage.""" def _make_request(self, tool_name: str = "test_tool") -> MagicMock: req = MagicMock() req.tool_call = {"name": tool_name, "id": "call_123", "args": {}} return req def test_sync_success_passthrough(self): mw = ToolErrorHandlerMiddleware() req = self._make_request() expected = MagicMock() result = mw.wrap_tool_call(req, handler=lambda r: expected) assert result is expected def test_sync_exception_returns_error_message(self): mw = ToolErrorHandlerMiddleware() req = self._make_request("failing_tool") def boom(r): raise httpx.ConnectError("nope") result = mw.wrap_tool_call(req, handler=boom) # result should be a ToolMessage assert hasattr(result, "content") assert "Network connection failed" in result.content assert result.status == "error" def test_async_exception_returns_error_message(self): import asyncio mw = ToolErrorHandlerMiddleware() req = self._make_request("async_tool") async def boom(r): raise ValueError("something broke") result = asyncio.get_event_loop().run_until_complete( mw.awrap_tool_call(req, handler=boom) ) assert hasattr(result, "content") assert "something broke" in result.content assert result.status == "error" def test_graph_interrupt_propagates(self): """GraphInterrupt must NOT be caught — it should re-raise.""" from langgraph.errors import GraphInterrupt mw = ToolErrorHandlerMiddleware() req = self._make_request() def interrupt(r): raise GraphInterrupt() with pytest.raises(GraphInterrupt): mw.wrap_tool_call(req, handler=interrupt)