mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
249 lines
9.5 KiB
Python
249 lines
9.5 KiB
Python
"""Test edge cases for the batch tool to prevent errors."""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
from mcp.server.fastmcp import Context as MCPContext
|
|
|
|
from hanzo_mcp.tools.common.batch_tool import BatchTool
|
|
|
|
|
|
class TestBatchToolEdgeCases:
|
|
"""Test edge cases for the batch tool."""
|
|
|
|
@pytest.fixture
|
|
def mock_ctx(self):
|
|
"""Create a mock MCP context."""
|
|
ctx = Mock(spec=MCPContext)
|
|
ctx.meta = {"tool_manager": Mock()}
|
|
return ctx
|
|
|
|
@pytest.fixture
|
|
def batch_tool(self):
|
|
"""Create a batch tool instance."""
|
|
# Create some mock tools
|
|
mock_tool1 = Mock(spec=["name", "call"])
|
|
mock_tool1.name = "tool1"
|
|
mock_tool1.call = AsyncMock(return_value="Tool 1 result")
|
|
|
|
mock_tool2 = Mock(spec=["name", "call"])
|
|
mock_tool2.name = "tool2"
|
|
mock_tool2.call = AsyncMock(return_value="Tool 2 result")
|
|
|
|
tools = {"tool1": mock_tool1, "tool2": mock_tool2}
|
|
|
|
return BatchTool(tools)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_invocations_error(self, tool_helper, batch_tool, mock_ctx):
|
|
"""Test that empty invocations list raises an error."""
|
|
# Create mock tool context
|
|
mock_tool_ctx = Mock()
|
|
mock_tool_ctx.set_tool_info = AsyncMock()
|
|
mock_tool_ctx.error = AsyncMock()
|
|
|
|
with patch(
|
|
"hanzo_mcp.tools.common.context.create_tool_context",
|
|
return_value=mock_tool_ctx,
|
|
):
|
|
result = await batch_tool.call(
|
|
ctx=mock_ctx,
|
|
description="Test batch",
|
|
invocations=[], # Empty list should fail
|
|
)
|
|
|
|
# Should return an error message
|
|
tool_helper.assert_in_result("Error:", result)
|
|
tool_helper.assert_in_result("invocations", result)
|
|
tool_helper.assert_in_result("empty", result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_tool_name(self, tool_helper, batch_tool, mock_ctx):
|
|
"""Test handling of invalid tool names."""
|
|
# Create mock tool context
|
|
mock_tool_ctx = Mock()
|
|
mock_tool_ctx.set_tool_info = AsyncMock()
|
|
mock_tool_ctx.error = AsyncMock()
|
|
mock_tool_ctx.info = AsyncMock()
|
|
|
|
with patch(
|
|
"hanzo_mcp.tools.common.context.create_tool_context",
|
|
return_value=mock_tool_ctx,
|
|
):
|
|
result = await batch_tool.call(
|
|
ctx=mock_ctx,
|
|
description="Test invalid tool",
|
|
invocations=[{"tool_name": "nonexistent_tool", "input": {}}],
|
|
)
|
|
|
|
# Batch tool returns results as a string
|
|
assert isinstance(result, str)
|
|
tool_helper.assert_in_result("Error", result)
|
|
tool_helper.assert_in_result("not found", result)
|
|
tool_helper.assert_in_result("nonexistent_tool", result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_execution_error(self, tool_helper, batch_tool, mock_ctx):
|
|
"""Test handling of tool execution errors."""
|
|
# Make existing tool1 raise an exception
|
|
batch_tool.tools["tool1"].call.side_effect = Exception("Tool execution failed")
|
|
|
|
result = await batch_tool.call(
|
|
ctx=mock_ctx,
|
|
description="Test error handling",
|
|
invocations=[{"tool_name": "tool1", "input": {"param": "value"}}],
|
|
)
|
|
|
|
# Should capture the error
|
|
assert isinstance(result, str)
|
|
tool_helper.assert_in_result("Error", result)
|
|
tool_helper.assert_in_result("Tool execution failed", result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mixed_success_and_failure(self, tool_helper, batch_tool, mock_ctx):
|
|
"""Test batch with both successful and failing tools."""
|
|
# Use existing tools - tool1 succeeds, tool2 fails
|
|
batch_tool.tools["tool1"].call.reset_mock()
|
|
batch_tool.tools["tool1"].call.side_effect = None
|
|
batch_tool.tools["tool1"].call.return_value = {"result": "success"}
|
|
|
|
batch_tool.tools["tool2"].call.side_effect = Exception("Failed")
|
|
|
|
result = await batch_tool.call(
|
|
ctx=mock_ctx,
|
|
description="Mixed results",
|
|
invocations=[
|
|
{"tool_name": "tool1", "input": {}},
|
|
{"tool_name": "tool2", "input": {}},
|
|
{"tool_name": "tool1", "input": {"param": "2"}},
|
|
],
|
|
)
|
|
|
|
# Check for mixed results in string output
|
|
assert isinstance(result, str)
|
|
tool_helper.assert_in_result("Result 1: tool1", result)
|
|
tool_helper.assert_in_result("Result 2: tool2", result)
|
|
tool_helper.assert_in_result("Result 3: tool1", result)
|
|
tool_helper.assert_in_result("Error", result) # For the failed tool
|
|
tool_helper.assert_in_result("Failed", result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_large_batch_pagination(self, tool_helper, batch_tool, mock_ctx):
|
|
"""Test pagination with large batch results."""
|
|
# Create mock tool context
|
|
mock_tool_ctx = Mock()
|
|
mock_tool_ctx.set_tool_info = AsyncMock()
|
|
mock_tool_ctx.error = AsyncMock()
|
|
mock_tool_ctx.info = AsyncMock()
|
|
|
|
# Make tools return large output
|
|
batch_tool.tools["tool1"].call = AsyncMock(
|
|
return_value="X" * 100000
|
|
) # 100KB output
|
|
|
|
# Create many invocations
|
|
invocations = [{"tool_name": "tool1", "input": {"id": i}} for i in range(50)]
|
|
|
|
with patch(
|
|
"hanzo_mcp.tools.common.context.create_tool_context",
|
|
return_value=mock_tool_ctx,
|
|
):
|
|
result = await batch_tool.call(
|
|
ctx=mock_ctx, description="Large batch", invocations=invocations
|
|
)
|
|
|
|
# Should handle pagination
|
|
tool_helper.assert_in_result("results", result)
|
|
# May have pagination info if output is too large
|
|
if "_pagination" in result:
|
|
assert "cursor" in result["_pagination"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_execution_limit(self, tool_helper, batch_tool, mock_ctx):
|
|
"""Test that concurrent execution respects limits."""
|
|
execution_times = []
|
|
|
|
async def slow_tool_call(*args, **kwargs):
|
|
start = asyncio.get_event_loop().time()
|
|
await asyncio.sleep(0.1) # Simulate work
|
|
execution_times.append(start)
|
|
return {"result": "done"}
|
|
|
|
# Use existing tool1 and make it slow
|
|
batch_tool.tools["tool1"].call = slow_tool_call
|
|
|
|
# Create many invocations
|
|
invocations = [{"tool_name": "tool1", "input": {"id": i}} for i in range(20)]
|
|
|
|
start_time = asyncio.get_event_loop().time()
|
|
await batch_tool.call(
|
|
ctx=mock_ctx, description="Concurrent test", invocations=invocations
|
|
)
|
|
end_time = asyncio.get_event_loop().time()
|
|
|
|
# Check that execution was concurrent but limited
|
|
total_time = end_time - start_time
|
|
|
|
# If all ran sequentially, would take 2 seconds (20 * 0.1)
|
|
# If all ran in parallel with no limit, would take ~0.1 seconds
|
|
# With concurrency limit, should be somewhere in between
|
|
assert total_time < 2.0 # Confirms some parallelism
|
|
# Relax the lower bound as concurrency behavior may vary
|
|
assert total_time > 0.05 # At least some execution time
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_input_types(self, tool_helper, batch_tool, mock_ctx):
|
|
"""Test handling of invalid input types."""
|
|
# Use existing tool1
|
|
batch_tool.tools["tool1"].call.reset_mock()
|
|
batch_tool.tools["tool1"].call.side_effect = None
|
|
batch_tool.tools["tool1"].call.return_value = {"result": "ok"}
|
|
|
|
# Test with various invalid input types
|
|
test_cases = [
|
|
# String instead of dict
|
|
{"tool_name": "tool1", "input": "not a dict"},
|
|
# List instead of dict
|
|
{"tool_name": "tool1", "input": ["not", "a", "dict"]},
|
|
# None input
|
|
{"tool_name": "tool1", "input": None},
|
|
]
|
|
|
|
for invalid_invocation in test_cases:
|
|
# Should handle gracefully or convert
|
|
result = await batch_tool.call(
|
|
ctx=mock_ctx,
|
|
description="Invalid input test",
|
|
invocations=[invalid_invocation],
|
|
)
|
|
|
|
tool_helper.assert_in_result("results", result)
|
|
# Either handles it or returns error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_name_normalization(self, tool_helper, batch_tool, mock_ctx):
|
|
"""Test that tool names are normalized properly."""
|
|
# Batch tool doesn't normalize names - it looks them up exactly
|
|
# So all variations will fail to find the tool
|
|
|
|
# Test with various name formats
|
|
invocations = [
|
|
{"tool_name": "TEST_TOOL", "input": {}},
|
|
{"tool_name": " test_tool ", "input": {}},
|
|
{"tool_name": "Test_Tool", "input": {}},
|
|
]
|
|
|
|
result = await batch_tool.call(
|
|
ctx=mock_ctx, description="Name normalization", invocations=invocations
|
|
)
|
|
|
|
# Should handle all variations - but batch tool doesn't normalize names
|
|
assert isinstance(result, str)
|
|
tool_helper.assert_in_result("Result 1", result)
|
|
tool_helper.assert_in_result("Result 2", result)
|
|
tool_helper.assert_in_result("Result 3", result)
|
|
# Since batch tool doesn't normalize names, these will all be "not found" errors
|
|
tool_helper.assert_in_result("Error", result)
|
|
tool_helper.assert_in_result("not found", result)
|