mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
A custom_providers config that names the model under model.name (or model.model) resolved to an empty model, so the API request went out with model= — HTTP 400 from OpenAI-compatible backends. Display paths (hermes status/dump) already read model.name and showed the model, making the failure silent. The model id was read via 'default or model' at ~14 independent sites (cli, gateway, cron, curator, oneshot, fallback, profiles, ...), none of which honored 'name'. Rather than patch every site, canonicalize at the single load/save chokepoint: _normalize_root_model_keys() now promotes model.model/model.name -> model.default (precedence default > model > name) and drops the stale alias, so every reader — present and future — sees a populated default and config.yaml is migrated canonical on next save. The gateway, which bypasses load_config(), replays the same normalization in _load_gateway_config(). Co-authored-by: Bartok9 <danielrpike9@gmail.com> Credit: root-cause analysis and fix direction from @Bartok9 (#34502, first) and @v86861062 (#34527).
826 lines
33 KiB
Python
826 lines
33 KiB
Python
"""Tests for HermesCLI initialization -- catches configuration bugs
|
|
that only manifest at runtime (not in mocked unit tests)."""
|
|
|
|
import os
|
|
import sys
|
|
from types import SimpleNamespace
|
|
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_invalid_env_var_max_turns_falls_back_to_default(self):
|
|
"""Invalid env values should not crash CLI init."""
|
|
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "not-a-number"})
|
|
assert cli_obj.max_turns == 90
|
|
|
|
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 TestFallbackChainInit:
|
|
def test_merges_new_and_legacy_fallback_config(self):
|
|
cli = _make_cli(config_overrides={
|
|
"fallback_providers": [
|
|
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
|
],
|
|
"fallback_model": {"provider": "nous", "model": "Hermes-4"},
|
|
})
|
|
assert cli._fallback_model == [
|
|
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
|
{"provider": "nous", "model": "Hermes-4"},
|
|
]
|
|
|
|
|
|
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_q_alias_queues_prompt(self):
|
|
"""The /q alias should resolve to /queue, not /quit."""
|
|
cli = _make_cli()
|
|
cli._agent_running = False
|
|
assert cli.process_command("/q follow up") is True
|
|
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 TestPromptToolkitTerminalCompatibility:
|
|
def test_lf_enter_binds_to_submit_handler_posix(self):
|
|
"""Some thin PTYs deliver Enter as LF/c-j instead of CR/enter.
|
|
|
|
On a bare local POSIX TTY (no SSH/WSL/WT/Ghostty) we keep c-j → submit so
|
|
Enter works on thin PTYs (docker exec, certain ssh configurations).
|
|
On Windows, WSL, SSH sessions, Windows Terminal, and Ghostty we leave c-j
|
|
unbound here so it can be used as the Ctrl+Enter newline keystroke
|
|
without conflicting with submit. See issue #22379.
|
|
"""
|
|
import sys as _sys
|
|
import os as _os
|
|
from unittest.mock import patch as _patch
|
|
from prompt_toolkit.key_binding import KeyBindings
|
|
|
|
from cli import _bind_prompt_submit_keys
|
|
|
|
def submit_handler(event):
|
|
return None
|
|
|
|
# Bare local POSIX (no SSH/WSL markers): both enter and c-j submit.
|
|
with _patch.object(_sys, "platform", "linux"), \
|
|
_patch.dict(_os.environ, {}, clear=True), \
|
|
_patch("builtins.open", side_effect=OSError("no /proc")):
|
|
kb = KeyBindings()
|
|
_bind_prompt_submit_keys(kb, submit_handler)
|
|
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
|
|
assert bindings[("c-m",)] is submit_handler
|
|
assert bindings[("c-j",)] is submit_handler
|
|
|
|
# POSIX over SSH: c-j stays free so Ctrl+Enter (sent as LF by
|
|
# Windows Terminal / Kitty / mintty over SSH) inserts a newline.
|
|
with _patch.object(_sys, "platform", "linux"), \
|
|
_patch.dict(_os.environ, {"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, clear=True), \
|
|
_patch("builtins.open", side_effect=OSError("no /proc")):
|
|
kb = KeyBindings()
|
|
_bind_prompt_submit_keys(kb, submit_handler)
|
|
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
|
|
assert bindings[("c-m",)] is submit_handler
|
|
assert ("c-j",) not in bindings
|
|
|
|
# Ghostty through tmux: TERM_PROGRAM is tmux, but Ghostty exports a
|
|
# stable env marker. Keep c-j free so Ctrl+J inserts a newline.
|
|
with _patch.object(_sys, "platform", "linux"), \
|
|
_patch.dict(_os.environ, {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, clear=True), \
|
|
_patch("builtins.open", side_effect=OSError("no /proc")):
|
|
kb = KeyBindings()
|
|
_bind_prompt_submit_keys(kb, submit_handler)
|
|
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
|
|
assert bindings[("c-m",)] is submit_handler
|
|
assert ("c-j",) not in bindings
|
|
|
|
# Windows: only enter submits; c-j is free for the newline binding
|
|
# added separately in the prompt setup.
|
|
with _patch.object(_sys, "platform", "win32"):
|
|
kb = KeyBindings()
|
|
_bind_prompt_submit_keys(kb, submit_handler)
|
|
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
|
|
assert bindings[("c-m",)] is submit_handler
|
|
assert ("c-j",) not in bindings
|
|
|
|
def test_cpr_warning_callback_is_disabled(self):
|
|
from cli import _disable_prompt_toolkit_cpr_warning
|
|
|
|
renderer = SimpleNamespace(cpr_not_supported_callback=lambda: None)
|
|
app = SimpleNamespace(renderer=renderer)
|
|
|
|
_disable_prompt_toolkit_cpr_warning(app)
|
|
|
|
assert renderer.cpr_not_supported_callback is None
|
|
|
|
def test_cpr_disabled_output_marks_renderer_not_supported(self):
|
|
"""CPR-disabled output must make prompt_toolkit skip ESC[6n entirely.
|
|
|
|
The root cause of #13870 is that prompt_toolkit sends ESC[6n cursor
|
|
queries whose CPR replies leak into the display over tunnels/slow PTYs.
|
|
Building the output with enable_cpr=False is what stops the queries:
|
|
the renderer marks CPR NOT_SUPPORTED and never calls ask_for_cpr().
|
|
"""
|
|
import sys as _sys
|
|
from cli import _build_cpr_disabled_output
|
|
from prompt_toolkit.application import Application
|
|
from prompt_toolkit.layout import Layout, Window, FormattedTextControl
|
|
from prompt_toolkit.renderer import CPR_Support
|
|
|
|
out = _build_cpr_disabled_output(_sys.stdout)
|
|
assert out is not None
|
|
# The contract: this output does not respond to CPR.
|
|
assert out.enable_cpr is False
|
|
assert out.responds_to_cpr is False
|
|
|
|
# And wired into an Application, the renderer treats CPR as unsupported,
|
|
# so request_absolute_cursor_position() never sends ESC[6n.
|
|
app = Application(
|
|
layout=Layout(Window(FormattedTextControl("x"))),
|
|
output=out,
|
|
full_screen=False,
|
|
)
|
|
assert app.renderer.cpr_support == CPR_Support.NOT_SUPPORTED
|
|
|
|
def test_cpr_disabled_output_returns_none_on_failure(self):
|
|
"""A non-fileno stdout must degrade to None (default output fallback)."""
|
|
from cli import _build_cpr_disabled_output
|
|
|
|
class _NoFileno:
|
|
def fileno(self):
|
|
raise OSError("not a real fd")
|
|
|
|
# Build must not raise; worst case it returns a usable output or None.
|
|
# The hard guarantee is no exception escapes (startup must never break).
|
|
result = _build_cpr_disabled_output(_NoFileno())
|
|
assert result is None or result.enable_cpr is False
|
|
|
|
def test_cpr_gating_local_vs_tunnel(self, monkeypatch):
|
|
"""CPR is only suppressed on tunneled links / explicit opt-out.
|
|
|
|
CPR works fine on local terminals and is only a layout hint, so the fix
|
|
for #13870 must not change default behavior locally — it gates on
|
|
_terminal_may_leak_cpr(). Local (no SSH env) -> CPR left enabled;
|
|
SSH session or PROMPT_TOOLKIT_NO_CPR=1 -> CPR suppressed.
|
|
"""
|
|
from cli import _terminal_may_leak_cpr
|
|
|
|
for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
# Local terminal: leave prompt_toolkit's default (CPR on) untouched.
|
|
assert _terminal_may_leak_cpr() is False
|
|
|
|
# SSH session: the tunnel where the leak reproduces.
|
|
monkeypatch.setenv("SSH_CONNECTION", "10.0.0.1 22 10.0.0.2 51234")
|
|
assert _terminal_may_leak_cpr() is True
|
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
|
|
|
# prompt_toolkit's own explicit opt-out is honored.
|
|
monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1")
|
|
assert _terminal_may_leak_cpr() is True
|
|
|
|
|
|
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" in output
|
|
assert "session title" in output
|
|
|
|
def test_resume_updates_hermes_session_id_env_and_context(self, tmp_path):
|
|
from gateway.session_context import _UNSET, _VAR_MAP, get_session_env
|
|
from hermes_state import SessionDB
|
|
|
|
cli = _make_cli()
|
|
cli.session_id = "current_session"
|
|
cli.conversation_history = []
|
|
cli.agent = None
|
|
cli._session_db = SessionDB(db_path=tmp_path / "state.db")
|
|
cli._session_db.create_session("current_session", "cli")
|
|
cli._session_db.create_session("target_session", "cli")
|
|
cli._session_db.append_message("target_session", "user", "hello from resumed session")
|
|
|
|
os.environ["HERMES_SESSION_ID"] = "current_session"
|
|
_VAR_MAP["HERMES_SESSION_ID"].set("current_session")
|
|
|
|
try:
|
|
cli._handle_resume_command("/resume target_session")
|
|
|
|
assert cli.session_id == "target_session"
|
|
assert os.environ["HERMES_SESSION_ID"] == "target_session"
|
|
assert get_session_env("HERMES_SESSION_ID") == "target_session"
|
|
finally:
|
|
cli._session_db.close()
|
|
os.environ.pop("HERMES_SESSION_ID", None)
|
|
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
|
|
|
|
def test_resume_list_shows_full_long_titles(self, capsys):
|
|
"""Long session titles render in full in the /resume table — not
|
|
truncated to 30 chars (fixes #14082)."""
|
|
cli = _make_cli()
|
|
cli.session_id = "current"
|
|
cli._session_db = MagicMock()
|
|
long_title = "Salvage BytePlus Volcengine PR With Fixes"
|
|
cli._session_db.list_sessions_rich.return_value = [
|
|
{
|
|
"id": "current",
|
|
"title": "Current",
|
|
"preview": "Current preview",
|
|
"last_active": 0,
|
|
},
|
|
{
|
|
"id": "20260401_201329_d85961",
|
|
"title": long_title,
|
|
"preview": "fix byteplus pr and resume",
|
|
"last_active": 0,
|
|
},
|
|
]
|
|
|
|
cli._handle_resume_command("/resume")
|
|
output = capsys.readouterr().out
|
|
|
|
assert long_title in output
|
|
assert "20260401_201329_d85961" in output
|
|
|
|
def test_sessions_command_no_args_lists_recent_sessions(self, capsys):
|
|
"""/sessions with no args prints the recent-sessions table (TUI parity).
|
|
|
|
Regression test: `sessions` was registered in the central command
|
|
registry and surfaced by /help and tab-completion, but the classic
|
|
CLI dispatcher had no elif branch for it, so the canonical name fell
|
|
through and printed `Unknown command: sessions`.
|
|
"""
|
|
cli = _make_cli()
|
|
cli.session_id = "current"
|
|
cli._session_db = MagicMock()
|
|
cli._session_db.list_sessions_rich.return_value = [
|
|
{
|
|
"id": "20260401_201329_d85961",
|
|
"title": "Checking Running Hermes Agent",
|
|
"preview": "check running gateways for hermes agent",
|
|
"last_active": 0,
|
|
},
|
|
]
|
|
|
|
# Drive it through the public dispatcher to also lock in the
|
|
# process_command wiring, not just the handler in isolation.
|
|
cli.process_command("/sessions")
|
|
output = capsys.readouterr().out
|
|
|
|
assert "Unknown command" not in output
|
|
assert "Recent sessions" in output
|
|
assert "Checking Running Hermes Agent" in output
|
|
assert "20260401_201329_d85961" in output
|
|
|
|
def test_sessions_list_subcommand_lists_recent_sessions(self, capsys):
|
|
"""/sessions list is an explicit alias for the no-arg list view."""
|
|
cli = _make_cli()
|
|
cli.session_id = "current"
|
|
cli._session_db = MagicMock()
|
|
cli._session_db.list_sessions_rich.return_value = [
|
|
{
|
|
"id": "20260401_201329_d85961",
|
|
"title": "Checking Running Hermes Agent",
|
|
"preview": "check running gateways for hermes agent",
|
|
"last_active": 0,
|
|
},
|
|
]
|
|
|
|
cli.process_command("/sessions list")
|
|
output = capsys.readouterr().out
|
|
|
|
assert "Unknown command" not in output
|
|
assert "Recent sessions" in output
|
|
assert "Checking Running Hermes Agent" in output
|
|
|
|
def test_sessions_with_target_delegates_to_resume(self):
|
|
"""/sessions <id_or_title> behaves identically to /resume <id_or_title>.
|
|
|
|
We intercept `_handle_resume_command` rather than the full resume
|
|
machinery (which would otherwise require simulating an entire session
|
|
switch). The contract under test is the dispatch wiring.
|
|
"""
|
|
cli = _make_cli()
|
|
with patch.object(cli, "_handle_resume_command") as mock_resume:
|
|
cli.process_command("/sessions Checking Running Hermes Agent")
|
|
|
|
mock_resume.assert_called_once_with(
|
|
"/resume Checking Running Hermes Agent"
|
|
)
|
|
|
|
def test_sessions_command_is_dispatched(self):
|
|
"""/sessions must hit _handle_sessions_command, not fall through.
|
|
|
|
Direct test that the process_command elif chain routes the canonical
|
|
name to the handler. Without this wiring, /sessions printed
|
|
`Unknown command: sessions` even though it was a registered command.
|
|
"""
|
|
cli = _make_cli()
|
|
cli._session_db = None # exercise the no-db path too
|
|
|
|
with patch.object(cli, "_handle_sessions_command") as mock_handler:
|
|
cli.process_command("/sessions")
|
|
|
|
mock_handler.assert_called_once()
|
|
called_with = mock_handler.call_args.args[0]
|
|
assert called_with.lower().startswith("/sessions")
|
|
|
|
|
|
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_used_as_fallback_when_model_provider_missing(self, tmp_path, monkeypatch):
|
|
"""Legacy root-level provider still populates model.provider in the CLI loader."""
|
|
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()
|
|
|
|
assert cfg["model"]["provider"] == "opencode-go"
|
|
|
|
def test_root_base_url_used_as_fallback_when_model_base_url_missing(self, tmp_path, monkeypatch):
|
|
"""Legacy root-level base_url still populates model.base_url in the CLI loader."""
|
|
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({
|
|
"base_url": "https://example.com/v1",
|
|
"model": {
|
|
"default": "google/gemini-3-flash-preview",
|
|
},
|
|
}))
|
|
|
|
import cli
|
|
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
|
|
cfg = cli.load_cli_config()
|
|
|
|
assert cfg["model"]["base_url"] == "https://example.com/v1"
|
|
|
|
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
|
|
|
|
def test_normalize_model_api_base_aliases_to_base_url(self):
|
|
"""model.api_base is migrated to model.base_url (issue #8919)."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
config = {
|
|
"model": {
|
|
"provider": "custom",
|
|
"api_base": "http://localhost:4000",
|
|
"api_key": "my-key",
|
|
"default": "default",
|
|
},
|
|
}
|
|
result = _normalize_root_model_keys(config)
|
|
assert result["model"]["base_url"] == "http://localhost:4000"
|
|
assert "api_base" not in result["model"] # alias cleaned up
|
|
|
|
def test_normalize_api_base_does_not_override_base_url(self):
|
|
"""An explicit model.base_url is never overridden by api_base."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
config = {
|
|
"model": {
|
|
"provider": "custom",
|
|
"api_base": "http://wrong:9999",
|
|
"base_url": "http://localhost:4000",
|
|
"default": "default",
|
|
},
|
|
}
|
|
result = _normalize_root_model_keys(config)
|
|
assert result["model"]["base_url"] == "http://localhost:4000"
|
|
assert "api_base" not in result["model"]
|
|
|
|
def test_normalize_root_context_length_migrates_to_model(self):
|
|
"""Root-level context_length is migrated into the model section."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
config = {
|
|
"context_length": 128000,
|
|
"model": {
|
|
"default": "my-model",
|
|
},
|
|
}
|
|
result = _normalize_root_model_keys(config)
|
|
assert result["model"]["context_length"] == 128000
|
|
assert "context_length" not in result # root key cleaned up
|
|
|
|
def test_normalize_root_context_length_does_not_override_existing(self):
|
|
"""Existing model.context_length is not overridden by root-level key."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
config = {
|
|
"context_length": 256000,
|
|
"model": {
|
|
"default": "my-model",
|
|
"context_length": 128000,
|
|
},
|
|
}
|
|
result = _normalize_root_model_keys(config)
|
|
assert result["model"]["context_length"] == 128000 # preserved
|
|
assert "context_length" not in result # root key still cleaned up
|
|
|
|
def test_normalize_root_context_length_with_string_model(self):
|
|
"""Root-level context_length is migrated even when model is a string."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
config = {
|
|
"context_length": 128000,
|
|
"model": "my-model",
|
|
}
|
|
result = _normalize_root_model_keys(config)
|
|
assert isinstance(result["model"], dict)
|
|
assert result["model"]["default"] == "my-model"
|
|
assert result["model"]["context_length"] == 128000
|
|
assert "context_length" not in result
|
|
|
|
# --- model-id alias canonicalization (issue #34500) -------------------
|
|
# ``model.name`` / ``model.model`` must canonicalize to ``model.default``
|
|
# so the runtime resolver (and ~14 other readers) never sends an empty
|
|
# ``model=`` to the backend. Precedence: default > model > name.
|
|
|
|
def test_normalize_model_name_aliases_to_default(self):
|
|
"""model.name (custom-provider repro) becomes model.default (#34500)."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
config = {
|
|
"model": {"name": "claude-sonnet-4-20250514", "provider": "my-litellm"},
|
|
}
|
|
result = _normalize_root_model_keys(config)
|
|
assert result["model"]["default"] == "claude-sonnet-4-20250514"
|
|
assert "name" not in result["model"] # stale alias dropped
|
|
|
|
def test_normalize_model_alias_to_default(self):
|
|
"""model.model becomes model.default."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
result = _normalize_root_model_keys({"model": {"model": "via-model-key"}})
|
|
assert result["model"]["default"] == "via-model-key"
|
|
assert "model" not in result["model"]
|
|
|
|
def test_normalize_explicit_default_wins_over_name(self):
|
|
"""An explicit model.default is never overridden, and a stale alias is dropped."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
result = _normalize_root_model_keys(
|
|
{"model": {"default": "real-model", "name": "ignored"}}
|
|
)
|
|
assert result["model"]["default"] == "real-model"
|
|
assert "name" not in result["model"]
|
|
|
|
def test_normalize_explicit_default_wins_over_model(self):
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
result = _normalize_root_model_keys(
|
|
{"model": {"default": "real-model", "model": "ignored"}}
|
|
)
|
|
assert result["model"]["default"] == "real-model"
|
|
assert "model" not in result["model"]
|
|
|
|
def test_normalize_model_wins_over_name(self):
|
|
"""Precedence: model > name when both are aliases and default is empty."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
result = _normalize_root_model_keys({"model": {"model": "m-key", "name": "n-key"}})
|
|
assert result["model"]["default"] == "m-key"
|
|
assert "model" not in result["model"] and "name" not in result["model"]
|
|
|
|
def test_normalize_empty_model_dict_stays_empty(self):
|
|
"""No id key anywhere → default stays empty (no fabricated value)."""
|
|
from hermes_cli.config import _normalize_root_model_keys
|
|
|
|
result = _normalize_root_model_keys({"model": {"provider": "my-litellm"}})
|
|
assert (result["model"].get("default") or "") == ""
|
|
|
|
def test_normalize_model_name_save_roundtrip_migrates_key(self, tmp_path, monkeypatch):
|
|
"""A model.name config is permanently migrated to model.default on save."""
|
|
import hermes_cli.config as cfgmod
|
|
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
cfg_path = home / "config.yaml"
|
|
cfg_path.write_text("model:\n name: claude-sonnet-4\n provider: my-litellm\n")
|
|
# bust the mtime cache
|
|
cfgmod._RAW_CONFIG_CACHE.clear()
|
|
|
|
loaded = cfgmod.load_config()
|
|
assert loaded["model"]["default"] == "claude-sonnet-4"
|
|
cfgmod.save_config(loaded)
|
|
|
|
raw = cfg_path.read_text()
|
|
assert "name:" not in raw # stale alias gone from the file
|
|
assert "default: claude-sonnet-4" in raw
|
|
|
|
|
|
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
|