Files
zeekay 7374804405 hanzo-mcp 0.15.21: the active mode is the whole manifest, and a flood of output has a ceiling
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.
2026-10-12 08:47:57 -07:00

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"