Files
hermes-agent/tests/test_cli_init.py
2026-03-08 05:08:57 -07:00

323 lines
11 KiB
Python

"""Tests for HermesCLI initialization -- catches configuration bugs
that only manifest at runtime (not in mocked unit tests)."""
import os
import sys
import types
from contextlib import nullcontext
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
def _install_prompt_toolkit_stubs():
"""Provide minimal prompt_toolkit shims for non-TUI unit tests."""
if "prompt_toolkit" in sys.modules:
return
class _StubBase:
def __init__(self, *args, **kwargs):
pass
def __call__(self, *args, **kwargs):
return None
def __getattr__(self, _name):
return lambda *args, **kwargs: None
class _StubStyle:
@classmethod
def from_dict(cls, *_args, **_kwargs):
return cls()
prompt_toolkit = types.ModuleType("prompt_toolkit")
prompt_toolkit.print_formatted_text = lambda *args, **kwargs: None
history = types.ModuleType("prompt_toolkit.history")
history.FileHistory = _StubBase
styles = types.ModuleType("prompt_toolkit.styles")
styles.Style = _StubStyle
patch_stdout = types.ModuleType("prompt_toolkit.patch_stdout")
patch_stdout.patch_stdout = nullcontext
application = types.ModuleType("prompt_toolkit.application")
application.Application = _StubBase
layout = types.ModuleType("prompt_toolkit.layout")
layout.Layout = _StubBase
layout.HSplit = _StubBase
layout.Window = _StubBase
layout.FormattedTextControl = _StubBase
layout.ConditionalContainer = _StubBase
processors = types.ModuleType("prompt_toolkit.layout.processors")
processors.Processor = _StubBase
processors.Transformation = _StubBase
processors.PasswordProcessor = _StubBase
processors.ConditionalProcessor = _StubBase
filters = types.ModuleType("prompt_toolkit.filters")
filters.Condition = lambda fn: fn
dimension = types.ModuleType("prompt_toolkit.layout.dimension")
dimension.Dimension = _StubBase
menus = types.ModuleType("prompt_toolkit.layout.menus")
menus.CompletionsMenu = _StubBase
widgets = types.ModuleType("prompt_toolkit.widgets")
widgets.TextArea = _StubBase
key_binding = types.ModuleType("prompt_toolkit.key_binding")
key_binding.KeyBindings = _StubBase
completion = types.ModuleType("prompt_toolkit.completion")
completion.Completer = object
completion.Completion = _StubBase
formatted_text = types.ModuleType("prompt_toolkit.formatted_text")
formatted_text.ANSI = str
sys.modules.update(
{
"prompt_toolkit": prompt_toolkit,
"prompt_toolkit.history": history,
"prompt_toolkit.styles": styles,
"prompt_toolkit.patch_stdout": patch_stdout,
"prompt_toolkit.application": application,
"prompt_toolkit.layout": layout,
"prompt_toolkit.layout.processors": processors,
"prompt_toolkit.filters": filters,
"prompt_toolkit.layout.dimension": dimension,
"prompt_toolkit.layout.menus": menus,
"prompt_toolkit.widgets": widgets,
"prompt_toolkit.key_binding": key_binding,
"prompt_toolkit.completion": completion,
"prompt_toolkit.formatted_text": formatted_text,
}
)
def _install_rich_stubs():
"""Provide minimal rich shims for CLI unit tests."""
if "rich" in sys.modules:
return
rich = types.ModuleType("rich")
console = types.ModuleType("rich.console")
panel = types.ModuleType("rich.panel")
table = types.ModuleType("rich.table")
class _RichStub:
def __init__(self, *args, **kwargs):
pass
def __call__(self, *args, **kwargs):
return None
def __getattr__(self, _name):
return lambda *args, **kwargs: None
console.Console = _RichStub
panel.Panel = _RichStub
table.Table = _RichStub
sys.modules.update(
{
"rich": rich,
"rich.console": console,
"rich.panel": panel,
"rich.table": table,
}
)
def _install_cli_dependency_stubs():
"""Stub heavy runtime-only dependencies so CLI unit tests stay lightweight."""
if "fire" not in sys.modules:
sys.modules["fire"] = types.ModuleType("fire")
if "run_agent" not in sys.modules:
run_agent = types.ModuleType("run_agent")
run_agent.AIAgent = object
sys.modules["run_agent"] = run_agent
if "model_tools" not in sys.modules:
model_tools = types.ModuleType("model_tools")
model_tools.get_tool_definitions = lambda *args, **kwargs: []
model_tools.get_toolset_for_tool = lambda *args, **kwargs: None
sys.modules["model_tools"] = model_tools
if "hermes_cli.banner" not in sys.modules:
banner = types.ModuleType("hermes_cli.banner")
banner.cprint = lambda *args, **kwargs: None
banner._GOLD = banner._BOLD = banner._DIM = banner._RST = ""
banner.VERSION = "test"
banner.HERMES_AGENT_LOGO = ""
banner.HERMES_CADUCEUS = ""
banner.COMPACT_BANNER = ""
banner.get_available_skills = lambda *args, **kwargs: []
banner.build_welcome_banner = lambda *args, **kwargs: ""
sys.modules.setdefault("hermes_cli", types.ModuleType("hermes_cli"))
sys.modules["hermes_cli.banner"] = banner
if "hermes_cli.commands" not in sys.modules:
commands = types.ModuleType("hermes_cli.commands")
commands.COMMANDS = {}
commands.SlashCommandCompleter = object
sys.modules["hermes_cli.commands"] = commands
if "hermes_cli.callbacks" not in sys.modules:
callbacks = types.ModuleType("hermes_cli.callbacks")
callbacks.register_approval_callback = lambda *args, **kwargs: None
callbacks.register_sudo_password_callback = lambda *args, **kwargs: None
sys.modules["hermes_cli.callbacks"] = callbacks
sys.modules.setdefault("hermes_cli", types.ModuleType("hermes_cli")).callbacks = callbacks
if "toolsets" not in sys.modules:
toolsets = types.ModuleType("toolsets")
toolsets.get_all_toolsets = lambda *args, **kwargs: []
toolsets.get_toolset_info = lambda *args, **kwargs: {}
toolsets.resolve_toolset = lambda *args, **kwargs: []
toolsets.validate_toolset = lambda *_args, **_kwargs: True
sys.modules["toolsets"] = toolsets
if "cron" not in sys.modules:
cron = types.ModuleType("cron")
cron.create_job = lambda *args, **kwargs: None
cron.list_jobs = lambda *args, **kwargs: []
cron.remove_job = lambda *args, **kwargs: None
cron.get_job = lambda *args, **kwargs: None
sys.modules["cron"] = cron
sys.modules.setdefault("tools", types.ModuleType("tools"))
if "tools.terminal_tool" not in sys.modules:
terminal_tool = types.ModuleType("tools.terminal_tool")
terminal_tool.cleanup_all_environments = lambda *args, **kwargs: None
terminal_tool.set_sudo_password_callback = lambda *args, **kwargs: None
terminal_tool.set_approval_callback = lambda *args, **kwargs: None
sys.modules["tools.terminal_tool"] = terminal_tool
if "tools.browser_tool" not in sys.modules:
browser_tool = types.ModuleType("tools.browser_tool")
browser_tool._emergency_cleanup_all_sessions = lambda *args, **kwargs: None
sys.modules["tools.browser_tool"] = browser_tool
def _make_cli(env_overrides=None, **kwargs):
"""Create a HermesCLI instance with minimal mocking."""
_install_prompt_toolkit_stubs()
_install_rich_stubs()
_install_cli_dependency_stubs()
import cli as _cli_mod
from cli import HermesCLI
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
if env_overrides:
clean_env.update(env_overrides)
with patch("cli.get_tool_definitions", return_value=[]), \
patch.dict("os.environ", clean_env, clear=False), \
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}):
return HermesCLI(**kwargs)
class TestMaxTurnsResolution:
"""max_turns must always resolve to a positive integer, never None."""
def test_default_max_turns_is_integer(self):
cli = _make_cli()
assert isinstance(cli.max_turns, int)
assert cli.max_turns == 90
def test_explicit_max_turns_honored(self):
cli = _make_cli(max_turns=25)
assert cli.max_turns == 25
def test_none_max_turns_gets_default(self):
cli = _make_cli(max_turns=None)
assert isinstance(cli.max_turns, int)
assert cli.max_turns == 90
def test_env_var_max_turns(self):
"""Env var is used when config file doesn't set max_turns."""
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "42"})
assert cli_obj.max_turns == 42
def test_max_turns_never_none_for_agent(self):
"""The value passed to AIAgent must never be None (causes TypeError in run_conversation)."""
cli = _make_cli()
assert isinstance(cli.max_turns, int) and cli.max_turns == 90
class TestVerboseAndToolProgress:
def test_default_verbose_is_bool(self):
cli = _make_cli()
assert isinstance(cli.verbose, bool)
def test_tool_progress_mode_is_string(self):
cli = _make_cli()
assert isinstance(cli.tool_progress_mode, str)
assert cli.tool_progress_mode in ("off", "new", "all", "verbose")
class TestHistoryDisplay:
def test_history_numbers_only_visible_messages_and_summarizes_tools(self, capsys):
cli = _make_cli()
cli.conversation_history = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "Hello"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1"}, {"id": "call_2"}],
},
{"role": "tool", "content": "tool output 1"},
{"role": "tool", "content": "tool output 2"},
{"role": "assistant", "content": "All set."},
{"role": "user", "content": "A" * 250},
]
cli.show_history()
output = capsys.readouterr().out
assert "[You #1]" in output
assert "[Hermes #2]" in output
assert "(requested 2 tool calls)" in output
assert "[Tools]" in output
assert "(2 tool messages hidden)" in output
assert "[Hermes #3]" in output
assert "[You #4]" in output
assert "[You #5]" not in output
assert "A" * 250 in output
assert "A" * 250 + "..." not in output
class TestProviderResolution:
def test_api_key_is_string_or_none(self):
cli = _make_cli()
assert cli.api_key is None or isinstance(cli.api_key, str)
def test_base_url_is_string(self):
cli = _make_cli()
assert isinstance(cli.base_url, str)
assert cli.base_url.startswith("http")
def test_model_is_string(self):
cli = _make_cli()
assert isinstance(cli.model, str)
assert isinstance(cli.model, str) and '/' in cli.model