Something went wrong. Try again.
The agent that grows with you
Something went wrong. Try again.
20 kB · 486 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487"""Tests for `hermes fallback` — chain reading, add/remove/clear, legacy migration."""from __future__ import annotations
import ioimport typesfrom pathlib import Pathfrom unittest.mock import patch
import pytestimport yaml
# ---------------------------------------------------------------------------# Shared fixture — isolate HERMES_HOME so save_config writes to tmp_path# ---------------------------------------------------------------------------
@pytest.fixture()def isolated_home(tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) home = tmp_path / ".hermes" home.mkdir(exist_ok=True) monkeypatch.setenv("HERMES_HOME", str(home)) return tmp_path
def _write_config(home: Path, data: dict) -> None: config_path = home / ".hermes" / "config.yaml" config_path.write_text(yaml.safe_dump(data), encoding="utf-8")
def _read_config(home: Path) -> dict: config_path = home / ".hermes" / "config.yaml" return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
# ---------------------------------------------------------------------------# _read_chain / _write_chain# ---------------------------------------------------------------------------
class TestReadChain: def test_returns_empty_list_when_unset(self): from hermes_cli.fallback_cmd import _read_chain assert _read_chain({}) == []
def test_reads_new_list_format(self): from hermes_cli.fallback_cmd import _read_chain cfg = { "fallback_providers": [ {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, ] } assert _read_chain(cfg) == [ {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, ]
def test_migrates_legacy_single_dict(self): from hermes_cli.fallback_cmd import _read_chain cfg = {"fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}} assert _read_chain(cfg) == [{"provider": "openrouter", "model": "gpt-5.4"}]
def test_skips_incomplete_entries(self): from hermes_cli.fallback_cmd import _read_chain cfg = { "fallback_providers": [ {"provider": "openrouter"}, # missing model {"model": "gpt-5.4"}, # missing provider {"provider": "nous", "model": "foo"}, # valid "not-a-dict", # noise ] } assert _read_chain(cfg) == [{"provider": "nous", "model": "foo"}]
def test_returns_copies_not_aliases(self): from hermes_cli.fallback_cmd import _read_chain cfg = {"fallback_providers": [{"provider": "nous", "model": "foo"}]} result = _read_chain(cfg) result[0]["provider"] = "mutated" assert cfg["fallback_providers"][0]["provider"] == "nous"
# ---------------------------------------------------------------------------# _extract_fallback_from_model_cfg# ---------------------------------------------------------------------------
class TestExtractFallback: def test_extracts_from_default_field(self): from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg model_cfg = {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"} assert _extract_fallback_from_model_cfg(model_cfg) == { "provider": "openrouter", "model": "anthropic/claude-sonnet-4.6", }
def test_extracts_optional_base_url_and_api_mode(self): from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg model_cfg = { "provider": "custom", "default": "local-model", "base_url": "http://localhost:11434/v1", "api_mode": "chat_completions", } assert _extract_fallback_from_model_cfg(model_cfg) == { "provider": "custom", "model": "local-model", "base_url": "http://localhost:11434/v1", "api_mode": "chat_completions", }
def test_returns_none_without_provider(self): from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg assert _extract_fallback_from_model_cfg({"default": "foo"}) is None
def test_returns_none_without_model(self): from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg assert _extract_fallback_from_model_cfg({"provider": "openrouter"}) is None
def test_returns_none_for_non_dict(self): from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg assert _extract_fallback_from_model_cfg("plain-string") is None assert _extract_fallback_from_model_cfg(None) is None
# ---------------------------------------------------------------------------# cmd_fallback_list# ---------------------------------------------------------------------------
class TestListCommand: def test_list_empty(self, isolated_home, capsys): _write_config(isolated_home, {}) from hermes_cli.fallback_cmd import cmd_fallback_list cmd_fallback_list(types.SimpleNamespace()) out = capsys.readouterr().out assert "No fallback providers configured" in out assert "hermes fallback add" in out
def test_list_with_entries(self, isolated_home, capsys): _write_config(isolated_home, { "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, "fallback_providers": [ {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, {"provider": "nous", "model": "Hermes-4"}, ], }) from hermes_cli.fallback_cmd import cmd_fallback_list cmd_fallback_list(types.SimpleNamespace()) out = capsys.readouterr().out assert "Fallback chain (2 entries)" in out assert "anthropic/claude-sonnet-4.6" in out assert "Hermes-4" in out # Primary should be shown too assert "claude-sonnet-4-6" in out
def test_list_migrates_legacy_for_display(self, isolated_home, capsys): _write_config(isolated_home, { "fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}, }) from hermes_cli.fallback_cmd import cmd_fallback_list cmd_fallback_list(types.SimpleNamespace()) out = capsys.readouterr().out assert "1 entry" in out assert "gpt-5.4" in out
# ---------------------------------------------------------------------------# cmd_fallback_add — mock select_provider_and_model# ---------------------------------------------------------------------------
class TestAddCommand: def test_add_appends_new_entry(self, isolated_home, capsys): _write_config(isolated_home, { "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, })
def fake_picker(args=None): # Simulate what the real picker does: writes the selection to config["model"] from hermes_cli.config import load_config, save_config cfg = load_config() cfg["model"] = { "provider": "openrouter", "default": "anthropic/claude-sonnet-4.6", "base_url": "https://openrouter.ai/api/v1", "api_mode": "chat_completions", } save_config(cfg)
with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ patch("hermes_cli.main._require_tty"): from hermes_cli.fallback_cmd import cmd_fallback_add cmd_fallback_add(types.SimpleNamespace())
cfg = _read_config(isolated_home) # Primary is preserved assert cfg["model"]["provider"] == "anthropic" assert cfg["model"]["default"] == "claude-sonnet-4-6" # Fallback was appended assert cfg["fallback_providers"] == [ { "provider": "openrouter", "model": "anthropic/claude-sonnet-4.6", "base_url": "https://openrouter.ai/api/v1", "api_mode": "chat_completions", } ] out = capsys.readouterr().out assert "Added fallback" in out
def test_add_rejects_duplicate(self, isolated_home, capsys): _write_config(isolated_home, { "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, "fallback_providers": [ {"provider": "openrouter", "model": "gpt-5.4"}, ], })
def fake_picker(args=None): from hermes_cli.config import load_config, save_config cfg = load_config() cfg["model"] = {"provider": "openrouter", "default": "gpt-5.4"} save_config(cfg)
with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ patch("hermes_cli.main._require_tty"): from hermes_cli.fallback_cmd import cmd_fallback_add cmd_fallback_add(types.SimpleNamespace())
cfg = _read_config(isolated_home) # Should still have exactly one entry assert len(cfg["fallback_providers"]) == 1 out = capsys.readouterr().out assert "already in the fallback chain" in out
def test_add_rejects_same_as_primary(self, isolated_home, capsys): _write_config(isolated_home, { "model": {"provider": "openrouter", "default": "gpt-5.4"}, })
def fake_picker(args=None): # User picks the same thing that's already the primary from hermes_cli.config import load_config, save_config cfg = load_config() cfg["model"] = {"provider": "openrouter", "default": "gpt-5.4"} save_config(cfg)
with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ patch("hermes_cli.main._require_tty"): from hermes_cli.fallback_cmd import cmd_fallback_add cmd_fallback_add(types.SimpleNamespace())
cfg = _read_config(isolated_home) assert "fallback_providers" not in cfg or cfg["fallback_providers"] == [] out = capsys.readouterr().out assert "matches the current primary" in out
def test_add_preserves_primary_when_picker_changes_it(self, isolated_home): """The picker mutates config["model"]; fallback_add must restore the primary.""" _write_config(isolated_home, { "model": { "provider": "anthropic", "default": "claude-sonnet-4-6", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages", }, })
def fake_picker(args=None): from hermes_cli.config import load_config, save_config cfg = load_config() cfg["model"] = { "provider": "openrouter", "default": "anthropic/claude-sonnet-4.6", "base_url": "https://openrouter.ai/api/v1", "api_mode": "chat_completions", } save_config(cfg)
with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ patch("hermes_cli.main._require_tty"): from hermes_cli.fallback_cmd import cmd_fallback_add cmd_fallback_add(types.SimpleNamespace())
cfg = _read_config(isolated_home) # Primary exactly as it was assert cfg["model"]["provider"] == "anthropic" assert cfg["model"]["default"] == "claude-sonnet-4-6" assert cfg["model"]["base_url"] == "https://api.anthropic.com" assert cfg["model"]["api_mode"] == "anthropic_messages" # Fallback added assert len(cfg["fallback_providers"]) == 1 assert cfg["fallback_providers"][0]["provider"] == "openrouter"
def test_add_noop_when_picker_cancelled(self, isolated_home, capsys): _write_config(isolated_home, { "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, })
def fake_picker(args=None): # User cancelled — no change to config pass
with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ patch("hermes_cli.main._require_tty"): from hermes_cli.fallback_cmd import cmd_fallback_add cmd_fallback_add(types.SimpleNamespace())
cfg = _read_config(isolated_home) assert "fallback_providers" not in cfg or cfg["fallback_providers"] == [] out = capsys.readouterr().out # Either "No fallback added" (picker fully cancelled) or "matches the current primary" # (picker left config untouched) — both indicate a non-add outcome. assert ("No fallback added" in out) or ("matches the current primary" in out)
def test_add_noop_when_picker_clears_model(self, isolated_home, capsys): """Simulate picker explicitly clearing model.default (unusual but possible).""" _write_config(isolated_home, { "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, })
def fake_picker(args=None): from hermes_cli.config import load_config, save_config cfg = load_config() cfg["model"] = {"provider": "", "default": ""} save_config(cfg)
with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ patch("hermes_cli.main._require_tty"): from hermes_cli.fallback_cmd import cmd_fallback_add cmd_fallback_add(types.SimpleNamespace())
out = capsys.readouterr().out assert "No fallback added" in out
# ---------------------------------------------------------------------------# cmd_fallback_remove# ---------------------------------------------------------------------------
class TestRemoveCommand: def test_remove_empty_chain(self, isolated_home, capsys): _write_config(isolated_home, {}) from hermes_cli.fallback_cmd import cmd_fallback_remove cmd_fallback_remove(types.SimpleNamespace()) out = capsys.readouterr().out assert "nothing to remove" in out
def test_remove_selected_entry(self, isolated_home, capsys): _write_config(isolated_home, { "fallback_providers": [ {"provider": "openrouter", "model": "gpt-5.4"}, {"provider": "nous", "model": "Hermes-4"}, {"provider": "anthropic", "model": "claude-sonnet-4-6"}, ], })
# Picker returns index 1 (the middle entry, "nous / Hermes-4") with patch("hermes_cli.setup._curses_prompt_choice", return_value=1): from hermes_cli.fallback_cmd import cmd_fallback_remove cmd_fallback_remove(types.SimpleNamespace())
cfg = _read_config(isolated_home) assert cfg["fallback_providers"] == [ {"provider": "openrouter", "model": "gpt-5.4"}, {"provider": "anthropic", "model": "claude-sonnet-4-6"}, ] out = capsys.readouterr().out assert "Removed fallback" in out assert "Hermes-4" in out
def test_remove_cancel_keeps_chain(self, isolated_home): _write_config(isolated_home, { "fallback_providers": [ {"provider": "openrouter", "model": "gpt-5.4"}, ], })
# Cancel = last item (index == len(chain) == 1 in our menu) with patch("hermes_cli.setup._curses_prompt_choice", return_value=1): from hermes_cli.fallback_cmd import cmd_fallback_remove cmd_fallback_remove(types.SimpleNamespace())
cfg = _read_config(isolated_home) assert len(cfg["fallback_providers"]) == 1
# ---------------------------------------------------------------------------# cmd_fallback_clear# ---------------------------------------------------------------------------
class TestClearCommand: def test_clear_empty_chain(self, isolated_home, capsys): _write_config(isolated_home, {}) from hermes_cli.fallback_cmd import cmd_fallback_clear cmd_fallback_clear(types.SimpleNamespace()) out = capsys.readouterr().out assert "nothing to clear" in out
def test_clear_with_confirmation(self, isolated_home, capsys, monkeypatch): _write_config(isolated_home, { "fallback_providers": [ {"provider": "openrouter", "model": "gpt-5.4"}, {"provider": "nous", "model": "Hermes-4"}, ], }) monkeypatch.setattr("builtins.input", lambda *a, **kw: "y") from hermes_cli.fallback_cmd import cmd_fallback_clear cmd_fallback_clear(types.SimpleNamespace())
cfg = _read_config(isolated_home) assert cfg.get("fallback_providers") == [] out = capsys.readouterr().out assert "Fallback chain cleared" in out
def test_clear_cancelled(self, isolated_home, monkeypatch): _write_config(isolated_home, { "fallback_providers": [{"provider": "openrouter", "model": "gpt-5.4"}], }) monkeypatch.setattr("builtins.input", lambda *a, **kw: "n") from hermes_cli.fallback_cmd import cmd_fallback_clear cmd_fallback_clear(types.SimpleNamespace())
cfg = _read_config(isolated_home) assert len(cfg["fallback_providers"]) == 1
# ---------------------------------------------------------------------------# cmd_fallback dispatcher# ---------------------------------------------------------------------------
class TestDispatcher: def test_no_subcommand_lists(self, isolated_home, capsys): _write_config(isolated_home, {}) from hermes_cli.fallback_cmd import cmd_fallback cmd_fallback(types.SimpleNamespace(fallback_command=None)) out = capsys.readouterr().out assert "No fallback providers configured" in out
def test_list_alias(self, isolated_home, capsys): _write_config(isolated_home, {}) from hermes_cli.fallback_cmd import cmd_fallback cmd_fallback(types.SimpleNamespace(fallback_command="ls")) out = capsys.readouterr().out assert "No fallback providers configured" in out
def test_remove_alias(self, isolated_home, capsys): _write_config(isolated_home, {}) from hermes_cli.fallback_cmd import cmd_fallback cmd_fallback(types.SimpleNamespace(fallback_command="rm")) out = capsys.readouterr().out assert "nothing to remove" in out
def test_unknown_subcommand_exits(self, isolated_home): _write_config(isolated_home, {}) from hermes_cli.fallback_cmd import cmd_fallback with pytest.raises(SystemExit): cmd_fallback(types.SimpleNamespace(fallback_command="nope"))
# ---------------------------------------------------------------------------# argparse wiring — verify the subparser is registered# ---------------------------------------------------------------------------
class TestArgparseWiring: """Verify `hermes fallback` is wired into main.py's argparse tree.
main() builds the parser inline, so we invoke main([...]) via subprocess with --help to introspect registered subcommands without side effects. """
def test_fallback_help_lists_subcommands(self): import subprocess import sys result = subprocess.run( [sys.executable, "-m", "hermes_cli.main", "fallback", "--help"], capture_output=True, text=True, timeout=30, ) # --help exits 0 assert result.returncode == 0, f"stderr: {result.stderr}" out = result.stdout + result.stderr # All four subcommands should appear in help assert "list" in out assert "add" in out assert "remove" in out assert "clear" in out