ba030bc0db
Tests did monkeypatch.setattr(<facade module>, name) where name is now defined in a sibling module and the production path reads the sibling's binding. Where production reads through BOTH bindings the setattr is duplicated onto the defining module (import added next to the existing alias import); where only the sibling reads it the target is repointed. Seams whose production readers go through the facade are left alone.
197 lines
7.3 KiB
Python
197 lines
7.3 KiB
Python
"""Tests for the auxiliary-model configuration UI in ``hermes model``.
|
|
|
|
Covers the helper functions:
|
|
- ``_save_aux_choice`` writes to config.yaml without touching main model config
|
|
- ``_reset_aux_to_auto`` clears routing fields but preserves timeouts
|
|
- ``_format_aux_current`` renders current task config for the menu
|
|
- ``_AUX_TASKS`` stays in sync with ``DEFAULT_CONFIG["auxiliary"]``
|
|
|
|
These are pure-function tests — the interactive menu loops are not covered
|
|
here (they're stdin-driven curses prompts).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.config import DEFAULT_CONFIG, load_config
|
|
from hermes_cli.main_provider_setup import _AUX_TASKS, _DELEGATION_TASK_KEY, _delegation_cfg_as_task, _format_aux_current, _reset_aux_to_auto, _save_aux_choice
|
|
|
|
|
|
# ── Default config ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_title_generation_present_in_default_config():
|
|
"""`title_generation` task must be defined in DEFAULT_CONFIG.
|
|
|
|
Regression for an existing gap: title_generator.py calls
|
|
``call_llm(task="title_generation", ...)`` but the task was missing
|
|
from DEFAULT_CONFIG["auxiliary"], so the config-backed timeout/provider
|
|
overrides never worked for that task.
|
|
"""
|
|
assert "title_generation" in DEFAULT_CONFIG["auxiliary"]
|
|
tg = DEFAULT_CONFIG["auxiliary"]["title_generation"]
|
|
assert tg["enabled"] is True
|
|
assert tg["provider"] == "auto"
|
|
assert tg["model"] == ""
|
|
assert tg["prefer_fast_model"] is False
|
|
assert tg["timeout"] > 0
|
|
assert tg["extra_body"] == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── _format_aux_current ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# ── _save_aux_choice ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_save_aux_choice_persists_to_config_yaml(tmp_path, monkeypatch):
|
|
"""Saving a task writes provider/model/base_url/api_key to auxiliary.<task>."""
|
|
from pathlib import Path
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
|
|
|
_save_aux_choice(
|
|
"vision", provider="openrouter", model="google/gemini-2.5-flash",
|
|
)
|
|
cfg = load_config()
|
|
v = cfg["auxiliary"]["vision"]
|
|
assert v["provider"] == "openrouter"
|
|
assert v["model"] == "google/gemini-2.5-flash"
|
|
assert v["base_url"] == ""
|
|
assert v["api_key"] == ""
|
|
|
|
|
|
|
|
|
|
# ── _reset_aux_to_auto ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Menu dispatch ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# ── Delegation entry (top-level `delegation.*`, not `auxiliary.*`) ──────────
|
|
|
|
|
|
def _isolate_home(tmp_path, monkeypatch):
|
|
from pathlib import Path
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
|
|
|
|
|
def test_save_delegation_writes_top_level_section(tmp_path, monkeypatch):
|
|
"""Delegation picks write to delegation.*, never auxiliary.delegation."""
|
|
_isolate_home(tmp_path, monkeypatch)
|
|
|
|
_save_aux_choice(
|
|
_DELEGATION_TASK_KEY, provider="openrouter", model="google/gemini-3-flash",
|
|
)
|
|
cfg = load_config()
|
|
d = cfg["delegation"]
|
|
assert d["provider"] == "openrouter"
|
|
assert d["model"] == "google/gemini-3-flash"
|
|
assert d["base_url"] == ""
|
|
assert d["api_key"] == ""
|
|
aux = cfg.get("auxiliary", {})
|
|
entry = aux.get("delegation", {}) if isinstance(aux, dict) else {}
|
|
assert not (isinstance(entry, dict) and entry.get("provider")), (
|
|
"delegation routing leaked into auxiliary.delegation"
|
|
)
|
|
|
|
|
|
def test_save_delegation_auto_stores_empty_provider(tmp_path, monkeypatch):
|
|
"""'auto' (inherit parent) persists as empty strings — never the literal
|
|
'auto', which delegate_tool would resolve as a provider name."""
|
|
_isolate_home(tmp_path, monkeypatch)
|
|
|
|
_save_aux_choice(_DELEGATION_TASK_KEY, provider="openrouter", model="m")
|
|
_save_aux_choice(_DELEGATION_TASK_KEY, provider="auto", model="", base_url="", api_key="")
|
|
cfg = load_config()
|
|
d = cfg["delegation"]
|
|
assert d["provider"] == ""
|
|
assert d["model"] == ""
|
|
assert d["base_url"] == ""
|
|
assert d["api_key"] == ""
|
|
|
|
|
|
def test_reset_aux_clears_delegation_routing_preserves_settings(tmp_path, monkeypatch):
|
|
"""Reset-all clears delegation provider/model/base_url/api_key but leaves
|
|
non-routing delegation settings (max_concurrent_children, etc.) alone."""
|
|
from hermes_cli.config import load_config as _lc, save_config
|
|
|
|
_isolate_home(tmp_path, monkeypatch)
|
|
|
|
cfg = _lc()
|
|
cfg.setdefault("delegation", {})
|
|
cfg["delegation"].update(
|
|
{"provider": "openrouter", "model": "x", "max_concurrent_children": 7}
|
|
)
|
|
save_config(cfg)
|
|
|
|
n = _reset_aux_to_auto()
|
|
assert n >= 1
|
|
|
|
cfg = _lc()
|
|
d = cfg["delegation"]
|
|
assert d["provider"] == ""
|
|
assert d["model"] == ""
|
|
assert d["max_concurrent_children"] == 7
|
|
|
|
|
|
def test_delegation_cfg_as_task_projection():
|
|
"""Projection renders empty provider as auto via _format_aux_current."""
|
|
assert _format_aux_current(_delegation_cfg_as_task({})) == "auto"
|
|
shaped = _delegation_cfg_as_task(
|
|
{"delegation": {"provider": "nous", "model": "Hermes-4.5"}}
|
|
)
|
|
assert _format_aux_current(shaped) == "nous · Hermes-4.5"
|
|
# Non-dict delegation section must not crash
|
|
assert _format_aux_current(_delegation_cfg_as_task({"delegation": "bogus"})) == "auto"
|
|
|
|
|
|
def test_leave_unchanged_replaces_cancel_label(tmp_path, monkeypatch):
|
|
"""The bottom cancel entry now reads 'Leave unchanged' (UX polish)."""
|
|
from pathlib import Path
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
|
|
|
from hermes_cli import main as main_mod
|
|
import hermes_cli.main_provider_setup as hermes_cli_main_provider_setup
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_prompt(choices, *, default=0):
|
|
captured.append(list(choices))
|
|
# Pick 'Leave unchanged' (last item) to exit cleanly
|
|
for i, label in enumerate(choices):
|
|
if label == "Leave unchanged":
|
|
return i
|
|
raise AssertionError("Leave unchanged not in provider list")
|
|
|
|
monkeypatch.setattr(main_mod, "_prompt_provider_choice", fake_prompt)
|
|
monkeypatch.setattr(hermes_cli_main_provider_setup, "_prompt_provider_choice", fake_prompt)
|
|
|
|
main_mod.select_provider_and_model()
|
|
|
|
assert captured, "provider menu never rendered"
|
|
labels = captured[0]
|
|
assert "Leave unchanged" in labels
|
|
assert "Cancel" not in labels, "Cancel label should be replaced"
|
|
assert any("Configure auxiliary models" in label for label in labels)
|