Files
python-sdk/pkg/hanzo-mcp/tests/test_cli_tools.py
T
zeekay 7ff34ba5bf feat(hanzo-tools): v0.10.17 - modular tool packages + computer control
- Add hanzo-tools-computer package for pyautogui Mac automation
- Fix hanzo-tools-agent imports to use new package structure
- Add get_read_only_*_tools() helpers to filesystem/jupyter
- Remove BatchTool (replaced by dag - one tool per capability)
- Remove hanzo-persona dependency (not published yet)
- Simplify pyproject.toml for local development

Packages ready for PyPI:
- hanzo-tools-core
- hanzo-tools-filesystem
- hanzo-tools-shell
- hanzo-tools-browser
- hanzo-tools-memory
- hanzo-tools-todo
- hanzo-tools-reasoning
- hanzo-tools-lsp
- hanzo-tools-refactor
- hanzo-tools-database
- hanzo-tools-agent
- hanzo-tools-jupyter
- hanzo-tools-editor
- hanzo-tools-computer (NEW)
2025-12-26 06:37:28 -08:00

351 lines
13 KiB
Python

"""Test suite for CLI tools in batch operations."""
import os
import time
import shutil
import asyncio
from typing import Any, Dict
from unittest.mock import Mock, AsyncMock, MagicMock, patch
import pytest
from hanzo_tools.agent.cli_tools import (
GrokCLITool,
AiderCLITool,
ClineCLITool,
CodexCLITool,
ClaudeCLITool,
GeminiCLITool,
HanzoDevCLITool,
OpenHandsCLITool,
ClaudeCodeCLITool,
OpenHandsShortCLITool,
)
from hanzo_mcp.tools.common.batch_tool import BatchTool
class TestCLITools:
"""Test CLI tool implementations."""
@pytest.fixture
def mock_context(self):
"""Create a mock MCP context."""
context = MagicMock()
context.session = MagicMock()
context.session.send_log_message = AsyncMock()
context.session.send_progress = AsyncMock()
return context
@pytest.fixture
def mock_permission_manager(self):
"""Create a mock permission manager."""
pm = MagicMock()
pm.check_permission = Mock(return_value=True)
return pm
@pytest.mark.asyncio
async def test_claude_cli_tool(self, mock_context, mock_permission_manager):
"""Test Claude CLI tool execution."""
tool = ClaudeCLITool(mock_permission_manager)
# Test name and description
assert tool.name == "claude"
assert "Claude CLI" in tool.description
# Mock subprocess execution
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Claude response"
result = await tool.call(mock_context, prompt="Test prompt", model="claude-3-opus-20240229", timeout=300)
assert result == "Claude response"
mock_exec.assert_called_once()
@pytest.mark.asyncio
async def test_claude_code_alias(self, mock_context, mock_permission_manager):
"""Test Claude Code (cc) alias tool."""
tool = ClaudeCodeCLITool(mock_permission_manager)
# Test alias name
assert tool.name == "cc"
assert "Claude Code" in tool.description
@pytest.mark.asyncio
async def test_codex_cli_tool(self, mock_context, mock_permission_manager):
"""Test Codex/GPT-4 CLI tool execution."""
tool = CodexCLITool(mock_permission_manager)
assert tool.name == "codex"
assert "OpenAI" in tool.description or "GPT" in tool.description
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "GPT-4 response"
result = await tool.call(mock_context, prompt="Generate code", model="gpt-4-turbo")
assert result == "GPT-4 response"
@pytest.mark.asyncio
async def test_gemini_cli_tool(self, mock_context, mock_permission_manager):
"""Test Gemini CLI tool execution."""
tool = GeminiCLITool(mock_permission_manager)
assert tool.name == "gemini"
assert "Gemini" in tool.description
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Gemini response"
result = await tool.call(mock_context, prompt="Analyze image", model="gemini-1.5-pro")
assert result == "Gemini response"
@pytest.mark.asyncio
async def test_grok_cli_tool(self, mock_context, mock_permission_manager):
"""Test Grok CLI tool execution."""
tool = GrokCLITool(mock_permission_manager)
assert tool.name == "grok"
assert "Grok" in tool.description
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Grok response"
result = await tool.call(mock_context, prompt="Real-time analysis", model="grok-2")
assert result == "Grok response"
@pytest.mark.asyncio
async def test_openhands_cli_tool(self, mock_context, mock_permission_manager):
"""Test OpenHands CLI tool execution."""
tool = OpenHandsCLITool(mock_permission_manager)
assert tool.name == "openhands"
assert "OpenHands" in tool.description or "OpenDevin" in tool.description
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "OpenHands execution complete"
result = await tool.call(mock_context, prompt="Build feature", working_dir="/project")
assert result == "OpenHands execution complete"
@pytest.mark.asyncio
async def test_openhands_alias(self, mock_context, mock_permission_manager):
"""Test OpenHands (oh) alias tool."""
tool = OpenHandsShortCLITool(mock_permission_manager)
assert tool.name == "oh"
assert "OpenHands" in tool.description
@pytest.mark.asyncio
async def test_hanzo_dev_cli_tool(self, mock_context, mock_permission_manager):
"""Test Hanzo Dev CLI tool execution."""
tool = HanzoDevCLITool(mock_permission_manager)
assert tool.name == "hanzo_dev"
assert "Hanzo Dev" in tool.description
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Hanzo Dev completed task"
result = await tool.call(mock_context, prompt="Implement feature", model="claude-3-5-sonnet-20241022")
assert result == "Hanzo Dev completed task"
@pytest.mark.asyncio
async def test_cline_cli_tool(self, mock_context, mock_permission_manager):
"""Test Cline CLI tool execution."""
tool = ClineCLITool(mock_permission_manager)
assert tool.name == "cline"
assert "Cline" in tool.description
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Cline autonomous coding complete"
result = await tool.call(mock_context, prompt="Fix bugs", working_dir="/src")
assert result == "Cline autonomous coding complete"
@pytest.mark.asyncio
async def test_aider_cli_tool(self, mock_context, mock_permission_manager):
"""Test Aider CLI tool execution."""
tool = AiderCLITool(mock_permission_manager)
assert tool.name == "aider"
assert "Aider" in tool.description
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Aider pair programming complete"
result = await tool.call(mock_context, prompt="Refactor module", model="gpt-4-turbo")
assert result == "Aider pair programming complete"
@pytest.mark.asyncio
async def test_cli_tool_auth_env(self, mock_permission_manager):
"""Test that CLI tools properly set authentication environment."""
# Test Claude with Anthropic key
with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-anthropic-key"}):
tool = ClaudeCLITool(mock_permission_manager)
env = tool.get_auth_env()
assert env["ANTHROPIC_API_KEY"] == "test-anthropic-key"
# Test Codex with OpenAI key
with patch.dict("os.environ", {"OPENAI_API_KEY": "test-openai-key"}):
tool = CodexCLITool(mock_permission_manager)
env = tool.get_auth_env()
assert env["OPENAI_API_KEY"] == "test-openai-key"
# Test unified Hanzo auth
with patch.dict("os.environ", {"HANZO_API_KEY": "test-hanzo-key"}):
tool = OpenHandsCLITool(mock_permission_manager)
env = tool.get_auth_env()
assert env["HANZO_API_KEY"] == "test-hanzo-key"
@pytest.mark.asyncio
async def test_cli_tool_timeout(self, mock_context, mock_permission_manager):
"""Test CLI tool timeout handling."""
tool = ClaudeCLITool(mock_permission_manager)
# Mock a timeout
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
mock_process = MagicMock()
mock_process.communicate = AsyncMock(side_effect=asyncio.TimeoutError)
mock_subprocess.return_value = mock_process
result = await tool.call(mock_context, prompt="Long task", timeout=1)
assert "timed out" in result.lower()
@pytest.mark.asyncio
async def test_cli_tool_error_handling(self, mock_context, mock_permission_manager):
"""Test CLI tool error handling."""
tool = CodexCLITool(mock_permission_manager)
# Mock a subprocess error
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
mock_process = MagicMock()
mock_process.communicate = AsyncMock(return_value=(b"", b"Error: API key not found"))
mock_process.returncode = 1
mock_subprocess.return_value = mock_process
result = await tool.call(mock_context, prompt="Generate code")
assert "Error" in result
assert "API key" in result
class TestBatchWithCLITools:
"""Test batch tool with CLI tools."""
@pytest.fixture
def mock_permission_manager(self):
"""Create a mock permission manager."""
pm = MagicMock()
pm.check_permission = Mock(return_value=True)
return pm
@pytest.fixture
def mock_tools(self, mock_permission_manager):
"""Create mock CLI tools for batch testing."""
tools = {
"claude": ClaudeCLITool(mock_permission_manager),
"cc": ClaudeCodeCLITool(mock_permission_manager),
"codex": CodexCLITool(mock_permission_manager),
"gemini": GeminiCLITool(mock_permission_manager),
"grok": GrokCLITool(mock_permission_manager),
"openhands": OpenHandsCLITool(mock_permission_manager),
"oh": OpenHandsShortCLITool(mock_permission_manager),
"cline": ClineCLITool(mock_permission_manager),
"aider": AiderCLITool(mock_permission_manager),
}
# Mock execute_cli for all tools
for tool in tools.values():
tool.execute_cli = AsyncMock(return_value=f"{tool.name} response")
return tools
@pytest.fixture
def batch_tool(self, mock_tools):
"""Create batch tool with CLI tools."""
return BatchTool(mock_tools)
@pytest.fixture
def mock_context(self):
"""Create a mock MCP context."""
context = MagicMock()
context.session = MagicMock()
context.session.send_log_message = AsyncMock()
context.session.send_progress = AsyncMock()
return context
@pytest.mark.asyncio
async def test_batch_with_multiple_cli_tools(self, batch_tool, mock_context):
"""Test batch execution with multiple CLI tools."""
invocations = [
{"tool_name": "claude", "input": {"prompt": "Analyze architecture"}},
{"tool_name": "codex", "input": {"prompt": "Generate implementation"}},
{"tool_name": "gemini", "input": {"prompt": "Review code"}},
]
result = await batch_tool.call(mock_context, description="Multi-AI analysis", invocations=invocations)
# Check that all tools were called
assert "claude response" in result
assert "codex response" in result
assert "gemini response" in result
@pytest.mark.asyncio
async def test_batch_with_cli_aliases(self, batch_tool, mock_context):
"""Test batch execution with CLI tool aliases."""
invocations = [
{
"tool_name": "cc", # Claude Code alias
"input": {"prompt": "Quick analysis"},
},
{
"tool_name": "oh", # OpenHands alias
"input": {"prompt": "Build feature"},
},
]
result = await batch_tool.call(mock_context, description="Test aliases", invocations=invocations)
assert "cc response" in result
assert "oh response" in result
@pytest.mark.asyncio
async def test_batch_cli_tools_parallel_execution(self, batch_tool, mock_context):
"""Test that CLI tools execute in parallel in batch."""
import time
# Add delay to mock executions
for tool in batch_tool.tools.values():
async def delayed_response(cmd, **kwargs):
await asyncio.sleep(0.1) # 100ms delay
return f"{tool.name} response"
tool.execute_cli = delayed_response
invocations = [
{"tool_name": "claude", "input": {"prompt": "Task 1"}},
{"tool_name": "codex", "input": {"prompt": "Task 2"}},
{"tool_name": "gemini", "input": {"prompt": "Task 3"}},
{"tool_name": "grok", "input": {"prompt": "Task 4"}},
]
start = time.time()
result = await batch_tool.call(mock_context, description="Parallel test", invocations=invocations)
duration = time.time() - start
# If executed in parallel, should take ~100ms, not 400ms
assert duration < 0.3 # Allow some overhead
assert all(tool in result for tool in ["claude", "codex", "gemini", "grok"])
# Integration tests for CLI tools have been moved to tests/e2e/test_cli_tools_integration.py
# These tests require the actual CLI tools to be installed and API keys to be set