Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -173,3 +173,78 @@ class TestCmdUpdateBranchFallback:
|
||||
mock_input.assert_not_called()
|
||||
captured = capsys.readouterr()
|
||||
assert "Non-interactive session" in captured.out
|
||||
|
||||
|
||||
class TestCmdUpdateProfileSkillSync:
|
||||
"""cmd_update syncs bundled skills to all profiles, including the active one.
|
||||
|
||||
Regression guard for #16176: previously the active profile was excluded
|
||||
from the seed_profile_skills loop, leaving it on stale skill content.
|
||||
"""
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_active_profile_included_in_skill_sync(
|
||||
self, mock_run, _mock_which, mock_args, capsys
|
||||
):
|
||||
from pathlib import Path
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
|
||||
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
|
||||
active_p = SimpleNamespace(name="bit", path=Path("/fake/.hermes/profiles/bit"))
|
||||
other_p = SimpleNamespace(name="work", path=Path("/fake/.hermes/profiles/work"))
|
||||
all_profiles = [default_p, active_p, other_p]
|
||||
|
||||
synced_paths = []
|
||||
|
||||
def fake_seed(path, quiet=False):
|
||||
synced_paths.append(path)
|
||||
return {"copied": [], "updated": [], "user_modified": []}
|
||||
|
||||
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
|
||||
|
||||
with (
|
||||
patch("hermes_cli.profiles.list_profiles", return_value=all_profiles),
|
||||
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
|
||||
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
|
||||
):
|
||||
cmd_update(mock_args)
|
||||
|
||||
assert active_p.path in synced_paths, (
|
||||
f"Active profile 'bit' must be included in skill sync; got: {synced_paths}"
|
||||
)
|
||||
assert set(synced_paths) == {p.path for p in all_profiles}, (
|
||||
f"All profiles must be synced; got: {synced_paths}"
|
||||
)
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_single_profile_default_is_synced(
|
||||
self, mock_run, _mock_which, mock_args, capsys
|
||||
):
|
||||
from pathlib import Path
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
|
||||
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
|
||||
synced_paths = []
|
||||
|
||||
def fake_seed(path, quiet=False):
|
||||
synced_paths.append(path)
|
||||
return {"copied": [], "updated": [], "user_modified": []}
|
||||
|
||||
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
|
||||
|
||||
with (
|
||||
patch("hermes_cli.profiles.list_profiles", return_value=[default_p]),
|
||||
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
|
||||
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
|
||||
):
|
||||
cmd_update(mock_args)
|
||||
|
||||
assert default_p.path in synced_paths
|
||||
|
||||
@@ -109,6 +109,12 @@ class TestResolveCommand:
|
||||
assert resolve_command("reload_mcp").name == "reload-mcp"
|
||||
assert resolve_command("tasks").name == "agents"
|
||||
|
||||
def test_topic_is_gateway_command(self):
|
||||
topic = resolve_command("topic")
|
||||
assert topic is not None
|
||||
assert topic.name == "topic"
|
||||
assert "topic" in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
def test_leading_slash_stripped(self):
|
||||
assert resolve_command("/help").name == "help"
|
||||
assert resolve_command("/bg").name == "background"
|
||||
|
||||
@@ -663,3 +663,79 @@ def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path
|
||||
)
|
||||
assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls)
|
||||
assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls)
|
||||
|
||||
|
||||
class TestGitHubTokenCheck:
|
||||
"""Tests for GitHub token / gh auth detection in doctor."""
|
||||
|
||||
def test_no_token_and_not_gh_authenticated_shows_warn(self, monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
|
||||
|
||||
from hermes_cli.doctor import run_doctor, _DHH
|
||||
import io, contextlib
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "No GITHUB_TOKEN" in out
|
||||
assert "60 req/hr" in out
|
||||
|
||||
def test_token_env_present_shows_ok(self, monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_test123")
|
||||
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
|
||||
|
||||
from hermes_cli.doctor import run_doctor
|
||||
import io, contextlib
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "GitHub token configured" in out
|
||||
|
||||
def test_gh_authenticated_without_env_token_shows_ok(self, monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
# No GITHUB_TOKEN or GH_TOKEN
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
|
||||
# Mock gh to return success
|
||||
import shutil
|
||||
real_which = shutil.which
|
||||
def mock_which(cmd):
|
||||
return "/usr/local/bin/gh" if cmd == "gh" else real_which(cmd)
|
||||
monkeypatch.setattr(shutil, "which", mock_which)
|
||||
|
||||
call_log = []
|
||||
def mock_run(cmd, **kwargs):
|
||||
call_log.append(cmd)
|
||||
if cmd[:2] == ["gh", "auth"]:
|
||||
result = types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
else:
|
||||
result = types.SimpleNamespace(returncode=1, stdout="", stderr="")
|
||||
return result
|
||||
|
||||
import subprocess
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
|
||||
from hermes_cli.doctor import run_doctor
|
||||
import io, contextlib
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "gh auth" in str(call_log) or any(c[0] == "gh" for c in call_log), f"gh not called: {call_log}"
|
||||
assert "GitHub authenticated via gh CLI" in out or "token configured" in out
|
||||
|
||||
@@ -182,6 +182,43 @@ class TestGeneratedSystemdUnits:
|
||||
|
||||
assert "/home/test/.nvm/versions/node/v24.14.0/bin" in unit
|
||||
|
||||
def test_user_unit_includes_wsl_windows_interop_paths(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: True)
|
||||
monkeypatch.setenv(
|
||||
"PATH",
|
||||
"/usr/local/bin:/mnt/c/WINDOWS/system32:/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/",
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None)
|
||||
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
|
||||
assert "/mnt/c/WINDOWS/system32" in unit
|
||||
assert "/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/" in unit
|
||||
|
||||
def test_user_unit_omits_windows_interop_paths_outside_wsl(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False)
|
||||
monkeypatch.setenv("PATH", "/usr/local/bin:/mnt/c/WINDOWS/system32")
|
||||
monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None)
|
||||
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
|
||||
assert "/mnt/c/WINDOWS/system32" not in unit
|
||||
|
||||
def test_system_unit_includes_wsl_windows_interop_paths(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_system_service_identity",
|
||||
lambda run_as_user=None: ("alice", "alice", "/home/alice"),
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "_hermes_home_for_target_user", lambda home: "/home/alice/.hermes")
|
||||
monkeypatch.setenv("PATH", "/usr/local/bin:/mnt/c/WINDOWS/system32")
|
||||
monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None)
|
||||
|
||||
unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice")
|
||||
|
||||
assert "/mnt/c/WINDOWS/system32" in unit
|
||||
|
||||
def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self):
|
||||
unit = gateway_cli.generate_systemd_unit(system=True)
|
||||
|
||||
|
||||
@@ -401,6 +401,103 @@ class TestOllamaCloudProvidersNew:
|
||||
assert pdef.transport == "openai_chat"
|
||||
|
||||
|
||||
# ── Cloud Suffix Stripping ──
|
||||
|
||||
class TestOllamaCloudSuffixStripping:
|
||||
"""models.dev appends :cloud / -cloud suffixes that the live API omits.
|
||||
|
||||
fetch_ollama_cloud_models() must normalise these before the dedup merge so
|
||||
users never see broken IDs like 'kimi-k2.6:cloud' in the model picker.
|
||||
"""
|
||||
|
||||
def test_strips_colon_cloud_suffix(self, tmp_path, monkeypatch):
|
||||
""":cloud suffix from models.dev is stripped before merge."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {"kimi-k2.6:cloud": {"tool_call": True}}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert "kimi-k2.6" in result
|
||||
assert "kimi-k2.6:cloud" not in result
|
||||
|
||||
def test_strips_dash_cloud_suffix(self, tmp_path, monkeypatch):
|
||||
"""-cloud suffix from models.dev is stripped before merge."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {"qwen3-coder:480b-cloud": {"tool_call": True}}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert "qwen3-coder:480b" in result
|
||||
assert "qwen3-coder:480b-cloud" not in result
|
||||
|
||||
def test_no_duplicate_when_live_clean_and_mdev_suffixed(self, tmp_path, monkeypatch):
|
||||
"""Live API returns clean ID; mdev has :cloud variant — result has exactly one entry."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {
|
||||
"kimi-k2.6:cloud": {"tool_call": True},
|
||||
"glm-5.1:cloud": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["kimi-k2.6", "glm-5.1"]), \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert result.count("kimi-k2.6") == 1
|
||||
assert result.count("glm-5.1") == 1
|
||||
assert "kimi-k2.6:cloud" not in result
|
||||
assert "glm-5.1:cloud" not in result
|
||||
|
||||
def test_unsuffixed_model_id_unchanged(self, tmp_path, monkeypatch):
|
||||
"""Model IDs without :cloud / -cloud suffix are passed through unchanged."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {"nemotron-3-nano:30b": {"tool_call": True}}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert "nemotron-3-nano:30b" in result
|
||||
|
||||
def test_strip_suffix_helper(self):
|
||||
"""Unit test for the _strip_ollama_cloud_suffix helper."""
|
||||
from hermes_cli.models import _strip_ollama_cloud_suffix
|
||||
|
||||
assert _strip_ollama_cloud_suffix("kimi-k2.6:cloud") == "kimi-k2.6"
|
||||
assert _strip_ollama_cloud_suffix("glm-5.1:cloud") == "glm-5.1"
|
||||
assert _strip_ollama_cloud_suffix("qwen3-coder:480b-cloud") == "qwen3-coder:480b"
|
||||
assert _strip_ollama_cloud_suffix("nemotron-3-nano:30b") == "nemotron-3-nano:30b"
|
||||
assert _strip_ollama_cloud_suffix("") == ""
|
||||
|
||||
|
||||
# ── Auxiliary Model ──
|
||||
|
||||
class TestOllamaCloudAuxiliary:
|
||||
|
||||
Reference in New Issue
Block a user