323 lines
11 KiB
Python
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
|