mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Make the main-branch test suite pass again. Most failures were tests
still asserting old shapes after recent refactors; two were real source
bugs.
Source fixes:
- tools/mcp_tool.py: _kill_orphaned_mcp_children() slept 2s on every
shutdown even when no tracked PIDs existed, making test_shutdown_is_parallel
measure ~3s for 3 parallel 1s shutdowns. Early-return when pids is empty.
- hermes_cli/tips.py: tip 105 was 157 chars; corpus max is 150.
Test fixes (mostly stale mock targets / missing fixture fields):
- test_zombie_process_cleanup, test_agent_cache: patch run_agent.cleanup_vm
(the local name bound at import), not tools.terminal_tool.cleanup_vm.
- test_browser_camofox: patch tools.browser_camofox.load_config, not
hermes_cli.config.load_config (the source module, not the resolved one).
- test_flush_memories_codex._chat_response_with_memory_call: add
finish_reason, tool_call.id, tool_call.type so the chat_completions
transport normalizer doesn't AttributeError.
- test_concurrent_interrupt: polling_tool signature now accepts
messages= kwarg that _invoke_tool() passes through.
- test_minimax_provider: add _fallback_chain=[] to the __new__'d agent
so switch_model() doesn't AttributeError.
- test_skills_config: SKILLS_DIR MagicMock + .rglob stopped working
after the scanner switched to agent.skill_utils.iter_skill_index_files
(os.walk-based). Point SKILLS_DIR at a real tmp_path and patch
agent.skill_utils.get_external_skills_dirs.
- test_browser_cdp_tool: browser_cdp toolset was intentionally split into
'browser-cdp' (commit 96b0f3700) so its stricter check_fn doesn't gate
the whole browser toolset; test now expects 'browser-cdp'.
- test_registry: add tools.browser_dialog_tool to the expected
builtin-discovery set (PR #14540 added it).
- test_file_tools TestPatchHints: patch_tool surfaces hints as a '_hint'
key on the JSON payload, not inline '[Hint: ...' text.
- test_write_deny test_hermes_env: resolve .env via get_hermes_home() so
the path matches the profile-aware denylist under hermetic HERMES_HOME.
- test_checkpoint_manager test_falls_back_to_parent: guard the walk-up
so a stray /tmp/pyproject.toml on the host doesn't pick up /tmp as the
project root.
- test_quick_commands: set cli.session_id in the __new__'d CLI so the
alias-args path doesn't trip AttributeError when fuzzy-matching leaks
a skill command across xdist test distribution.
207 lines
8.9 KiB
Python
207 lines
8.9 KiB
Python
"""Tests for user-defined quick commands that bypass the agent loop."""
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch, AsyncMock
|
|
from rich.text import Text
|
|
import pytest
|
|
|
|
|
|
# ── CLI tests ──────────────────────────────────────────────────────────────
|
|
|
|
class TestCLIQuickCommands:
|
|
"""Test quick command dispatch in HermesCLI.process_command."""
|
|
|
|
@staticmethod
|
|
def _printed_plain(call_arg):
|
|
if isinstance(call_arg, Text):
|
|
return call_arg.plain
|
|
return str(call_arg)
|
|
|
|
def _make_cli(self, quick_commands):
|
|
from cli import HermesCLI
|
|
cli = HermesCLI.__new__(HermesCLI)
|
|
cli.config = {"quick_commands": quick_commands}
|
|
cli.console = MagicMock()
|
|
cli.agent = None
|
|
cli.conversation_history = []
|
|
# session_id is accessed by the fallback skill/fuzzy-match path in
|
|
# process_command; without it, tests that exercise `/alias args`
|
|
# can trip an AttributeError when cross-test state leaks a skill
|
|
# command matching the alias target.
|
|
cli.session_id = "test-session"
|
|
return cli
|
|
|
|
def test_exec_command_runs_and_prints_output(self):
|
|
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
|
|
result = cli.process_command("/dn")
|
|
assert result is True
|
|
cli.console.print.assert_called_once()
|
|
printed = self._printed_plain(cli.console.print.call_args[0][0])
|
|
assert printed == "daily-note"
|
|
|
|
def test_exec_command_uses_chat_console_when_tui_is_live(self):
|
|
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
|
|
cli._app = object()
|
|
live_console = MagicMock()
|
|
|
|
with patch("cli.ChatConsole", return_value=live_console):
|
|
result = cli.process_command("/dn")
|
|
|
|
assert result is True
|
|
live_console.print.assert_called_once()
|
|
printed = self._printed_plain(live_console.print.call_args[0][0])
|
|
assert printed == "daily-note"
|
|
cli.console.print.assert_not_called()
|
|
|
|
def test_exec_command_stderr_shown_on_no_stdout(self):
|
|
cli = self._make_cli({"err": {"type": "exec", "command": "echo error >&2"}})
|
|
result = cli.process_command("/err")
|
|
assert result is True
|
|
# stderr fallback — should print something
|
|
cli.console.print.assert_called_once()
|
|
|
|
def test_exec_command_no_output_shows_fallback(self):
|
|
cli = self._make_cli({"empty": {"type": "exec", "command": "true"}})
|
|
cli.process_command("/empty")
|
|
cli.console.print.assert_called_once()
|
|
args = cli.console.print.call_args[0][0]
|
|
assert "no output" in args.lower()
|
|
|
|
def test_alias_command_routes_to_target(self):
|
|
"""Alias quick commands rewrite to the target command."""
|
|
cli = self._make_cli({"shortcut": {"type": "alias", "target": "/help"}})
|
|
with patch.object(cli, "process_command", wraps=cli.process_command) as spy:
|
|
cli.process_command("/shortcut")
|
|
# Should recursively call process_command with /help
|
|
spy.assert_any_call("/help")
|
|
|
|
def test_alias_command_passes_args(self):
|
|
"""Alias quick commands forward user arguments to the target."""
|
|
cli = self._make_cli({"sc": {"type": "alias", "target": "/context"}})
|
|
with patch.object(cli, "process_command", wraps=cli.process_command) as spy:
|
|
cli.process_command("/sc some args")
|
|
spy.assert_any_call("/context some args")
|
|
|
|
def test_alias_no_target_shows_error(self):
|
|
cli = self._make_cli({"broken": {"type": "alias", "target": ""}})
|
|
cli.process_command("/broken")
|
|
cli.console.print.assert_called_once()
|
|
args = cli.console.print.call_args[0][0]
|
|
assert "no target defined" in args.lower()
|
|
|
|
def test_unsupported_type_shows_error(self):
|
|
cli = self._make_cli({"bad": {"type": "prompt", "command": "echo hi"}})
|
|
cli.process_command("/bad")
|
|
cli.console.print.assert_called_once()
|
|
args = cli.console.print.call_args[0][0]
|
|
assert "unsupported type" in args.lower()
|
|
|
|
def test_missing_command_field_shows_error(self):
|
|
cli = self._make_cli({"oops": {"type": "exec"}})
|
|
cli.process_command("/oops")
|
|
cli.console.print.assert_called_once()
|
|
args = cli.console.print.call_args[0][0]
|
|
assert "no command defined" in args.lower()
|
|
|
|
def test_quick_command_takes_priority_over_skill_commands(self):
|
|
"""Quick commands must be checked before skill slash commands."""
|
|
cli = self._make_cli({"mygif": {"type": "exec", "command": "echo overridden"}})
|
|
with patch("cli._skill_commands", {"/mygif": {"name": "gif-search"}}):
|
|
cli.process_command("/mygif")
|
|
cli.console.print.assert_called_once()
|
|
printed = self._printed_plain(cli.console.print.call_args[0][0])
|
|
assert printed == "overridden"
|
|
|
|
def test_unknown_command_still_shows_error(self):
|
|
cli = self._make_cli({})
|
|
with patch("cli._cprint") as mock_cprint:
|
|
cli.process_command("/nonexistent")
|
|
mock_cprint.assert_called()
|
|
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
|
assert "unknown command" in printed.lower()
|
|
|
|
def test_timeout_shows_error(self):
|
|
cli = self._make_cli({"slow": {"type": "exec", "command": "sleep 100"}})
|
|
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("sleep", 30)):
|
|
cli.process_command("/slow")
|
|
cli.console.print.assert_called_once()
|
|
args = cli.console.print.call_args[0][0]
|
|
assert "timed out" in args.lower()
|
|
|
|
|
|
# ── Gateway tests ──────────────────────────────────────────────────────────
|
|
|
|
class TestGatewayQuickCommands:
|
|
"""Test quick command dispatch in GatewayRunner._handle_message."""
|
|
|
|
def _make_event(self, command, args=""):
|
|
event = MagicMock()
|
|
event.get_command.return_value = command
|
|
event.get_command_args.return_value = args
|
|
event.text = f"/{command} {args}".strip()
|
|
event.source = MagicMock()
|
|
event.source.user_id = "test_user"
|
|
event.source.user_name = "Test User"
|
|
event.source.platform.value = "telegram"
|
|
event.source.chat_type = "dm"
|
|
event.source.chat_id = "123"
|
|
return event
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_command_returns_output(self):
|
|
from gateway.run import GatewayRunner
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner.config = {"quick_commands": {"limits": {"type": "exec", "command": "echo ok"}}}
|
|
runner._running_agents = {}
|
|
runner._pending_messages = {}
|
|
runner._is_user_authorized = MagicMock(return_value=True)
|
|
|
|
event = self._make_event("limits")
|
|
result = await runner._handle_message(event)
|
|
assert result == "ok"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unsupported_type_returns_error(self):
|
|
from gateway.run import GatewayRunner
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner.config = {"quick_commands": {"bad": {"type": "prompt", "command": "echo hi"}}}
|
|
runner._running_agents = {}
|
|
runner._pending_messages = {}
|
|
runner._is_user_authorized = MagicMock(return_value=True)
|
|
|
|
event = self._make_event("bad")
|
|
result = await runner._handle_message(event)
|
|
assert result is not None
|
|
assert "unsupported type" in result.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_returns_error(self):
|
|
from gateway.run import GatewayRunner
|
|
import asyncio
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner.config = {"quick_commands": {"slow": {"type": "exec", "command": "sleep 100"}}}
|
|
runner._running_agents = {}
|
|
runner._pending_messages = {}
|
|
runner._is_user_authorized = MagicMock(return_value=True)
|
|
|
|
event = self._make_event("slow")
|
|
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
|
|
result = await runner._handle_message(event)
|
|
assert result is not None
|
|
assert "timed out" in result.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_config_object_supports_quick_commands(self):
|
|
from gateway.config import GatewayConfig
|
|
from gateway.run import GatewayRunner
|
|
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner.config = GatewayConfig(
|
|
quick_commands={"limits": {"type": "exec", "command": "echo ok"}}
|
|
)
|
|
runner._running_agents = {}
|
|
runner._pending_messages = {}
|
|
runner._is_user_authorized = MagicMock(return_value=True)
|
|
|
|
event = self._make_event("limits")
|
|
result = await runner._handle_message(event)
|
|
assert result == "ok"
|