c2743251e9
Self-evolving AI scientist framework built on LangGraph/LangChain with CLI/TUI core, FastAPI gateway, and Next.js frontend. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
134 lines
4.7 KiB
Python
134 lines
4.7 KiB
Python
"""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)
|