Spaces:
Paused
Paused
| """Tests for HermesCLI initialization -- catches configuration bugs | |
| that only manifest at runtime (not in mocked unit tests).""" | |
| import os | |
| import sys | |
| from unittest.mock import MagicMock, patch | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) | |
| def _make_cli(env_overrides=None, config_overrides=None, **kwargs): | |
| """Create a HermesCLI instance with minimal mocking.""" | |
| import importlib | |
| _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"}, | |
| } | |
| if config_overrides: | |
| _clean_config.update(config_overrides) | |
| clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} | |
| if env_overrides: | |
| clean_env.update(env_overrides) | |
| prompt_toolkit_stubs = { | |
| "prompt_toolkit": MagicMock(), | |
| "prompt_toolkit.history": MagicMock(), | |
| "prompt_toolkit.styles": MagicMock(), | |
| "prompt_toolkit.patch_stdout": MagicMock(), | |
| "prompt_toolkit.application": MagicMock(), | |
| "prompt_toolkit.layout": MagicMock(), | |
| "prompt_toolkit.layout.processors": MagicMock(), | |
| "prompt_toolkit.filters": MagicMock(), | |
| "prompt_toolkit.layout.dimension": MagicMock(), | |
| "prompt_toolkit.layout.menus": MagicMock(), | |
| "prompt_toolkit.widgets": MagicMock(), | |
| "prompt_toolkit.key_binding": MagicMock(), | |
| "prompt_toolkit.completion": MagicMock(), | |
| "prompt_toolkit.formatted_text": MagicMock(), | |
| "prompt_toolkit.auto_suggest": MagicMock(), | |
| } | |
| with patch.dict(sys.modules, prompt_toolkit_stubs), \ | |
| patch.dict("os.environ", clean_env, clear=False): | |
| import cli as _cli_mod | |
| _cli_mod = importlib.reload(_cli_mod) | |
| with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), \ | |
| patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}): | |
| return _cli_mod.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_legacy_root_max_turns_is_used_when_agent_key_exists_without_value(self): | |
| cli_obj = _make_cli(config_overrides={"agent": {}, "max_turns": 77}) | |
| assert cli_obj.max_turns == 77 | |
| 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 TestBusyInputMode: | |
| def test_default_busy_input_mode_is_interrupt(self): | |
| cli = _make_cli() | |
| assert cli.busy_input_mode == "interrupt" | |
| def test_busy_input_mode_queue_is_honored(self): | |
| cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}}) | |
| assert cli.busy_input_mode == "queue" | |
| def test_unknown_busy_input_mode_falls_back_to_interrupt(self): | |
| cli = _make_cli(config_overrides={"display": {"busy_input_mode": "bogus"}}) | |
| assert cli.busy_input_mode == "interrupt" | |
| def test_queue_command_works_while_busy(self): | |
| """When agent is running, /queue should still put the prompt in _pending_input.""" | |
| cli = _make_cli() | |
| cli._agent_running = True | |
| cli.process_command("/queue follow up") | |
| assert cli._pending_input.get_nowait() == "follow up" | |
| def test_queue_command_works_while_idle(self): | |
| """When agent is idle, /queue should still queue (not reject).""" | |
| cli = _make_cli() | |
| cli._agent_running = False | |
| cli.process_command("/queue follow up") | |
| assert cli._pending_input.get_nowait() == "follow up" | |
| def test_queue_mode_routes_busy_enter_to_pending(self): | |
| """In queue mode, Enter while busy should go to _pending_input, not _interrupt_queue.""" | |
| cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}}) | |
| cli._agent_running = True | |
| # Simulate what handle_enter does for non-command input while busy | |
| text = "follow up" | |
| if cli.busy_input_mode == "queue": | |
| cli._pending_input.put(text) | |
| else: | |
| cli._interrupt_queue.put(text) | |
| assert cli._pending_input.get_nowait() == "follow up" | |
| assert cli._interrupt_queue.empty() | |
| def test_interrupt_mode_routes_busy_enter_to_interrupt(self): | |
| """In interrupt mode (default), Enter while busy goes to _interrupt_queue.""" | |
| cli = _make_cli() | |
| cli._agent_running = True | |
| text = "redirect" | |
| if cli.busy_input_mode == "queue": | |
| cli._pending_input.put(text) | |
| else: | |
| cli._interrupt_queue.put(text) | |
| assert cli._interrupt_queue.get_nowait() == "redirect" | |
| assert cli._pending_input.empty() | |
| class TestSingleQueryState: | |
| def test_voice_and_interrupt_state_initialized_before_run(self): | |
| """Single-query mode calls chat() without going through run().""" | |
| cli = _make_cli() | |
| assert cli._voice_tts is False | |
| assert cli._voice_mode is False | |
| assert cli._voice_tts_done.is_set() | |
| assert hasattr(cli, "_interrupt_queue") | |
| assert hasattr(cli, "_pending_input") | |
| 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 | |
| def test_history_shows_recent_sessions_when_current_chat_is_empty(self, capsys): | |
| cli = _make_cli() | |
| cli.session_id = "current" | |
| cli._session_db = MagicMock() | |
| cli._session_db.list_sessions_rich.return_value = [ | |
| { | |
| "id": "current", | |
| "title": "Current", | |
| "preview": "Current preview", | |
| "last_active": 0, | |
| }, | |
| { | |
| "id": "20260401_201329_d85961", | |
| "title": "Checking Running Hermes Agent", | |
| "preview": "check running gateways for hermes agent", | |
| "last_active": 0, | |
| }, | |
| ] | |
| cli.show_history() | |
| output = capsys.readouterr().out | |
| assert "No messages in the current chat yet" in output | |
| assert "Checking Running Hermes Agent" in output | |
| assert "20260401_201329_d85961" in output | |
| assert "/resume" in output | |
| assert "Current preview" not in output | |
| def test_resume_without_target_lists_recent_sessions(self, capsys): | |
| cli = _make_cli() | |
| cli.session_id = "current" | |
| cli._session_db = MagicMock() | |
| cli._session_db.list_sessions_rich.return_value = [ | |
| { | |
| "id": "current", | |
| "title": "Current", | |
| "preview": "Current preview", | |
| "last_active": 0, | |
| }, | |
| { | |
| "id": "20260401_201329_d85961", | |
| "title": "Checking Running Hermes Agent", | |
| "preview": "check running gateways for hermes agent", | |
| "last_active": 0, | |
| }, | |
| ] | |
| cli._handle_resume_command("/resume") | |
| output = capsys.readouterr().out | |
| assert "Recent sessions" in output | |
| assert "Checking Running Hermes Agent" in output | |
| assert "Use /resume <session id or title> to continue" in output | |
| class TestRootLevelProviderOverride: | |
| """Root-level provider/base_url in config.yaml must NOT override model.provider.""" | |
| def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch): | |
| """model.provider takes priority — root-level provider is only a fallback.""" | |
| import yaml | |
| hermes_home = tmp_path / ".hermes" | |
| hermes_home.mkdir() | |
| monkeypatch.setenv("HERMES_HOME", str(hermes_home)) | |
| config_path = hermes_home / "config.yaml" | |
| config_path.write_text(yaml.safe_dump({ | |
| "provider": "opencode-go", # stale root-level key | |
| "model": { | |
| "default": "google/gemini-3-flash-preview", | |
| "provider": "openrouter", # correct canonical key | |
| }, | |
| })) | |
| import cli | |
| monkeypatch.setattr(cli, "_hermes_home", hermes_home) | |
| cfg = cli.load_cli_config() | |
| assert cfg["model"]["provider"] == "openrouter" | |
| def test_root_provider_ignored_when_default_model_provider_exists(self, tmp_path, monkeypatch): | |
| """Even when model.provider is the default 'auto', root-level provider is ignored.""" | |
| import yaml | |
| hermes_home = tmp_path / ".hermes" | |
| hermes_home.mkdir() | |
| monkeypatch.setenv("HERMES_HOME", str(hermes_home)) | |
| config_path = hermes_home / "config.yaml" | |
| config_path.write_text(yaml.safe_dump({ | |
| "provider": "opencode-go", # stale root key | |
| "model": { | |
| "default": "google/gemini-3-flash-preview", | |
| # no explicit model.provider — defaults provide "auto" | |
| }, | |
| })) | |
| import cli | |
| monkeypatch.setattr(cli, "_hermes_home", hermes_home) | |
| cfg = cli.load_cli_config() | |
| # Root-level "opencode-go" must NOT leak through | |
| assert cfg["model"]["provider"] != "opencode-go" | |
| def test_normalize_root_model_keys_moves_to_model(self): | |
| """_normalize_root_model_keys migrates root keys into model section.""" | |
| from hermes_cli.config import _normalize_root_model_keys | |
| config = { | |
| "provider": "opencode-go", | |
| "base_url": "https://example.com/v1", | |
| "model": { | |
| "default": "some-model", | |
| }, | |
| } | |
| result = _normalize_root_model_keys(config) | |
| # Root keys removed | |
| assert "provider" not in result | |
| assert "base_url" not in result | |
| # Migrated into model section | |
| assert result["model"]["provider"] == "opencode-go" | |
| assert result["model"]["base_url"] == "https://example.com/v1" | |
| def test_normalize_root_model_keys_does_not_override_existing(self): | |
| """Existing model.provider is never overridden by root-level key.""" | |
| from hermes_cli.config import _normalize_root_model_keys | |
| config = { | |
| "provider": "stale-provider", | |
| "model": { | |
| "default": "some-model", | |
| "provider": "correct-provider", | |
| }, | |
| } | |
| result = _normalize_root_model_keys(config) | |
| assert result["model"]["provider"] == "correct-provider" | |
| assert "provider" not in result # root key still cleaned up | |
| 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 | |