Files
hermes-agent/tests/hermes_cli/test_plugin_aux_tasks_dashboard.py
Teknium 4013fa57d5 feat(dashboard,desktop): show which slot a plugin aux task inherits
Plugin aux rows (#40880) now carry `inherit_from` and, for inheriting
tasks, an `effective` {provider, model, base_url} resolved inside the
request's profile scope. Desktop Settings → Models and the dashboard
Auxiliary Tasks modal render an unpinned inheriting row as
"inherits Compression · <provider · model>" and offer "Follow <base>"
on a pinned one (writes provider auto, which resumes inheritance).

The dashboard test now writes plugin manifests/config as JSON (no yaml
dependency in the test env) and covers profile A/B isolation of the
same plugin slot: listing, effective route, pin persistence, follow and
base-change propagation each stay in their own profile.
2026-10-05 17:24:40 -07:00

156 lines
7.5 KiB
Python

"""Plugin-registered auxiliary tasks reach the dashboard/Desktop Models settings.
``PluginContext.register_auxiliary_task`` already puts a plugin task in the ``hermes model``
picker (``main_provider_setup._all_aux_tasks``), but the REST surface behind the Desktop
Settings → Models page enumerated only the built-in ``_AUX_TASK_SLOTS``: ``GET
/api/model/auxiliary`` never listed the task, ``POST /api/model/set`` rejected it with
``unknown auxiliary task``, ``__reset__`` skipped it and the stale-pin nudge ignored it.
These tests load real plugins from temp homes through ``PluginManager`` discovery (no fake
registry) and cover the profile seam: one ``hermes serve`` process, two profiles with their own
plugins and their own config. Regression for #40880 / #129189.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi import HTTPException
import hermes_cli.plugins as plugins_mod
from agent.auxiliary_client import _get_auxiliary_task_config
from hermes_cli.plugins import PluginManager
from hermes_cli.web_server_config import (
_AUX_TASK_SLOTS, _apply_aux_assignment_sync, _apply_model_assignment_sync, _aux_task_slots,
_stale_aux_pins,
)
from hermes_cli.web_server_profiles import _profile_scope
from hermes_cli.web_routers.models import get_auxiliary_models
def _write_plugin(home: Path, name: str, register_call: str) -> None:
plugin_dir = home / "plugins" / name
plugin_dir.mkdir(parents=True)
# JSON is valid YAML; keeps the test free of a yaml dependency.
(plugin_dir / "plugin.yaml").write_text(
json.dumps({"name": name, "version": "0.1.0", "description": f"{name} probe"}))
(plugin_dir / "__init__.py").write_text(f"def register(ctx):\n ctx.{register_call}\n")
def _write_home(home: Path, plugins: dict, compression_model: str) -> None:
for name, call in plugins.items():
_write_plugin(home, name, call)
(home / "config.yaml").write_text(json.dumps({
"plugins": {"enabled": list(plugins)},
"auxiliary": {"compression": {"provider": "openrouter", "model": compression_model}},
}))
_SIDE = ("register_auxiliary_task('side_task', display_name='Side model', "
"description='side model for side', inherit_from='compression')")
_ALPHA = ("register_auxiliary_task('alpha_task', display_name='Alpha side model', "
"description='side model for alpha_plugin', defaults={'timeout': 7})")
@pytest.fixture
def two_profile_homes(tmp_path, monkeypatch):
"""Process home ``a`` (plugins side + alpha) and named profile ``b`` (plugin side only), each
with its own ``auxiliary.compression`` model."""
home_a = tmp_path / ".hermes"
home_b = home_a / "profiles" / "b"
_write_home(home_a, {"side": _SIDE, "alpha_plugin": _ALPHA}, "vendor/model-a")
_write_home(home_b, {"side": _SIDE}, "vendor/model-b")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home_a))
monkeypatch.setattr(plugins_mod, "get_bundled_plugins_dir", lambda: tmp_path / "empty-bundled")
monkeypatch.setattr(PluginManager, "_scan_entry_points", lambda self: [])
plugins_mod._reset_plugin_managers_for_tests()
yield home_a, home_b
plugins_mod._reset_plugin_managers_for_tests()
def _rows(profile):
return {row["task"]: row for row in get_auxiliary_models(profile=profile)["tasks"]}
def _resolved_model(profile):
with _profile_scope(profile):
return _get_auxiliary_task_config("side_task").get("model")
def test_listing_is_per_profile_and_carries_inheritance(two_profile_homes):
listing = get_auxiliary_models(profile=None)["tasks"]
# Built-ins first, in their fixed order and pre-existing shape; plugin rows appended.
assert [row["task"] for row in listing][: len(_AUX_TASK_SLOTS)] == list(_AUX_TASK_SLOTS)
assert "label" not in listing[0] and "plugin" not in listing[0]
rows_a, rows_b = _rows(None), _rows("b")
assert "alpha_task" in rows_a and "alpha_task" not in rows_b
side_a, side_b = rows_a["side_task"], rows_b["side_task"]
assert (side_a["label"], side_a["hint"], side_a["plugin"]) == ("Side model", "side model for side", "side")
assert side_a["inherit_from"] == "compression" and rows_a["alpha_task"]["inherit_from"] is None
# Unpinned: own provider stays "auto"; ``effective`` is the base slot of THAT profile.
assert side_a["provider"] == "auto"
assert side_a["effective"] == {"provider": "openrouter", "model": "vendor/model-a", "base_url": ""}
assert side_b["effective"]["model"] == "vendor/model-b"
def test_pin_persists_per_profile_and_is_honored_by_resolution(two_profile_homes):
home_a, home_b = two_profile_homes
with _profile_scope("b"):
_apply_model_assignment_sync("auxiliary", "nous", "hermes-4", "side_task", "", "")
assert _rows("b")["side_task"]["provider"] == "nous"
assert "side_task" not in (home_a / "config.yaml").read_text()
assert (_resolved_model("b"), _resolved_model(None)) == ("hermes-4", "vendor/model-a")
# "auto" on an inheriting slot (Desktop's "Follow <base>") goes back to the base.
with _profile_scope("b"):
_apply_model_assignment_sync("auxiliary", "auto", "", "side_task", "", "")
assert _resolved_model("b") == "vendor/model-b"
# A base-slot change in B reaches B's plugin slot, never A's.
with _profile_scope("b"):
_apply_model_assignment_sync("auxiliary", "openrouter", "vendor/model-b2", "compression", "", "")
assert (_resolved_model("b"), _resolved_model(None)) == ("vendor/model-b2", "vendor/model-a")
def test_assignment_reset_and_stale_pins_cover_plugin_tasks(two_profile_homes, monkeypatch):
saved: dict = {}
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg))
cfg: dict = {"auxiliary": {}}
out = _apply_aux_assignment_sync(cfg, "openrouter", "fast-model", "alpha_task", "", "")
assert out["tasks"] == ["alpha_task"]
assert cfg["auxiliary"]["alpha_task"] == {"provider": "openrouter", "model": "fast-model"}
assert saved["auxiliary"]["alpha_task"]["model"] == "fast-model"
# A pin on a plugin task is a stale pin like any other when main moves elsewhere.
assert {"task": "alpha_task", "provider": "openrouter", "model": "fast-model"} in _stale_aux_pins(cfg, "nous")
# Empty task = broadcast; it must reach the plugin slot too.
_apply_aux_assignment_sync(cfg, "nous", "hermes-4", "", "", "")
assert cfg["auxiliary"]["alpha_task"]["provider"] == "nous"
_apply_aux_assignment_sync(cfg, "", "", "__reset__", "", "")
assert cfg["auxiliary"]["alpha_task"] == {"provider": "auto", "model": ""}
# Another profile's task is still unknown here — the validation is per-profile, not global.
with _profile_scope("b"):
_apply_aux_assignment_sync({"auxiliary": {}}, "openrouter", "m", "side_task", "", "")
with pytest.raises(HTTPException) as exc:
_apply_aux_assignment_sync({"auxiliary": {}}, "openrouter", "m", "alpha_task", "", "")
assert exc.value.status_code == 400 and "unknown auxiliary task" in exc.value.detail
def test_plugin_discovery_failure_leaves_builtins_working(two_profile_homes, monkeypatch):
def _boom():
raise RuntimeError("plugin scan exploded")
# Patch where production reads: the resolver imports the name from hermes_cli.plugins at call time.
monkeypatch.setattr(plugins_mod, "get_plugin_auxiliary_tasks", _boom)
assert _aux_task_slots() == _AUX_TASK_SLOTS
tasks = [row["task"] for row in get_auxiliary_models(profile=None)["tasks"]]
assert tasks == list(_AUX_TASK_SLOTS)