Something went wrong. Try again.
The agent that grows with you
Something went wrong. Try again.
19 kB · 529 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530"""Tests for setup.py configuration flows."""import jsonimport sysimport types
import pytest
from hermes_cli.auth import get_active_providerfrom hermes_cli.config import load_config, save_configfrom hermes_cli import setup as setup_modfrom hermes_cli.setup import setup_model_provider
def _maybe_keep_current_tts(question, choices): if question != "Select TTS provider:": return None assert choices[-1].startswith("Keep current (") return len(choices) - 1
def _clear_provider_env(monkeypatch): for key in ( "NOUS_API_KEY", "OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", "LLM_MODEL", ): monkeypatch.delenv(key, raising=False)
def _stub_tts(monkeypatch): """Stub out TTS prompts so setup_model_provider doesn't block.""" monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda q, c, d=0: ( _maybe_keep_current_tts(q, c) if _maybe_keep_current_tts(q, c) is not None else d )) monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False)
def _write_model_config(tmp_path, provider, base_url="", model_name="test-model"): """Simulate what a _model_flow_* function writes to disk.""" cfg = load_config() m = cfg.get("model") if not isinstance(m, dict): m = {"default": m} if m else {} cfg["model"] = m m["provider"] = provider if base_url: m["base_url"] = base_url if model_name: m["default"] = model_name save_config(cfg)
def test_setup_delegates_to_select_provider_and_model(tmp_path, monkeypatch): """setup_model_provider calls select_provider_and_model and syncs config.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) _stub_tts(monkeypatch)
config = load_config()
def fake_select(): _write_model_config(tmp_path, "custom", "http://localhost:11434/v1", "qwen3.5:32b")
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
setup_model_provider(config) save_config(config)
reloaded = load_config() assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "custom" assert reloaded["model"]["base_url"] == "http://localhost:11434/v1" assert reloaded["model"]["default"] == "qwen3.5:32b"
def test_setup_syncs_openrouter_from_disk(tmp_path, monkeypatch): """When select_provider_and_model saves OpenRouter config to disk, the wizard's config dict picks it up.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) _stub_tts(monkeypatch)
config = load_config() assert isinstance(config.get("model"), str) # fresh install
def fake_select(): _write_model_config(tmp_path, "openrouter", model_name="anthropic/claude-opus-4.6")
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
setup_model_provider(config) save_config(config)
reloaded = load_config() assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "openrouter"
def test_setup_syncs_nous_from_disk(tmp_path, monkeypatch): """Nous OAuth writes config to disk; wizard config dict must pick it up.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) _stub_tts(monkeypatch)
config = load_config()
def fake_select(): _write_model_config(tmp_path, "nous", "https://inference.example.com/v1", "gemini-3-flash")
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
setup_model_provider(config) save_config(config)
reloaded = load_config() assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "nous" assert reloaded["model"]["base_url"] == "https://inference.example.com/v1"
def test_setup_custom_providers_synced(tmp_path, monkeypatch): """custom_providers written by select_provider_and_model must survive.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) _stub_tts(monkeypatch)
config = load_config()
def fake_select(): _write_model_config(tmp_path, "custom", "http://localhost:8080/v1", "llama3") cfg = load_config() cfg["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}] save_config(cfg)
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
setup_model_provider(config) save_config(config)
reloaded = load_config() assert reloaded.get("custom_providers") == [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch, capsys): env = { "TELEGRAM_BOT_TOKEN": "", "TELEGRAM_HOME_CHANNEL": "", "DISCORD_BOT_TOKEN": "", "DISCORD_HOME_CHANNEL": "", "SLACK_BOT_TOKEN": "", "SLACK_HOME_CHANNEL": "", "MATRIX_HOMESERVER": "https://matrix.example.com", "MATRIX_USER_ID": "@alice:example.com", "MATRIX_PASSWORD": "", "MATRIX_ACCESS_TOKEN": "token", "BLUEBUBBLES_SERVER_URL": "", "BLUEBUBBLES_HOME_CHANNEL": "", "WHATSAPP_ENABLED": "", "WEBHOOK_ENABLED": "", }
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) monkeypatch.setattr("platform.system", lambda: "Linux")
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) monkeypatch.setattr(gateway_mod, "is_macos", lambda: False) monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False) monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False)
setup_mod.setup_gateway({})
out = capsys.readouterr().out assert "Messaging platforms configured!" in out assert "Start the gateway to bring your bots online:" in out assert "hermes gateway" in out
def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys): """setup_gateway() in a Docker container shows Docker-specific restart instructions.""" env = { "TELEGRAM_BOT_TOKEN": "", "TELEGRAM_HOME_CHANNEL": "", "DISCORD_BOT_TOKEN": "", "DISCORD_HOME_CHANNEL": "", "SLACK_BOT_TOKEN": "", "SLACK_HOME_CHANNEL": "", "MATRIX_HOMESERVER": "https://matrix.example.com", "MATRIX_USER_ID": "@alice:example.com", "MATRIX_PASSWORD": "", "MATRIX_ACCESS_TOKEN": "token", "BLUEBUBBLES_SERVER_URL": "", "BLUEBUBBLES_HOME_CHANNEL": "", "WHATSAPP_ENABLED": "", "WEBHOOK_ENABLED": "", }
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) monkeypatch.setattr("platform.system", lambda: "Linux")
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) monkeypatch.setattr(gateway_mod, "is_macos", lambda: False) monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False) monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False)
# Patch is_container at the import location in setup.py import hermes_constants monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
setup_mod.setup_gateway({})
out = capsys.readouterr().out assert "Messaging platforms configured!" in out assert "docker" in out.lower() or "Docker" in out assert "restart" in out.lower()
def test_setup_syncs_custom_provider_removal_from_disk(tmp_path, monkeypatch): """Removing the last custom provider in model setup should persist.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) _stub_tts(monkeypatch)
config = load_config() config["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}] save_config(config)
def fake_select(): cfg = load_config() cfg["model"] = {"provider": "openrouter", "default": "anthropic/claude-opus-4.6"} cfg["custom_providers"] = [] save_config(cfg)
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
setup_model_provider(config) save_config(config)
reloaded = load_config() assert reloaded.get("custom_providers") == []
def test_setup_cancel_preserves_existing_config(tmp_path, monkeypatch): """When the user cancels provider selection, existing config is preserved.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) _stub_tts(monkeypatch)
# Pre-set a provider _write_model_config(tmp_path, "openrouter", model_name="gpt-4o")
config = load_config() assert config["model"]["provider"] == "openrouter"
def fake_select(): pass # user cancelled — nothing written to disk
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
setup_model_provider(config) save_config(config)
reloaded = load_config() assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "openrouter" assert reloaded["model"]["default"] == "gpt-4o"
def test_setup_exception_in_select_gracefully_handled(tmp_path, monkeypatch): """If select_provider_and_model raises, setup continues with existing config.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) _stub_tts(monkeypatch)
config = load_config()
def fake_select(): raise RuntimeError("something broke")
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
# Should not raise setup_model_provider(config)
def test_setup_keyboard_interrupt_gracefully_handled(tmp_path, monkeypatch): """KeyboardInterrupt during provider selection is handled.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) _stub_tts(monkeypatch)
config = load_config()
def fake_select(): raise KeyboardInterrupt()
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
setup_model_provider(config)
def test_select_provider_and_model_warns_if_named_custom_provider_disappears( tmp_path, monkeypatch, capsys): """If a saved custom provider is deleted mid-selection, show a warning instead of silently doing nothing.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch)
cfg = load_config() cfg["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}] save_config(cfg)
def fake_prompt_provider_choice(choices, default=0): current = load_config() current["custom_providers"] = [] save_config(current) return next(i for i, label in enumerate(choices) if label.startswith("Local (localhost:8080/v1)"))
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda provider: None) monkeypatch.setattr("hermes_cli.main._prompt_provider_choice", fake_prompt_provider_choice) monkeypatch.setattr( "hermes_cli.main._model_flow_named_custom", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("named custom flow should not run")), )
from hermes_cli.main import select_provider_and_model
select_provider_and_model()
out = capsys.readouterr().out assert "selected saved custom provider is no longer available" in out
def test_select_provider_and_model_accepts_named_provider_from_providers_section( tmp_path, monkeypatch, capsys): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch)
cfg = load_config() cfg["model"] = { "provider": "volcengine-plan", "default": "doubao-seed-2.0-code", } cfg["providers"] = { "volcengine-plan": { "name": "volcengine-plan", "base_url": "https://ark.cn-beijing.volces.com/api/coding/v3", "default_model": "doubao-seed-2.0-code", "models": {"doubao-seed-2.0-code": {}}, } } save_config(cfg)
monkeypatch.setattr( "hermes_cli.main._prompt_provider_choice", lambda choices, default=0: len(choices) - 1, )
from hermes_cli.main import select_provider_and_model
select_provider_and_model()
out = capsys.readouterr().out assert "Warning: Unknown provider 'volcengine-plan'" not in out assert "Active provider: volcengine-plan" in out
def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch): """Codex model list fetching uses the runtime access token.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key") _clear_provider_env(monkeypatch) monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
config = load_config() _stub_tts(monkeypatch)
def fake_select(): _write_model_config(tmp_path, "openai-codex", "https://api.openai.com/v1", "gpt-4o")
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
setup_model_provider(config) save_config(config)
reloaded = load_config() assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "openai-codex"
def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys): monkeypatch.setattr("hermes_cli.setup.managed_nous_tools_enabled", lambda: True) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) config = load_config()
def fake_prompt_choice(question, choices, default=0): if question == "Select terminal backend:": return 2 if question == "Select how Modal execution should be billed:": return 0 raise AssertionError(f"Unexpected prompt_choice call: {question}")
def fake_prompt(message, *args, **kwargs): assert "Modal Token" not in message raise AssertionError(f"Unexpected prompt call: {message}")
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt) monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None) monkeypatch.setattr( "hermes_cli.setup.get_nous_subscription_features", lambda config: type("Features", (), {"nous_auth_present": True})(), ) monkeypatch.setitem( sys.modules, "tools.managed_tool_gateway", types.SimpleNamespace( is_managed_tool_gateway_ready=lambda vendor: vendor == "modal", resolve_managed_tool_gateway=lambda vendor: None, ), )
from hermes_cli.setup import setup_terminal_backend
setup_terminal_backend(config)
out = capsys.readouterr().out assert config["terminal"]["backend"] == "modal" assert config["terminal"]["modal_mode"] == "managed" assert "bill to your subscription" in out
def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch): monkeypatch.setattr("hermes_cli.setup.managed_nous_tools_enabled", lambda: True) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) config = load_config()
def fake_prompt_choice(question, choices, default=0): if question == "Select terminal backend:": return 2 if question == "Select how Modal execution should be billed:": return 1 raise AssertionError(f"Unexpected prompt_choice call: {question}")
prompt_values = iter(["token-id", "token-secret", ""])
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values)) monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None) monkeypatch.setattr( "hermes_cli.setup.get_nous_subscription_features", lambda config: type("Features", (), {"nous_auth_present": True})(), ) monkeypatch.setitem( sys.modules, "tools.managed_tool_gateway", types.SimpleNamespace( is_managed_tool_gateway_ready=lambda vendor: vendor == "modal", resolve_managed_tool_gateway=lambda vendor: None, ), ) monkeypatch.setitem(sys.modules, "swe_rex", object())
from hermes_cli.setup import setup_terminal_backend
setup_terminal_backend(config)
assert config["terminal"]["backend"] == "modal" assert config["terminal"]["modal_mode"] == "direct"
def test_resolve_hermes_chat_argv_prefers_which(monkeypatch): from hermes_cli import setup as setup_mod
monkeypatch.setattr(setup_mod.shutil, "which", lambda name: "/usr/local/bin/hermes" if name == "hermes" else None)
assert setup_mod._resolve_hermes_chat_argv() == ["/usr/local/bin/hermes", "chat"]
def test_resolve_hermes_chat_argv_falls_back_to_module(monkeypatch): from hermes_cli import setup as setup_mod
monkeypatch.setattr(setup_mod.shutil, "which", lambda _name: None) monkeypatch.setattr(setup_mod.importlib.util, "find_spec", lambda name: object() if name == "hermes_cli" else None)
assert setup_mod._resolve_hermes_chat_argv() == [sys.executable, "-m", "hermes_cli.main", "chat"]
def test_offer_launch_chat_execs_fresh_process(monkeypatch): from hermes_cli import setup as setup_mod
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True) monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: ["/usr/local/bin/hermes", "chat"])
exec_calls = []
def fake_execvp(path, argv): exec_calls.append((path, argv)) raise SystemExit(0)
monkeypatch.setattr(setup_mod.os, "execvp", fake_execvp)
with pytest.raises(SystemExit): setup_mod._offer_launch_chat()
assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])]
def test_offer_launch_chat_manual_fallback_when_unresolvable(monkeypatch, capsys): from hermes_cli import setup as setup_mod
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True) monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: None)
setup_mod._offer_launch_chat()
captured = capsys.readouterr() assert "Run 'hermes chat' manually" in captured.out