mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
The default surface was 34 tools, 61 KB, ~15,300 tokens of schema in every
prompt of every agent. The mode system already names what a session should
carry, but only the tools register_all_tools enumerates obeyed it: zsh,
curl, playwright and the helpers (ps, npx, uvx, jq, wget, cdp, open) stayed
on in every mode. A mode now decides every discovered tool, the `hanzo`
mode lists what a coding agent calls every few steps (18 tools, 32 KB,
~8,000 tokens), and the rest is a name away: HANZO_MCP_ENABLED_TOOLS=a,b
or =all, `tool enable`, or `mode`.
hanzo-tools-shell 0.6.6: run_shell kept every byte a foreground command
wrote until the command ended. `yes` for thirty seconds is gigabytes. It
keeps the first 16 MiB per stream and drains the rest.
The tests' session-wide patch of asyncio.create_subprocess_exec handed
every shell command a stream that never reached EOF; the first test to run
one read it in a loop until the machine was out of memory (22 GB, killed).
The patch is now opt-in and its process has exited.
bench/latency.py measures the cold start, the tools/list bill and the common
calls through a real MCP client over stdio. Interleaved rounds, wall and CPU
seconds, p50 over 6 cold starts:
cold wall cold CPU tools bytes ~tokens
installed 0.15.18 9.34 s 10.46 s 30 52,601 13,150
main 0.15.20 1.52 s 1.13 s 34 61,287 15,321
this 0.96 s 1.06 s 18 32,222 8,055
The installed environment's 9 s is its hanzoai 8.5.156, whose package init
imports the whole generated cloud client; hanzo-mcp >= 0.15.20 requires
hanzoai >= 8.5.624, which loads it lazily. Per-call latency is spawn-bound
(zsh 17.6 ms, git status 33 ms, fs.read 2.5 ms at p50) and moves with the
machine's load; no call path changed.
292 lines
9.1 KiB
Python
292 lines
9.1 KiB
Python
"""Test shell features including auto-backgrounding and shell detection."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from hanzo_tools.shell.base_process import ProcessManager
|
|
from hanzo_tools.shell.ps_tool import PsTool
|
|
from hanzo_tools.shell.shell_detect import (
|
|
SUPPORTED_SHELLS,
|
|
clear_shell_cache,
|
|
detect_shells,
|
|
get_active_shell,
|
|
get_shell_tool_class,
|
|
)
|
|
from hanzo_tools.shell.shell_tools import (
|
|
BashTool,
|
|
DashTool,
|
|
FishTool,
|
|
ShellTool,
|
|
ZshTool,
|
|
)
|
|
|
|
from hanzo_mcp.tools.common.permissions import PermissionManager
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_ctx():
|
|
"""Create a mock MCP context."""
|
|
ctx = MagicMock()
|
|
return ctx
|
|
|
|
|
|
@pytest.fixture
|
|
def permission_manager():
|
|
"""Create a permission manager."""
|
|
pm = PermissionManager()
|
|
pm.add_allowed_path("/tmp")
|
|
pm.add_allowed_path(str(Path.home()))
|
|
return pm
|
|
|
|
|
|
@pytest.fixture
|
|
def bash_tool(permission_manager):
|
|
"""Create a bash tool instance."""
|
|
tool = BashTool()
|
|
tool.permission_manager = permission_manager
|
|
return tool
|
|
|
|
|
|
@pytest.fixture
|
|
def zsh_tool(permission_manager):
|
|
"""Create a zsh tool instance."""
|
|
tool = ZshTool()
|
|
tool.permission_manager = permission_manager
|
|
return tool
|
|
|
|
|
|
@pytest.fixture
|
|
def shell_tool(permission_manager):
|
|
"""Create a shell tool instance (smart shell detection)."""
|
|
tool = ShellTool()
|
|
tool.permission_manager = permission_manager
|
|
return tool
|
|
|
|
|
|
@pytest.fixture
|
|
def ps_tool_instance():
|
|
"""Create a ps tool instance."""
|
|
return PsTool()
|
|
|
|
|
|
class TestShellDetection:
|
|
"""Test shell detection functionality."""
|
|
|
|
def test_detect_shells_returns_shell_info(self):
|
|
"""Test detect_shells returns proper ShellInfo."""
|
|
info = detect_shells()
|
|
assert hasattr(info, "login_shell")
|
|
assert hasattr(info, "invoking_shell")
|
|
assert hasattr(info, "env_shell")
|
|
assert hasattr(info, "evidence")
|
|
|
|
def test_get_active_shell_returns_tuple(self):
|
|
"""Test get_active_shell returns (name, path) tuple."""
|
|
name, path = get_active_shell()
|
|
assert isinstance(name, str)
|
|
assert isinstance(path, str)
|
|
assert name in SUPPORTED_SHELLS or name == "sh"
|
|
|
|
def test_supported_shells_contains_expected(self):
|
|
"""Test SUPPORTED_SHELLS contains expected shells."""
|
|
assert "zsh" in SUPPORTED_SHELLS
|
|
assert "bash" in SUPPORTED_SHELLS
|
|
assert "fish" in SUPPORTED_SHELLS
|
|
assert "dash" in SUPPORTED_SHELLS
|
|
|
|
def test_get_shell_tool_class_returns_correct_class(self):
|
|
"""Test get_shell_tool_class returns the right tool class."""
|
|
assert get_shell_tool_class("zsh") == ZshTool
|
|
assert get_shell_tool_class("bash") == BashTool
|
|
assert get_shell_tool_class("fish") == FishTool
|
|
assert get_shell_tool_class("dash") == DashTool
|
|
assert get_shell_tool_class("unknown") is None
|
|
|
|
def test_shell_env_override(self):
|
|
"""Test HANZO_MCP_SHELL environment variable override."""
|
|
clear_shell_cache()
|
|
with patch.dict(os.environ, {"HANZO_MCP_SHELL": "bash"}, clear=False):
|
|
name, path = get_active_shell()
|
|
assert name == "bash"
|
|
clear_shell_cache()
|
|
|
|
def test_force_shell_path_override(self):
|
|
"""Test HANZO_MCP_FORCE_SHELL environment variable override."""
|
|
clear_shell_cache()
|
|
with patch.dict(
|
|
os.environ, {"HANZO_MCP_FORCE_SHELL": "/bin/bash"}, clear=False
|
|
):
|
|
name, path = get_active_shell()
|
|
assert name == "bash"
|
|
assert path == "/bin/bash"
|
|
clear_shell_cache()
|
|
|
|
def test_bash_tool_name(self, bash_tool):
|
|
"""Test BashTool has correct name."""
|
|
assert bash_tool.name == "bash"
|
|
|
|
def test_zsh_tool_name(self, zsh_tool):
|
|
"""Test ZshTool has correct name."""
|
|
assert zsh_tool.name == "zsh"
|
|
|
|
def test_shell_tool_name(self, shell_tool):
|
|
"""Test ShellTool has correct name."""
|
|
assert shell_tool.name == "shell"
|
|
|
|
|
|
class TestAutoBackgrounding:
|
|
"""Test auto-backgrounding functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_command_completes(self, bash_tool, mock_ctx):
|
|
"""Test that quick commands complete normally."""
|
|
result = await bash_tool.call(mock_ctx, command="echo 'Hello World'")
|
|
assert "Hello World" in result
|
|
assert "backgrounded" not in result.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_command_output(self, zsh_tool, mock_ctx):
|
|
"""Test command output is captured."""
|
|
result = await zsh_tool.call(mock_ctx, command="echo 'test output'")
|
|
assert "test output" in result
|
|
|
|
|
|
class TestOutputCapture:
|
|
"""A command that floods its output must not fill the server's memory while it runs."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flood_is_truncated(self, zsh_tool, mock_ctx):
|
|
result = await zsh_tool.call(
|
|
mock_ctx, command="head -c 40000000 /dev/zero | tr '\\0' x"
|
|
)
|
|
assert len(result) < 20 * 1024 * 1024
|
|
assert "[output truncated]" in result
|
|
|
|
|
|
class TestProcessManagement:
|
|
"""Test process management functionality."""
|
|
|
|
@pytest.fixture
|
|
def process_manager(self):
|
|
"""Get process manager instance."""
|
|
return ProcessManager()
|
|
|
|
def test_process_tracking(self, process_manager):
|
|
"""Test process tracking functionality."""
|
|
# Create mock process (asyncio.subprocess.Process uses returncode, not poll())
|
|
mock_process = MagicMock()
|
|
mock_process.pid = 12345
|
|
mock_process.returncode = (
|
|
None # Still running (asyncio.subprocess.Process style)
|
|
)
|
|
|
|
# Add process
|
|
process_manager.add_process("test_123", mock_process, "/tmp/test.log")
|
|
|
|
# Check it's tracked
|
|
assert process_manager.get_process("test_123") == mock_process
|
|
|
|
# List processes
|
|
processes = process_manager.list_processes()
|
|
assert "test_123" in processes
|
|
assert processes["test_123"]["pid"] == 12345
|
|
assert processes["test_123"]["running"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ps_list_command(self, ps_tool_instance, mock_ctx):
|
|
"""Test ps list command."""
|
|
result = await ps_tool_instance.call(mock_ctx)
|
|
assert isinstance(result, str)
|
|
|
|
|
|
class TestToolsListWithShellDetection:
|
|
"""Test that TOOLS list respects shell detection."""
|
|
|
|
def test_default_tools_list_has_one_shell(self):
|
|
"""Test default TOOLS list only includes detected shell."""
|
|
clear_shell_cache()
|
|
# Re-import to get fresh TOOLS list
|
|
from hanzo_tools.shell import TOOLS, get_cached_active_shell
|
|
|
|
shell_name, _ = get_cached_active_shell()
|
|
|
|
# Count shell tools
|
|
shell_tools = [
|
|
t for t in TOOLS if hasattr(t, "name") and t.name in SUPPORTED_SHELLS
|
|
]
|
|
|
|
# Should have exactly one shell tool (the detected one)
|
|
assert len(shell_tools) == 1
|
|
assert shell_tools[0].name == shell_name
|
|
|
|
def test_all_shells_mode(self):
|
|
"""Test HANZO_MCP_ALL_SHELLS=1 exposes all shells."""
|
|
clear_shell_cache()
|
|
with patch.dict(os.environ, {"HANZO_MCP_ALL_SHELLS": "1"}, clear=False):
|
|
# Re-import to get fresh TOOLS list
|
|
import importlib
|
|
|
|
import hanzo_tools.shell
|
|
|
|
importlib.reload(hanzo_tools.shell)
|
|
from hanzo_tools.shell import TOOLS
|
|
|
|
# Count shell tools
|
|
shell_tools = [
|
|
t for t in TOOLS if hasattr(t, "name") and t.name in SUPPORTED_SHELLS
|
|
]
|
|
|
|
# Should have all 4 shell tools
|
|
assert len(shell_tools) == 4
|
|
shell_names = {t.name for t in shell_tools}
|
|
assert shell_names == SUPPORTED_SHELLS
|
|
|
|
# Cleanup
|
|
clear_shell_cache()
|
|
if "HANZO_MCP_ALL_SHELLS" in os.environ:
|
|
del os.environ["HANZO_MCP_ALL_SHELLS"]
|
|
import importlib
|
|
|
|
import hanzo_tools.shell
|
|
|
|
importlib.reload(hanzo_tools.shell)
|
|
|
|
|
|
class TestIntegration:
|
|
"""Integration tests for shell features."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bash_command_execution(self, bash_tool, mock_ctx):
|
|
"""Test bash command execution."""
|
|
result = await bash_tool.call(mock_ctx, command="echo 'integration test'")
|
|
assert "integration test" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zsh_command_execution(self, zsh_tool, mock_ctx):
|
|
"""Test zsh command execution."""
|
|
result = await zsh_tool.call(mock_ctx, command="echo 'zsh test'")
|
|
assert "zsh test" in result
|
|
|
|
def test_tool_names_are_correct(self, bash_tool, zsh_tool, shell_tool):
|
|
"""Test all shell tools have correct names."""
|
|
assert bash_tool.name == "bash"
|
|
assert zsh_tool.name == "zsh"
|
|
assert shell_tool.name == "shell"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_to_end_workflow():
|
|
"""Test complete workflow with real commands."""
|
|
pm = PermissionManager()
|
|
pm.add_allowed_path("/tmp")
|
|
|
|
bash = BashTool()
|
|
bash.permission_manager = pm
|
|
|
|
ps = PsTool()
|
|
|
|
assert bash.name == "bash"
|
|
assert ps.name == "ps"
|