Merge branch 'main' into feat/dashboard-skill-analytics
This commit is contained in:
@@ -1,17 +1,9 @@
|
||||
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax, AI Gateway)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure dotenv doesn't interfere
|
||||
if "dotenv" not in sys.modules:
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
sys.modules["dotenv"] = fake_dotenv
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
ProviderConfig,
|
||||
@@ -41,6 +33,7 @@ class TestProviderRegistry:
|
||||
("huggingface", "Hugging Face", "api_key"),
|
||||
("zai", "Z.AI / GLM", "api_key"),
|
||||
("xai", "xAI", "api_key"),
|
||||
("nvidia", "NVIDIA NIM", "api_key"),
|
||||
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
||||
("minimax", "MiniMax", "api_key"),
|
||||
("minimax-cn", "MiniMax (China)", "api_key"),
|
||||
@@ -65,6 +58,12 @@ class TestProviderRegistry:
|
||||
assert pconfig.base_url_env_var == "XAI_BASE_URL"
|
||||
assert pconfig.inference_base_url == "https://api.x.ai/v1"
|
||||
|
||||
def test_nvidia_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["nvidia"]
|
||||
assert pconfig.api_key_env_vars == ("NVIDIA_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "NVIDIA_BASE_URL"
|
||||
assert pconfig.inference_base_url == "https://integrate.api.nvidia.com/v1"
|
||||
|
||||
def test_copilot_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["copilot"]
|
||||
assert pconfig.api_key_env_vars == ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
"""Tests for Arcee AI provider support — standard direct API provider."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
if "dotenv" not in sys.modules:
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
sys.modules["dotenv"] = fake_dotenv
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
resolve_provider,
|
||||
|
||||
@@ -57,85 +57,6 @@ def _build_parser():
|
||||
return parser
|
||||
|
||||
|
||||
class TestFlagBeforeSubcommand:
|
||||
"""Flags placed before 'chat' must propagate through."""
|
||||
|
||||
def test_yolo_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--yolo", "chat"])
|
||||
assert getattr(args, "yolo", False) is True
|
||||
|
||||
def test_worktree_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-w", "chat"])
|
||||
assert getattr(args, "worktree", False) is True
|
||||
|
||||
def test_skills_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-s", "myskill", "chat"])
|
||||
assert getattr(args, "skills", None) == ["myskill"]
|
||||
|
||||
def test_pass_session_id_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--pass-session-id", "chat"])
|
||||
assert getattr(args, "pass_session_id", False) is True
|
||||
|
||||
def test_resume_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-r", "abc123", "chat"])
|
||||
assert getattr(args, "resume", None) == "abc123"
|
||||
|
||||
|
||||
class TestFlagAfterSubcommand:
|
||||
"""Flags placed after 'chat' must still work."""
|
||||
|
||||
def test_yolo_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "--yolo"])
|
||||
assert getattr(args, "yolo", False) is True
|
||||
|
||||
def test_worktree_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "-w"])
|
||||
assert getattr(args, "worktree", False) is True
|
||||
|
||||
def test_skills_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "-s", "myskill"])
|
||||
assert getattr(args, "skills", None) == ["myskill"]
|
||||
|
||||
def test_resume_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "-r", "abc123"])
|
||||
assert getattr(args, "resume", None) == "abc123"
|
||||
|
||||
|
||||
class TestNoSubcommandDefaults:
|
||||
"""When no subcommand is given, flags must work and defaults must hold."""
|
||||
|
||||
def test_yolo_no_subcommand(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--yolo"])
|
||||
assert args.yolo is True
|
||||
assert args.command is None
|
||||
|
||||
def test_defaults_no_flags(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args([])
|
||||
assert getattr(args, "yolo", False) is False
|
||||
assert getattr(args, "worktree", False) is False
|
||||
assert getattr(args, "skills", None) is None
|
||||
assert getattr(args, "resume", None) is None
|
||||
|
||||
def test_defaults_chat_no_flags(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat"])
|
||||
# With SUPPRESS, these fall through to parent defaults
|
||||
assert getattr(args, "yolo", False) is False
|
||||
assert getattr(args, "worktree", False) is False
|
||||
assert getattr(args, "skills", None) is None
|
||||
|
||||
|
||||
class TestYoloEnvVar:
|
||||
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
_read_codex_tokens,
|
||||
_save_codex_tokens,
|
||||
_write_codex_cli_tokens,
|
||||
_import_codex_cli_tokens,
|
||||
get_codex_auth_status,
|
||||
get_provider_auth_state,
|
||||
@@ -182,98 +181,6 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):
|
||||
assert data["tokens"]["access_token"] == "hermes-at"
|
||||
|
||||
|
||||
def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch):
|
||||
"""_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens."""
|
||||
codex_home = tmp_path / "codex-cli"
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
_write_codex_cli_tokens("new-access", "new-refresh", last_refresh="2026-04-12T00:00:00Z")
|
||||
|
||||
auth_path = codex_home / "auth.json"
|
||||
assert auth_path.exists()
|
||||
data = json.loads(auth_path.read_text())
|
||||
assert data["tokens"]["access_token"] == "new-access"
|
||||
assert data["tokens"]["refresh_token"] == "new-refresh"
|
||||
assert data["last_refresh"] == "2026-04-12T00:00:00Z"
|
||||
# Verify file permissions are restricted
|
||||
assert (auth_path.stat().st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch):
|
||||
"""_write_codex_cli_tokens preserves extra fields in existing auth.json."""
|
||||
codex_home = tmp_path / "codex-cli"
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
existing = {
|
||||
"tokens": {
|
||||
"access_token": "old-access",
|
||||
"refresh_token": "old-refresh",
|
||||
"extra_field": "preserved",
|
||||
},
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
"custom_key": "keep_me",
|
||||
}
|
||||
(codex_home / "auth.json").write_text(json.dumps(existing))
|
||||
|
||||
_write_codex_cli_tokens("updated-access", "updated-refresh")
|
||||
|
||||
data = json.loads((codex_home / "auth.json").read_text())
|
||||
assert data["tokens"]["access_token"] == "updated-access"
|
||||
assert data["tokens"]["refresh_token"] == "updated-refresh"
|
||||
assert data["tokens"]["extra_field"] == "preserved"
|
||||
assert data["custom_key"] == "keep_me"
|
||||
# last_refresh not updated since we didn't pass it
|
||||
assert data["last_refresh"] == "2026-01-01T00:00:00Z"
|
||||
|
||||
|
||||
def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch):
|
||||
"""_write_codex_cli_tokens creates parent directories if missing."""
|
||||
codex_home = tmp_path / "does" / "not" / "exist"
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
_write_codex_cli_tokens("at", "rt")
|
||||
|
||||
assert (codex_home / "auth.json").exists()
|
||||
data = json.loads((codex_home / "auth.json").read_text())
|
||||
assert data["tokens"]["access_token"] == "at"
|
||||
|
||||
|
||||
def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch):
|
||||
"""After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json."""
|
||||
from hermes_cli.auth import _refresh_codex_auth_tokens
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
codex_home = tmp_path / "codex-cli"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
# Write initial CLI tokens
|
||||
(codex_home / "auth.json").write_text(json.dumps({
|
||||
"tokens": {"access_token": "old-at", "refresh_token": "old-rt"},
|
||||
}))
|
||||
|
||||
# Mock the pure refresh to return new tokens
|
||||
monkeypatch.setattr("hermes_cli.auth.refresh_codex_oauth_pure", lambda *a, **kw: {
|
||||
"access_token": "refreshed-at",
|
||||
"refresh_token": "refreshed-rt",
|
||||
"last_refresh": "2026-04-12T01:00:00Z",
|
||||
})
|
||||
|
||||
_refresh_codex_auth_tokens(
|
||||
{"access_token": "old-at", "refresh_token": "old-rt"},
|
||||
timeout_seconds=10,
|
||||
)
|
||||
|
||||
# Verify CLI file was updated
|
||||
cli_data = json.loads((codex_home / "auth.json").read_text())
|
||||
assert cli_data["tokens"]["access_token"] == "refreshed-at"
|
||||
assert cli_data["tokens"]["refresh_token"] == "refreshed-rt"
|
||||
|
||||
|
||||
def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home)
|
||||
|
||||
@@ -141,13 +141,93 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
|
||||
# Pool has exactly one canonical `device_code` entry — not a duplicate
|
||||
# pair of `manual:device_code` + `device_code` (the latter would be
|
||||
# materialised by _seed_from_singletons on every load_pool).
|
||||
entries = payload["credential_pool"]["nous"]
|
||||
entry = next(item for item in entries if item["source"] == "manual:device_code")
|
||||
assert entry["label"] == "nous@example.com"
|
||||
assert entry["source"] == "manual:device_code"
|
||||
device_code_entries = [
|
||||
item for item in entries if item["source"] == "device_code"
|
||||
]
|
||||
assert len(device_code_entries) == 1, entries
|
||||
assert not any(item["source"] == "manual:device_code" for item in entries)
|
||||
entry = device_code_entries[0]
|
||||
assert entry["source"] == "device_code"
|
||||
assert entry["agent_key"] == "ak-test"
|
||||
assert entry["portal_base_url"] == "https://portal.example.com"
|
||||
|
||||
# `hermes auth add nous` must also populate providers.nous so the
|
||||
# 401-recovery path (resolve_nous_runtime_credentials) can mint a fresh
|
||||
# agent_key when the 24h TTL expires. If this mirror is missing, recovery
|
||||
# raises "Hermes is not logged into Nous Portal" and the agent dies.
|
||||
singleton = payload["providers"]["nous"]
|
||||
assert singleton["access_token"] == token
|
||||
assert singleton["refresh_token"] == "refresh-token"
|
||||
assert singleton["agent_key"] == "ak-test"
|
||||
assert singleton["portal_base_url"] == "https://portal.example.com"
|
||||
assert singleton["inference_base_url"] == "https://inference.example.com/v1"
|
||||
|
||||
|
||||
def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
|
||||
"""`hermes auth add nous --type oauth --label <name>` must preserve the
|
||||
custom label end-to-end — it was silently dropped in the first cut of the
|
||||
persist_nous_credentials helper because `--label` wasn't threaded through.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
token = _jwt_with_email("nous@example.com")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._nous_device_code_login",
|
||||
lambda **kwargs: {
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
"inference_base_url": "https://inference.example.com/v1",
|
||||
"client_id": "hermes-cli",
|
||||
"scope": "inference:mint_agent_key",
|
||||
"token_type": "Bearer",
|
||||
"access_token": token,
|
||||
"refresh_token": "refresh-token",
|
||||
"obtained_at": "2026-03-23T10:00:00+00:00",
|
||||
"expires_at": "2026-03-23T11:00:00+00:00",
|
||||
"expires_in": 3600,
|
||||
"agent_key": "ak-test",
|
||||
"agent_key_id": "ak-id",
|
||||
"agent_key_expires_at": "2026-03-23T10:30:00+00:00",
|
||||
"agent_key_expires_in": 1800,
|
||||
"agent_key_reused": False,
|
||||
"agent_key_obtained_at": "2026-03-23T10:00:10+00:00",
|
||||
"tls": {"insecure": False, "ca_bundle": None},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "nous"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = "my-nous"
|
||||
portal_url = None
|
||||
inference_url = None
|
||||
client_id = None
|
||||
scope = None
|
||||
no_browser = False
|
||||
timeout = None
|
||||
insecure = False
|
||||
ca_bundle = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
|
||||
# Custom label reaches the pool entry …
|
||||
pool_entry = payload["credential_pool"]["nous"][0]
|
||||
assert pool_entry["source"] == "device_code"
|
||||
assert pool_entry["label"] == "my-nous"
|
||||
|
||||
# … and survives in providers.nous so a subsequent load_pool() re-seeds
|
||||
# it without reverting to the auto-derived fingerprint.
|
||||
assert payload["providers"]["nous"]["label"] == "my-nous"
|
||||
|
||||
|
||||
def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
@@ -703,3 +783,231 @@ def test_auth_remove_claude_code_suppresses_reseed(tmp_path, monkeypatch):
|
||||
suppressed = updated.get("suppressed_sources", {})
|
||||
assert "anthropic" in suppressed
|
||||
assert "claude_code" in suppressed["anthropic"]
|
||||
|
||||
|
||||
def test_unsuppress_credential_source_clears_marker(tmp_path, monkeypatch):
|
||||
"""unsuppress_credential_source() removes a previously-set marker."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1})
|
||||
|
||||
from hermes_cli.auth import suppress_credential_source, unsuppress_credential_source, is_source_suppressed
|
||||
|
||||
suppress_credential_source("openai-codex", "device_code")
|
||||
assert is_source_suppressed("openai-codex", "device_code") is True
|
||||
|
||||
cleared = unsuppress_credential_source("openai-codex", "device_code")
|
||||
assert cleared is True
|
||||
assert is_source_suppressed("openai-codex", "device_code") is False
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
# Empty suppressed_sources dict should be cleaned up entirely
|
||||
assert "suppressed_sources" not in payload
|
||||
|
||||
|
||||
def test_unsuppress_credential_source_returns_false_when_absent(tmp_path, monkeypatch):
|
||||
"""unsuppress_credential_source() returns False if no marker exists."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1})
|
||||
|
||||
from hermes_cli.auth import unsuppress_credential_source
|
||||
|
||||
assert unsuppress_credential_source("openai-codex", "device_code") is False
|
||||
assert unsuppress_credential_source("nonexistent", "whatever") is False
|
||||
|
||||
|
||||
def test_unsuppress_credential_source_preserves_other_markers(tmp_path, monkeypatch):
|
||||
"""Clearing one marker must not affect unrelated markers."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1})
|
||||
|
||||
from hermes_cli.auth import (
|
||||
suppress_credential_source,
|
||||
unsuppress_credential_source,
|
||||
is_source_suppressed,
|
||||
)
|
||||
|
||||
suppress_credential_source("openai-codex", "device_code")
|
||||
suppress_credential_source("anthropic", "claude_code")
|
||||
|
||||
assert unsuppress_credential_source("openai-codex", "device_code") is True
|
||||
assert is_source_suppressed("anthropic", "claude_code") is True
|
||||
|
||||
|
||||
def test_auth_remove_codex_device_code_suppresses_reseed(tmp_path, monkeypatch):
|
||||
"""Removing an auto-seeded openai-codex credential must mark the source as suppressed."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, {"device_code"}),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {
|
||||
"access_token": "acc-1",
|
||||
"refresh_token": "ref-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [{
|
||||
"id": "cx1",
|
||||
"label": "codex-auto",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "device_code",
|
||||
"access_token": "acc-1",
|
||||
"refresh_token": "ref-1",
|
||||
}]
|
||||
},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
auth_remove_command(SimpleNamespace(provider="openai-codex", target="1"))
|
||||
|
||||
updated = json.loads((hermes_home / "auth.json").read_text())
|
||||
suppressed = updated.get("suppressed_sources", {})
|
||||
assert "openai-codex" in suppressed
|
||||
assert "device_code" in suppressed["openai-codex"]
|
||||
# Tokens in providers state should also be cleared
|
||||
assert "openai-codex" not in updated.get("providers", {})
|
||||
|
||||
|
||||
def test_auth_remove_codex_manual_source_suppresses_reseed(tmp_path, monkeypatch):
|
||||
"""Removing a manually-added (`manual:device_code`) openai-codex credential must also suppress."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {
|
||||
"access_token": "acc-2",
|
||||
"refresh_token": "ref-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [{
|
||||
"id": "cx2",
|
||||
"label": "manual-codex",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "manual:device_code",
|
||||
"access_token": "acc-2",
|
||||
"refresh_token": "ref-2",
|
||||
}]
|
||||
},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
auth_remove_command(SimpleNamespace(provider="openai-codex", target="1"))
|
||||
|
||||
updated = json.loads((hermes_home / "auth.json").read_text())
|
||||
suppressed = updated.get("suppressed_sources", {})
|
||||
# Critical: manual:device_code source must also trigger the suppression path
|
||||
assert "openai-codex" in suppressed
|
||||
assert "device_code" in suppressed["openai-codex"]
|
||||
assert "openai-codex" not in updated.get("providers", {})
|
||||
|
||||
|
||||
def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
||||
"""Re-linking codex via `hermes auth add openai-codex` must clear any suppression marker."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Pre-existing suppression (simulating a prior `hermes auth remove`)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"openai-codex": ["device_code"]},
|
||||
}))
|
||||
|
||||
token = _jwt_with_email("codex@example.com")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._codex_device_code_login",
|
||||
lambda: {
|
||||
"tokens": {
|
||||
"access_token": token,
|
||||
"refresh_token": "refreshed",
|
||||
},
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "openai-codex"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((hermes_home / "auth.json").read_text())
|
||||
# Suppression marker must be cleared
|
||||
assert "openai-codex" not in payload.get("suppressed_sources", {})
|
||||
# New pool entry must be present
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
assert any(e["source"] == "manual:device_code" for e in entries)
|
||||
|
||||
|
||||
def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
|
||||
"""_seed_from_singletons() for openai-codex must skip auto-import when suppressed."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Suppression marker in place
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"openai-codex": ["device_code"]},
|
||||
}))
|
||||
|
||||
# Make _import_codex_cli_tokens return tokens — these would normally trigger
|
||||
# a re-seed, but suppression must skip it.
|
||||
def _fake_import():
|
||||
return {
|
||||
"access_token": "would-be-reimported",
|
||||
"refresh_token": "would-be-reimported",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._import_codex_cli_tokens", _fake_import)
|
||||
|
||||
from agent.credential_pool import _seed_from_singletons
|
||||
|
||||
entries = []
|
||||
changed, active_sources = _seed_from_singletons("openai-codex", entries)
|
||||
|
||||
# With suppression in place: nothing changes, no entries added, no sources
|
||||
assert changed is False
|
||||
assert entries == []
|
||||
assert active_sources == set()
|
||||
|
||||
# Verify the auth store was NOT modified (no auto-import happened)
|
||||
after = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert "openai-codex" not in after.get("providers", {})
|
||||
|
||||
@@ -27,15 +27,23 @@ class TestResolveVerifyFallback:
|
||||
})
|
||||
assert result is True
|
||||
|
||||
def test_valid_ca_bundle_in_auth_state_is_returned(self, tmp_path):
|
||||
def test_valid_ca_bundle_in_auth_state_is_returned(self, tmp_path, monkeypatch):
|
||||
import ssl
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
ca_file = tmp_path / "ca-bundle.pem"
|
||||
ca_file.write_text("fake cert")
|
||||
|
||||
# Avoid loading actual PEM — just verify the return type
|
||||
mock_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
monkeypatch.setattr(ssl, "create_default_context", lambda **kw: mock_ctx)
|
||||
|
||||
result = _resolve_verify(auth_state={
|
||||
"tls": {"insecure": False, "ca_bundle": str(ca_file)},
|
||||
})
|
||||
assert result == str(ca_file)
|
||||
assert isinstance(result, ssl.SSLContext), (
|
||||
f"Expected ssl.SSLContext but got {type(result).__name__}: {result!r}"
|
||||
)
|
||||
|
||||
def test_missing_ssl_cert_file_env_falls_back(self, monkeypatch):
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
@@ -76,13 +84,21 @@ class TestResolveVerifyFallback:
|
||||
result = _resolve_verify(ca_bundle="/nonexistent/explicit-ca.pem")
|
||||
assert result is True
|
||||
|
||||
def test_explicit_ca_bundle_param_valid_is_returned(self, tmp_path):
|
||||
def test_explicit_ca_bundle_param_valid_is_returned(self, tmp_path, monkeypatch):
|
||||
import ssl
|
||||
from hermes_cli.auth import _resolve_verify
|
||||
|
||||
ca_file = tmp_path / "explicit-ca.pem"
|
||||
ca_file.write_text("fake cert")
|
||||
|
||||
# Avoid loading actual PEM — just verify the return type
|
||||
mock_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
monkeypatch.setattr(ssl, "create_default_context", lambda **kw: mock_ctx)
|
||||
|
||||
result = _resolve_verify(ca_bundle=str(ca_file))
|
||||
assert result == str(ca_file)
|
||||
assert isinstance(result, ssl.SSLContext), (
|
||||
f"Expected ssl.SSLContext but got {type(result).__name__}: {result!r}"
|
||||
)
|
||||
|
||||
|
||||
def _setup_nous_auth(
|
||||
@@ -299,3 +315,415 @@ def test_mint_retry_uses_latest_rotated_refresh_token(tmp_path, monkeypatch):
|
||||
assert creds["api_key"] == "agent-key"
|
||||
assert refresh_calls == ["refresh-old", "refresh-1"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _login_nous: "Skip (keep current)" must preserve prior provider + model
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLoginNousSkipKeepsCurrent:
|
||||
"""When a user runs `hermes model` → Nous Portal → Skip (keep current) after
|
||||
a successful OAuth login, the prior provider and model MUST be preserved.
|
||||
|
||||
Regression: previously, _update_config_for_provider was called
|
||||
unconditionally after login, which flipped model.provider to "nous" while
|
||||
keeping the old model.default (e.g. anthropic/claude-opus-4.6 from
|
||||
OpenRouter), leaving the user with a mismatched provider/model pair.
|
||||
"""
|
||||
|
||||
def _setup_home_with_openrouter(self, tmp_path, monkeypatch):
|
||||
import yaml
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "openrouter",
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
},
|
||||
}, sort_keys=False))
|
||||
|
||||
auth_path = hermes_home / "auth.json"
|
||||
auth_path.write_text(json.dumps({
|
||||
"version": 1,
|
||||
"active_provider": "openrouter",
|
||||
"providers": {"openrouter": {"api_key": "sk-or-fake"}},
|
||||
}))
|
||||
return hermes_home, config_path, auth_path
|
||||
|
||||
def _patch_login_internals(self, monkeypatch, *, prompt_returns):
|
||||
"""Patch OAuth + model-list + prompt so _login_nous doesn't hit network."""
|
||||
import hermes_cli.auth as auth_mod
|
||||
import hermes_cli.models as models_mod
|
||||
import hermes_cli.nous_subscription as ns
|
||||
|
||||
fake_auth_state = {
|
||||
"access_token": "fake-nous-token",
|
||||
"agent_key": "fake-agent-key",
|
||||
"inference_base_url": "https://inference-api.nousresearch.com",
|
||||
"portal_base_url": "https://portal.nousresearch.com",
|
||||
"refresh_token": "fake-refresh",
|
||||
"token_expires_at": 9999999999,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
auth_mod, "_nous_device_code_login",
|
||||
lambda **kwargs: dict(fake_auth_state),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_mod, "_prompt_model_selection",
|
||||
lambda *a, **kw: prompt_returns,
|
||||
)
|
||||
monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda p: {})
|
||||
monkeypatch.setattr(models_mod, "filter_nous_free_models", lambda ids, p: ids)
|
||||
monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
models_mod, "partition_nous_models_by_tier",
|
||||
lambda ids, p, free_tier=False: (ids, []),
|
||||
)
|
||||
monkeypatch.setattr(ns, "prompt_enable_tool_gateway", lambda cfg: None)
|
||||
|
||||
def test_skip_keep_current_preserves_provider_and_model(self, tmp_path, monkeypatch):
|
||||
"""User picks Skip → config.yaml untouched, Nous creds still saved."""
|
||||
import argparse
|
||||
import yaml
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous
|
||||
|
||||
hermes_home, config_path, auth_path = self._setup_home_with_openrouter(
|
||||
tmp_path, monkeypatch,
|
||||
)
|
||||
self._patch_login_internals(monkeypatch, prompt_returns=None)
|
||||
|
||||
args = argparse.Namespace(
|
||||
portal_url=None, inference_url=None, client_id=None, scope=None,
|
||||
no_browser=True, timeout=15.0, ca_bundle=None, insecure=False,
|
||||
)
|
||||
_login_nous(args, PROVIDER_REGISTRY["nous"])
|
||||
|
||||
# config.yaml model section must be unchanged
|
||||
cfg_after = yaml.safe_load(config_path.read_text())
|
||||
assert cfg_after["model"]["provider"] == "openrouter"
|
||||
assert cfg_after["model"]["default"] == "anthropic/claude-opus-4.6"
|
||||
assert "base_url" not in cfg_after["model"]
|
||||
|
||||
# auth.json: active_provider restored to openrouter, but Nous creds saved
|
||||
auth_after = json.loads(auth_path.read_text())
|
||||
assert auth_after["active_provider"] == "openrouter"
|
||||
assert "nous" in auth_after["providers"]
|
||||
assert auth_after["providers"]["nous"]["access_token"] == "fake-nous-token"
|
||||
# Existing openrouter creds still intact
|
||||
assert auth_after["providers"]["openrouter"]["api_key"] == "sk-or-fake"
|
||||
|
||||
def test_picking_model_switches_to_nous(self, tmp_path, monkeypatch):
|
||||
"""User picks a Nous model → provider flips to nous with that model."""
|
||||
import argparse
|
||||
import yaml
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous
|
||||
|
||||
hermes_home, config_path, auth_path = self._setup_home_with_openrouter(
|
||||
tmp_path, monkeypatch,
|
||||
)
|
||||
self._patch_login_internals(
|
||||
monkeypatch, prompt_returns="xiaomi/mimo-v2-pro",
|
||||
)
|
||||
|
||||
args = argparse.Namespace(
|
||||
portal_url=None, inference_url=None, client_id=None, scope=None,
|
||||
no_browser=True, timeout=15.0, ca_bundle=None, insecure=False,
|
||||
)
|
||||
_login_nous(args, PROVIDER_REGISTRY["nous"])
|
||||
|
||||
cfg_after = yaml.safe_load(config_path.read_text())
|
||||
assert cfg_after["model"]["provider"] == "nous"
|
||||
assert cfg_after["model"]["default"] == "xiaomi/mimo-v2-pro"
|
||||
|
||||
auth_after = json.loads(auth_path.read_text())
|
||||
assert auth_after["active_provider"] == "nous"
|
||||
|
||||
def test_skip_with_no_prior_active_provider_clears_it(self, tmp_path, monkeypatch):
|
||||
"""Fresh install (no prior active_provider) → Skip clears active_provider
|
||||
instead of leaving it as nous."""
|
||||
import argparse
|
||||
import yaml
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(yaml.safe_dump({"model": {}}, sort_keys=False))
|
||||
|
||||
# No auth.json yet — simulates first-run before any OAuth
|
||||
self._patch_login_internals(monkeypatch, prompt_returns=None)
|
||||
|
||||
args = argparse.Namespace(
|
||||
portal_url=None, inference_url=None, client_id=None, scope=None,
|
||||
no_browser=True, timeout=15.0, ca_bundle=None, insecure=False,
|
||||
)
|
||||
_login_nous(args, PROVIDER_REGISTRY["nous"])
|
||||
|
||||
auth_path = hermes_home / "auth.json"
|
||||
auth_after = json.loads(auth_path.read_text())
|
||||
# active_provider should NOT be set to "nous" after Skip
|
||||
assert auth_after.get("active_provider") in (None, "")
|
||||
# But Nous creds are still saved
|
||||
assert "nous" in auth_after.get("providers", {})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# persist_nous_credentials: shared helper for CLI + web dashboard login paths
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _full_state_fixture() -> dict:
|
||||
"""Shape of the dict returned by _nous_device_code_login /
|
||||
refresh_nous_oauth_from_state. Used as helper input."""
|
||||
return {
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
"inference_base_url": "https://inference.example.com/v1",
|
||||
"client_id": "hermes-cli",
|
||||
"scope": "inference:mint_agent_key",
|
||||
"token_type": "Bearer",
|
||||
"access_token": "access-tok",
|
||||
"refresh_token": "refresh-tok",
|
||||
"obtained_at": "2026-04-17T22:00:00+00:00",
|
||||
"expires_at": "2026-04-17T22:15:00+00:00",
|
||||
"expires_in": 900,
|
||||
"agent_key": "agent-key-value",
|
||||
"agent_key_id": "ak-id",
|
||||
"agent_key_expires_at": "2026-04-18T22:00:00+00:00",
|
||||
"agent_key_expires_in": 86400,
|
||||
"agent_key_reused": False,
|
||||
"agent_key_obtained_at": "2026-04-17T22:00:10+00:00",
|
||||
"tls": {"insecure": False, "ca_bundle": None},
|
||||
}
|
||||
|
||||
|
||||
def test_persist_nous_credentials_writes_both_pool_and_providers(tmp_path, monkeypatch):
|
||||
"""Helper must populate BOTH credential_pool.nous AND providers.nous.
|
||||
|
||||
Regression guard: before this helper existed, `hermes auth add nous`
|
||||
wrote only the pool. After the Nous agent_key's 24h TTL expired, the
|
||||
401-recovery path in run_agent.py called resolve_nous_runtime_credentials
|
||||
which reads providers.nous, found it empty, raised AuthError, and the
|
||||
agent failed with "Non-retryable client error". Both stores must stay
|
||||
in sync at write time.
|
||||
"""
|
||||
from hermes_cli.auth import persist_nous_credentials, NOUS_DEVICE_CODE_SOURCE
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1, "providers": {},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
entry = persist_nous_credentials(_full_state_fixture())
|
||||
|
||||
assert entry is not None
|
||||
assert entry.provider == "nous"
|
||||
assert entry.source == NOUS_DEVICE_CODE_SOURCE
|
||||
|
||||
payload = json.loads((hermes_home / "auth.json").read_text())
|
||||
|
||||
# providers.nous populated with the full state (new behaviour)
|
||||
singleton = payload["providers"]["nous"]
|
||||
assert singleton["access_token"] == "access-tok"
|
||||
assert singleton["refresh_token"] == "refresh-tok"
|
||||
assert singleton["agent_key"] == "agent-key-value"
|
||||
assert singleton["agent_key_expires_at"] == "2026-04-18T22:00:00+00:00"
|
||||
|
||||
# credential_pool.nous has exactly one canonical device_code entry
|
||||
pool_entries = payload["credential_pool"]["nous"]
|
||||
assert len(pool_entries) == 1, pool_entries
|
||||
pool_entry = pool_entries[0]
|
||||
assert pool_entry["source"] == NOUS_DEVICE_CODE_SOURCE
|
||||
assert pool_entry["agent_key"] == "agent-key-value"
|
||||
assert pool_entry["inference_base_url"] == "https://inference.example.com/v1"
|
||||
|
||||
|
||||
def test_persist_nous_credentials_allows_recovery_from_401(tmp_path, monkeypatch):
|
||||
"""End-to-end: after persisting via the helper, resolve_nous_runtime_credentials
|
||||
must succeed (not raise "Hermes is not logged into Nous Portal").
|
||||
|
||||
This is the exact path that run_agent.py's `_try_refresh_nous_client_credentials`
|
||||
calls after a Nous 401 — before the fix it would raise AuthError because
|
||||
providers.nous was empty.
|
||||
"""
|
||||
from hermes_cli.auth import persist_nous_credentials, resolve_nous_runtime_credentials
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1, "providers": {},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
persist_nous_credentials(_full_state_fixture())
|
||||
|
||||
# Stub the network-touching steps so we don't actually contact the
|
||||
# portal — the point of this test is that state lookup succeeds and
|
||||
# doesn't raise "Hermes is not logged into Nous Portal".
|
||||
def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token):
|
||||
return {
|
||||
"access_token": "access-new",
|
||||
"refresh_token": "refresh-new",
|
||||
"expires_in": 900,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
|
||||
return _mint_payload(api_key="new-agent-key")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token)
|
||||
monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key)
|
||||
|
||||
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=300, force_mint=True)
|
||||
assert creds["api_key"] == "new-agent-key"
|
||||
|
||||
|
||||
def test_persist_nous_credentials_idempotent_no_duplicate_pool_entries(tmp_path, monkeypatch):
|
||||
"""Re-running persist must upsert — not accumulate duplicate device_code rows.
|
||||
|
||||
Regression guard for the review comment on PR #11858: before normalisation,
|
||||
the helper wrote `manual:device_code` while `_seed_from_singletons` wrote
|
||||
`device_code`, so the pool grew a second duplicate entry on every
|
||||
``load_pool()``. The helper now writes providers.nous and lets seeding
|
||||
materialise the pool entry under the canonical ``device_code`` source, so
|
||||
two persists still leave the pool with exactly one row.
|
||||
"""
|
||||
from hermes_cli.auth import persist_nous_credentials, NOUS_DEVICE_CODE_SOURCE
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1, "providers": {},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
first = _full_state_fixture()
|
||||
persist_nous_credentials(first)
|
||||
|
||||
second = _full_state_fixture()
|
||||
second["access_token"] = "access-second"
|
||||
second["agent_key"] = "agent-key-second"
|
||||
persist_nous_credentials(second)
|
||||
|
||||
payload = json.loads((hermes_home / "auth.json").read_text())
|
||||
|
||||
# providers.nous reflects the latest write (singleton semantics)
|
||||
assert payload["providers"]["nous"]["access_token"] == "access-second"
|
||||
assert payload["providers"]["nous"]["agent_key"] == "agent-key-second"
|
||||
|
||||
# credential_pool.nous has exactly one entry, carrying the latest agent_key
|
||||
pool_entries = payload["credential_pool"]["nous"]
|
||||
assert len(pool_entries) == 1, pool_entries
|
||||
assert pool_entries[0]["source"] == NOUS_DEVICE_CODE_SOURCE
|
||||
assert pool_entries[0]["agent_key"] == "agent-key-second"
|
||||
# And no stray `manual:device_code` / `manual:dashboard_device_code` rows
|
||||
assert not any(
|
||||
e["source"].startswith("manual:") for e in pool_entries
|
||||
)
|
||||
|
||||
|
||||
def test_persist_nous_credentials_reloads_pool_after_singleton_write(tmp_path, monkeypatch):
|
||||
"""The entry returned by the helper must come from a fresh ``load_pool`` so
|
||||
callers observe the canonical seeded state, including any legacy entries
|
||||
that ``_seed_from_singletons`` pruned or upserted.
|
||||
"""
|
||||
from hermes_cli.auth import persist_nous_credentials, NOUS_DEVICE_CODE_SOURCE
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1, "providers": {},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
entry = persist_nous_credentials(_full_state_fixture())
|
||||
assert entry is not None
|
||||
assert entry.source == NOUS_DEVICE_CODE_SOURCE
|
||||
# Label derived by _seed_from_singletons via label_from_token; we don't
|
||||
# assert its exact value, just that the helper returned a real entry.
|
||||
assert entry.access_token == "access-tok"
|
||||
assert entry.agent_key == "agent-key-value"
|
||||
|
||||
|
||||
def test_persist_nous_credentials_embeds_custom_label(tmp_path, monkeypatch):
|
||||
"""User-supplied ``--label`` round-trips through providers.nous and the pool.
|
||||
|
||||
Previously `hermes auth add nous --type oauth --label <name>` silently
|
||||
dropped the label because persist_nous_credentials() ignored it and
|
||||
_seed_from_singletons always auto-derived via label_from_token(). The
|
||||
fix stashes the label inside providers.nous so seeding prefers it.
|
||||
"""
|
||||
from hermes_cli.auth import persist_nous_credentials, NOUS_DEVICE_CODE_SOURCE
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1, "providers": {},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
entry = persist_nous_credentials(_full_state_fixture(), label="my-personal")
|
||||
assert entry is not None
|
||||
assert entry.source == NOUS_DEVICE_CODE_SOURCE
|
||||
assert entry.label == "my-personal"
|
||||
|
||||
# providers.nous carries the label so re-seeding on the next load_pool
|
||||
# doesn't overwrite it with the auto-derived fingerprint.
|
||||
payload = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert payload["providers"]["nous"]["label"] == "my-personal"
|
||||
|
||||
|
||||
def test_persist_nous_credentials_custom_label_survives_reseed(tmp_path, monkeypatch):
|
||||
"""Reopening the pool (which re-runs _seed_from_singletons) must keep the
|
||||
user-chosen label instead of clobbering it with label_from_token output.
|
||||
"""
|
||||
from hermes_cli.auth import persist_nous_credentials
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1, "providers": {},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
persist_nous_credentials(_full_state_fixture(), label="work-acct")
|
||||
|
||||
# Second load_pool triggers _seed_from_singletons again. Without the
|
||||
# fix, this call overwrote the label with label_from_token(access_token).
|
||||
pool = load_pool("nous")
|
||||
entries = pool.entries()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].label == "work-acct"
|
||||
|
||||
|
||||
def test_persist_nous_credentials_no_label_uses_auto_derived(tmp_path, monkeypatch):
|
||||
"""When the caller doesn't pass ``label``, the auto-derived fingerprint
|
||||
is used (unchanged default behaviour — regression guard).
|
||||
"""
|
||||
from hermes_cli.auth import persist_nous_credentials
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1, "providers": {},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
entry = persist_nous_credentials(_full_state_fixture())
|
||||
assert entry is not None
|
||||
# label_from_token derives from the access_token; exact value depends on
|
||||
# the fingerprinter but it must not be empty and must not equal an
|
||||
# arbitrary user string we never passed.
|
||||
assert entry.label
|
||||
assert entry.label != "my-personal"
|
||||
|
||||
# No "label" key embedded in providers.nous when the caller didn't supply one.
|
||||
payload = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert "label" not in payload["providers"]["nous"]
|
||||
|
||||
303
tests/hermes_cli/test_aux_config.py
Normal file
303
tests/hermes_cli/test_aux_config.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""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 import (
|
||||
_AUX_TASKS,
|
||||
_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["provider"] == "auto"
|
||||
assert tg["model"] == ""
|
||||
assert tg["timeout"] > 0
|
||||
assert tg["extra_body"] == {}
|
||||
|
||||
|
||||
def test_session_search_defaults_include_extra_body_and_concurrency():
|
||||
ss = DEFAULT_CONFIG["auxiliary"]["session_search"]
|
||||
assert ss["provider"] == "auto"
|
||||
assert ss["model"] == ""
|
||||
assert ss["extra_body"] == {}
|
||||
assert ss["max_concurrency"] == 3
|
||||
|
||||
|
||||
def test_aux_tasks_keys_all_exist_in_default_config():
|
||||
"""Every task the menu offers must be defined in DEFAULT_CONFIG."""
|
||||
aux_keys = {k for k, _name, _desc in _AUX_TASKS}
|
||||
default_keys = set(DEFAULT_CONFIG["auxiliary"].keys())
|
||||
missing = aux_keys - default_keys
|
||||
assert not missing, (
|
||||
f"_AUX_TASKS references tasks not in DEFAULT_CONFIG.auxiliary: {missing}"
|
||||
)
|
||||
|
||||
|
||||
# ── _format_aux_current ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"task_cfg,expected",
|
||||
[
|
||||
({}, "auto"),
|
||||
({"provider": "", "model": ""}, "auto"),
|
||||
({"provider": "auto", "model": ""}, "auto"),
|
||||
({"provider": "auto", "model": "gpt-4o"}, "auto · gpt-4o"),
|
||||
({"provider": "openrouter", "model": ""}, "openrouter"),
|
||||
(
|
||||
{"provider": "openrouter", "model": "google/gemini-2.5-flash"},
|
||||
"openrouter · google/gemini-2.5-flash",
|
||||
),
|
||||
({"provider": "nous", "model": "gemini-3-flash"}, "nous · gemini-3-flash"),
|
||||
(
|
||||
{"provider": "custom", "base_url": "http://localhost:11434/v1", "model": ""},
|
||||
"custom (localhost:11434/v1)",
|
||||
),
|
||||
(
|
||||
{
|
||||
"provider": "custom",
|
||||
"base_url": "http://localhost:11434/v1/",
|
||||
"model": "qwen2.5:32b",
|
||||
},
|
||||
"custom (localhost:11434/v1) · qwen2.5:32b",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_format_aux_current(task_cfg, expected):
|
||||
assert _format_aux_current(task_cfg) == expected
|
||||
|
||||
|
||||
def test_format_aux_current_handles_non_dict():
|
||||
assert _format_aux_current(None) == "auto"
|
||||
assert _format_aux_current("string") == "auto"
|
||||
|
||||
|
||||
# ── _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"] == ""
|
||||
|
||||
|
||||
def test_save_aux_choice_preserves_timeout(tmp_path, monkeypatch):
|
||||
"""Saving must NOT clobber user-tuned timeout values."""
|
||||
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)
|
||||
|
||||
# Default vision timeout is 120
|
||||
cfg_before = load_config()
|
||||
default_timeout = cfg_before["auxiliary"]["vision"]["timeout"]
|
||||
assert default_timeout == 120
|
||||
|
||||
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
|
||||
cfg_after = load_config()
|
||||
assert cfg_after["auxiliary"]["vision"]["timeout"] == default_timeout
|
||||
# download_timeout also preserved for vision
|
||||
assert cfg_after["auxiliary"]["vision"].get("download_timeout") == 30
|
||||
|
||||
|
||||
def test_save_aux_choice_does_not_touch_main_model(tmp_path, monkeypatch):
|
||||
"""Aux config must never mutate model.default / model.provider / model.base_url."""
|
||||
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)
|
||||
|
||||
# Simulate a configured main model
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg["model"] = {
|
||||
"default": "claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"base_url": "",
|
||||
}
|
||||
save_config(cfg)
|
||||
|
||||
_save_aux_choice(
|
||||
"compression", provider="custom",
|
||||
base_url="http://localhost:11434/v1", model="qwen2.5:32b",
|
||||
)
|
||||
|
||||
cfg = load_config()
|
||||
# Main model untouched
|
||||
assert cfg["model"]["default"] == "claude-sonnet-4.6"
|
||||
assert cfg["model"]["provider"] == "anthropic"
|
||||
# Aux saved correctly
|
||||
c = cfg["auxiliary"]["compression"]
|
||||
assert c["provider"] == "custom"
|
||||
assert c["model"] == "qwen2.5:32b"
|
||||
assert c["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
def test_save_aux_choice_creates_missing_task_entry(tmp_path, monkeypatch):
|
||||
"""Saving a task that was wiped from config.yaml should recreate it."""
|
||||
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)
|
||||
|
||||
# Remove vision from config entirely
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("auxiliary", {}).pop("vision", None)
|
||||
save_config(cfg)
|
||||
|
||||
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
|
||||
cfg = load_config()
|
||||
assert cfg["auxiliary"]["vision"]["provider"] == "nous"
|
||||
assert cfg["auxiliary"]["vision"]["model"] == "gemini-3-flash"
|
||||
|
||||
|
||||
# ── _reset_aux_to_auto ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reset_aux_to_auto_clears_routing_preserves_timeouts(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)
|
||||
|
||||
# Configure two tasks non-auto, and bump a timeout
|
||||
_save_aux_choice("vision", provider="openrouter", model="gpt-4o")
|
||||
_save_aux_choice("compression", provider="nous", model="gemini-3-flash")
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg["auxiliary"]["vision"]["timeout"] = 300 # user-tuned
|
||||
save_config(cfg)
|
||||
|
||||
n = _reset_aux_to_auto()
|
||||
assert n == 2 # both changed
|
||||
|
||||
cfg = load_config()
|
||||
for task in ("vision", "compression"):
|
||||
v = cfg["auxiliary"][task]
|
||||
assert v["provider"] == "auto"
|
||||
assert v["model"] == ""
|
||||
assert v["base_url"] == ""
|
||||
assert v["api_key"] == ""
|
||||
# User-tuned timeout survives reset
|
||||
assert cfg["auxiliary"]["vision"]["timeout"] == 300
|
||||
# Default compression timeout preserved
|
||||
assert cfg["auxiliary"]["compression"]["timeout"] == 120
|
||||
|
||||
|
||||
def test_reset_aux_to_auto_idempotent(tmp_path, monkeypatch):
|
||||
"""Second reset on already-auto config returns 0 without errors."""
|
||||
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)
|
||||
|
||||
assert _reset_aux_to_auto() == 0
|
||||
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
|
||||
assert _reset_aux_to_auto() == 1
|
||||
assert _reset_aux_to_auto() == 0
|
||||
|
||||
|
||||
# ── Menu dispatch ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_select_provider_and_model_dispatches_to_aux_menu(tmp_path, monkeypatch):
|
||||
"""Picking 'Configure auxiliary models...' in the provider list calls _aux_config_menu."""
|
||||
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
|
||||
|
||||
called = {"aux": 0, "flow": 0}
|
||||
|
||||
def fake_prompt(choices, *, default=0):
|
||||
# Find the aux-config entry by its label text and return its index
|
||||
for i, label in enumerate(choices):
|
||||
if "Configure auxiliary models" in label:
|
||||
return i
|
||||
raise AssertionError("aux entry not in provider list")
|
||||
|
||||
monkeypatch.setattr(main_mod, "_prompt_provider_choice", fake_prompt)
|
||||
monkeypatch.setattr(main_mod, "_aux_config_menu", lambda: called.__setitem__("aux", called["aux"] + 1))
|
||||
# Guard against any main flow accidentally running
|
||||
monkeypatch.setattr(main_mod, "_model_flow_openrouter",
|
||||
lambda *a, **kw: called.__setitem__("flow", called["flow"] + 1))
|
||||
|
||||
main_mod.select_provider_and_model()
|
||||
|
||||
assert called["aux"] == 1, "aux menu not invoked"
|
||||
assert called["flow"] == 0, "main provider flow should not run"
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
@@ -702,6 +702,34 @@ class TestBackupEdgeCases:
|
||||
# Zip should still be created with the readable files
|
||||
assert out_zip.exists()
|
||||
|
||||
def test_pre1980_timestamp_skipped(self, tmp_path, monkeypatch):
|
||||
"""Backup skips files with pre-1980 timestamps (ZIP limitation)."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("model: test\n")
|
||||
|
||||
# Create a file with epoch timestamp (1970-01-01)
|
||||
old_file = hermes_home / "ancient.txt"
|
||||
old_file.write_text("old data")
|
||||
os.utime(old_file, (0, 0))
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = tmp_path / "out.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
# Zip should still be created with the valid files
|
||||
assert out_zip.exists()
|
||||
with zipfile.ZipFile(out_zip, "r") as zf:
|
||||
names = zf.namelist()
|
||||
assert "config.yaml" in names
|
||||
# The pre-1980 file should be skipped, not crash the backup
|
||||
assert "ancient.txt" not in names
|
||||
|
||||
def test_skips_output_zip_inside_hermes(self, tmp_path, monkeypatch):
|
||||
"""Backup skips its own output zip if it's inside hermes root."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
|
||||
@@ -106,6 +106,43 @@ class TestCmdUpdateBranchFallback:
|
||||
pull_cmds = [c for c in commands if "pull" in c]
|
||||
assert len(pull_cmds) == 0
|
||||
|
||||
@patch("shutil.which")
|
||||
@patch("subprocess.run")
|
||||
def test_update_refreshes_repo_and_tui_node_dependencies(
|
||||
self, mock_run, mock_which, mock_args
|
||||
):
|
||||
mock_which.side_effect = {"uv": "/usr/bin/uv", "npm": "/usr/bin/npm"}.get
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
|
||||
cmd_update(mock_args)
|
||||
|
||||
npm_calls = [
|
||||
(call.args[0], call.kwargs.get("cwd"))
|
||||
for call in mock_run.call_args_list
|
||||
if call.args and call.args[0][0] == "/usr/bin/npm"
|
||||
]
|
||||
|
||||
# cmd_update runs npm commands in three locations:
|
||||
# 1. repo root — slash-command / TUI bridge deps
|
||||
# 2. ui-tui/ — Ink TUI deps
|
||||
# 3. web/ — install + "npm run build" for the web frontend
|
||||
full_flags = [
|
||||
"/usr/bin/npm",
|
||||
"install",
|
||||
"--silent",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--progress=false",
|
||||
]
|
||||
assert npm_calls == [
|
||||
(full_flags, PROJECT_ROOT),
|
||||
(full_flags, PROJECT_ROOT / "ui-tui"),
|
||||
(["/usr/bin/npm", "install", "--silent"], PROJECT_ROOT / "web"),
|
||||
(["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "web"),
|
||||
]
|
||||
|
||||
def test_update_non_interactive_skips_migration_prompt(self, mock_args, capsys):
|
||||
"""When stdin/stdout aren't TTYs, config migration prompt is skipped."""
|
||||
with patch("shutil.which", return_value=None), patch(
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Regression test: openai-codex must appear in /model picker when
|
||||
credentials are only in the Codex CLI shared file (~/.codex/auth.json)
|
||||
and haven't been migrated to the Hermes auth store yet.
|
||||
"""Regression tests for the /model picker's credential-discovery paths.
|
||||
|
||||
Root cause: list_authenticated_providers() checked the raw Hermes auth
|
||||
store but didn't know about the Codex CLI fallback import path.
|
||||
Covers:
|
||||
- Normal path (tokens already in Hermes auth store)
|
||||
- Claude Code fallback (tokens only in ~/.claude/.credentials.json)
|
||||
- Negative case (no credentials anywhere)
|
||||
|
||||
Fix: _seed_from_singletons() now imports from the Codex CLI when the
|
||||
Hermes auth store has no openai-codex tokens, and
|
||||
list_authenticated_providers() falls back to load_pool() for OAuth
|
||||
providers.
|
||||
Note: auto-import from ~/.codex/auth.json was removed in #12360 — Hermes
|
||||
now owns its own openai-codex auth state, and users explicitly adopt
|
||||
existing Codex CLI tokens via `hermes auth openai-codex`. The old
|
||||
"Codex CLI shared file" discovery tests were removed with that change.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -31,83 +31,6 @@ def _make_fake_jwt(expiry_offset: int = 3600) -> str:
|
||||
return f"{header}.{payload}.fakesig"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def codex_cli_only_env(tmp_path, monkeypatch):
|
||||
"""Set up an environment where Codex tokens exist only in ~/.codex/auth.json,
|
||||
NOT in the Hermes auth store."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
codex_home = tmp_path / ".codex"
|
||||
codex_home.mkdir()
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
# Empty Hermes auth store
|
||||
(hermes_home / "auth.json").write_text(
|
||||
json.dumps({"version": 2, "providers": {}})
|
||||
)
|
||||
|
||||
# Valid Codex CLI tokens
|
||||
fake_jwt = _make_fake_jwt()
|
||||
(codex_home / "auth.json").write_text(
|
||||
json.dumps({
|
||||
"tokens": {
|
||||
"access_token": fake_jwt,
|
||||
"refresh_token": "fake-refresh-token",
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
# Clear provider env vars so only OAuth is a detection path
|
||||
for var in [
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN",
|
||||
"GH_TOKEN", "GEMINI_API_KEY",
|
||||
]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
return hermes_home
|
||||
|
||||
|
||||
def test_codex_cli_tokens_detected_by_model_picker(codex_cli_only_env):
|
||||
"""openai-codex should appear when tokens only exist in ~/.codex/auth.json."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openai-codex",
|
||||
max_models=10,
|
||||
)
|
||||
slugs = [p["slug"] for p in providers]
|
||||
assert "openai-codex" in slugs, (
|
||||
f"openai-codex not found in /model picker providers: {slugs}"
|
||||
)
|
||||
|
||||
codex = next(p for p in providers if p["slug"] == "openai-codex")
|
||||
assert codex["is_current"] is True
|
||||
assert codex["total_models"] > 0
|
||||
|
||||
|
||||
def test_codex_cli_tokens_migrated_after_detection(codex_cli_only_env):
|
||||
"""After the /model picker detects Codex CLI tokens, they should be
|
||||
migrated into the Hermes auth store for subsequent fast lookups."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
# First call triggers migration
|
||||
list_authenticated_providers(current_provider="openai-codex")
|
||||
|
||||
# Verify tokens are now in Hermes auth store
|
||||
auth_path = codex_cli_only_env / "auth.json"
|
||||
store = json.loads(auth_path.read_text())
|
||||
providers = store.get("providers", {})
|
||||
assert "openai-codex" in providers, (
|
||||
f"openai-codex not migrated to Hermes auth store: {list(providers.keys())}"
|
||||
)
|
||||
tokens = providers["openai-codex"].get("tokens", {})
|
||||
assert tokens.get("access_token"), "access_token missing after migration"
|
||||
assert tokens.get("refresh_token"), "refresh_token missing after migration"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hermes_auth_only_env(tmp_path, monkeypatch):
|
||||
"""Tokens already in Hermes auth store (no Codex CLI needed)."""
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_get_codex_model_ids_falls_back_to_curated_defaults(tmp_path, monkeypatc
|
||||
|
||||
assert models[: len(DEFAULT_CODEX_MODELS)] == DEFAULT_CODEX_MODELS
|
||||
assert "gpt-5.4" in models
|
||||
assert "gpt-5.3-codex-spark" in models
|
||||
assert "gpt-5.3-codex-spark" not in models
|
||||
|
||||
|
||||
def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypatch):
|
||||
@@ -65,7 +65,7 @@ def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypat
|
||||
|
||||
models = get_codex_model_ids(access_token="codex-access-token")
|
||||
|
||||
assert models == ["gpt-5.2-codex", "gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex", "gpt-5.3-codex-spark"]
|
||||
assert models == ["gpt-5.2-codex", "gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex"]
|
||||
|
||||
|
||||
def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
|
||||
|
||||
@@ -93,15 +93,18 @@ class TestResolveCommand:
|
||||
def test_canonical_name_resolves(self):
|
||||
assert resolve_command("help").name == "help"
|
||||
assert resolve_command("background").name == "background"
|
||||
assert resolve_command("copy").name == "copy"
|
||||
assert resolve_command("agents").name == "agents"
|
||||
|
||||
def test_alias_resolves_to_canonical(self):
|
||||
assert resolve_command("bg").name == "background"
|
||||
assert resolve_command("reset").name == "new"
|
||||
assert resolve_command("q").name == "quit"
|
||||
assert resolve_command("q").name == "queue"
|
||||
assert resolve_command("exit").name == "quit"
|
||||
assert resolve_command("gateway").name == "platforms"
|
||||
assert resolve_command("set-home").name == "sethome"
|
||||
assert resolve_command("reload_mcp").name == "reload-mcp"
|
||||
assert resolve_command("tasks").name == "agents"
|
||||
|
||||
def test_leading_slash_stripped(self):
|
||||
assert resolve_command("/help").name == "help"
|
||||
@@ -685,6 +688,32 @@ class TestTelegramMenuCommands:
|
||||
f"Command '{name}' is {len(name)} chars (limit {_TG_NAME_LIMIT})"
|
||||
)
|
||||
|
||||
def test_includes_plugin_commands_via_lazy_discovery(self, tmp_path, monkeypatch):
|
||||
"""Telegram menu generation should discover plugin slash commands on first access."""
|
||||
from unittest.mock import patch
|
||||
import hermes_cli.plugins as plugins_mod
|
||||
|
||||
plugin_dir = tmp_path / "plugins" / "cmd-plugin"
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
"name: cmd-plugin\nversion: 0.1.0\ndescription: Test plugin\n"
|
||||
)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_command('lcm', lambda args: 'ok', description='LCM status and diagnostics')\n"
|
||||
)
|
||||
# Opt-in: plugins are opt-in by default, so enable in config.yaml
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"plugins:\n enabled:\n - cmd-plugin\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
with patch.object(plugins_mod, "_plugin_manager", None):
|
||||
menu, _ = telegram_menu_commands(max_commands=100)
|
||||
|
||||
menu_names = {name for name, _ in menu}
|
||||
assert "lcm" in menu_names
|
||||
|
||||
def test_excludes_telegram_disabled_skills(self, tmp_path, monkeypatch):
|
||||
"""Skills disabled for telegram should not appear in the menu."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -1028,3 +1057,154 @@ class TestDiscordSkillCommands:
|
||||
assert len(name) <= _CMD_NAME_LIMIT, (
|
||||
f"Name '{name}' is {len(name)} chars (limit {_CMD_NAME_LIMIT})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discord skill commands grouped by category
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from hermes_cli.commands import discord_skill_commands_by_category # noqa: E402
|
||||
|
||||
|
||||
class TestDiscordSkillCommandsByCategory:
|
||||
"""Tests for discord_skill_commands_by_category() — /skill group registration."""
|
||||
|
||||
def test_groups_skills_by_category(self, tmp_path, monkeypatch):
|
||||
"""Skills nested 2+ levels deep should be grouped by top-level category."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
# Create the directory structure so resolve() works
|
||||
for p in [
|
||||
"skills/creative/ascii-art",
|
||||
"skills/creative/excalidraw",
|
||||
"skills/media/gif-search",
|
||||
]:
|
||||
(tmp_path / p).mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / p / "SKILL.md").write_text("---\nname: test\n---\n")
|
||||
|
||||
fake_cmds = {
|
||||
"/ascii-art": {
|
||||
"name": "ascii-art",
|
||||
"description": "Generate ASCII art",
|
||||
"skill_md_path": f"{fake_skills_dir}/creative/ascii-art/SKILL.md",
|
||||
},
|
||||
"/excalidraw": {
|
||||
"name": "excalidraw",
|
||||
"description": "Hand-drawn diagrams",
|
||||
"skill_md_path": f"{fake_skills_dir}/creative/excalidraw/SKILL.md",
|
||||
},
|
||||
"/gif-search": {
|
||||
"name": "gif-search",
|
||||
"description": "Search for GIFs",
|
||||
"skill_md_path": f"{fake_skills_dir}/media/gif-search/SKILL.md",
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=set(),
|
||||
)
|
||||
|
||||
assert "creative" in categories
|
||||
assert "media" in categories
|
||||
assert len(categories["creative"]) == 2
|
||||
assert len(categories["media"]) == 1
|
||||
assert uncategorized == []
|
||||
assert hidden == 0
|
||||
|
||||
def test_root_level_skills_are_uncategorized(self, tmp_path, monkeypatch):
|
||||
"""Skills directly under SKILLS_DIR (only 1 path component) → uncategorized."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
(tmp_path / "skills" / "dogfood").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "skills" / "dogfood" / "SKILL.md").write_text("")
|
||||
|
||||
fake_cmds = {
|
||||
"/dogfood": {
|
||||
"name": "dogfood",
|
||||
"description": "QA testing",
|
||||
"skill_md_path": f"{fake_skills_dir}/dogfood/SKILL.md",
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=set(),
|
||||
)
|
||||
|
||||
assert categories == {}
|
||||
assert len(uncategorized) == 1
|
||||
assert uncategorized[0][0] == "dogfood"
|
||||
|
||||
def test_hub_skills_excluded(self, tmp_path, monkeypatch):
|
||||
"""Skills under .hub should be excluded."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
(tmp_path / "skills" / ".hub" / "some-skill").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "skills" / ".hub" / "some-skill" / "SKILL.md").write_text("")
|
||||
|
||||
fake_cmds = {
|
||||
"/some-skill": {
|
||||
"name": "some-skill",
|
||||
"description": "Hub skill",
|
||||
"skill_md_path": f"{fake_skills_dir}/.hub/some-skill/SKILL.md",
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=set(),
|
||||
)
|
||||
|
||||
assert categories == {}
|
||||
assert uncategorized == []
|
||||
|
||||
def test_deep_nested_skills_use_top_category(self, tmp_path, monkeypatch):
|
||||
"""Skills like mlops/training/axolotl should group under 'mlops'."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
(tmp_path / "skills" / "mlops" / "training" / "axolotl").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "skills" / "mlops" / "training" / "axolotl" / "SKILL.md").write_text("")
|
||||
(tmp_path / "skills" / "mlops" / "inference" / "vllm").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "skills" / "mlops" / "inference" / "vllm" / "SKILL.md").write_text("")
|
||||
|
||||
fake_cmds = {
|
||||
"/axolotl": {
|
||||
"name": "axolotl",
|
||||
"description": "Fine-tuning with Axolotl",
|
||||
"skill_md_path": f"{fake_skills_dir}/mlops/training/axolotl/SKILL.md",
|
||||
},
|
||||
"/vllm": {
|
||||
"name": "vllm",
|
||||
"description": "vLLM inference",
|
||||
"skill_md_path": f"{fake_skills_dir}/mlops/inference/vllm/SKILL.md",
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=set(),
|
||||
)
|
||||
|
||||
# Both should be under 'mlops' regardless of sub-category
|
||||
assert "mlops" in categories
|
||||
names = {n for n, _d, _k in categories["mlops"]}
|
||||
assert "axolotl" in names
|
||||
assert "vllm" in names
|
||||
assert len(uncategorized) == 0
|
||||
|
||||
@@ -459,7 +459,7 @@ class TestCustomProviderCompatibility:
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 17
|
||||
assert raw["_config_version"] == 21
|
||||
assert raw["providers"]["openai-direct"] == {
|
||||
"api": "https://api.openai.com/v1",
|
||||
"api_key": "test-key",
|
||||
@@ -606,6 +606,33 @@ class TestInterimAssistantMessageConfig:
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 17
|
||||
assert raw["_config_version"] == 21
|
||||
assert raw["display"]["tool_progress"] == "off"
|
||||
assert raw["display"]["interim_assistant_messages"] is True
|
||||
|
||||
|
||||
class TestDiscordChannelPromptsConfig:
|
||||
def test_default_config_includes_discord_channel_prompts(self):
|
||||
assert DEFAULT_CONFIG["discord"]["channel_prompts"] == {}
|
||||
|
||||
def test_migrate_adds_discord_channel_prompts_default(self, tmp_path):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
yaml.safe_dump({"_config_version": 17, "discord": {"auto_thread": True}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 21
|
||||
assert raw["discord"]["auto_thread"] is True
|
||||
assert raw["discord"]["channel_prompts"] == {}
|
||||
|
||||
|
||||
class TestUserMessagePreviewConfig:
|
||||
def test_default_config_preview_line_counts(self):
|
||||
preview = DEFAULT_CONFIG["display"]["user_message_preview"]
|
||||
assert preview["first_lines"] == 2
|
||||
assert preview["last_lines"] == 2
|
||||
|
||||
169
tests/hermes_cli/test_config_env_refs.py
Normal file
169
tests/hermes_cli/test_config_env_refs.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import textwrap
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
|
||||
def _write_config(tmp_path, body: str):
|
||||
(tmp_path / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
|
||||
|
||||
|
||||
def _read_config(tmp_path) -> str:
|
||||
return (tmp_path / "config.yaml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_save_config_preserves_env_refs_on_unrelated_change(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("TU_ZI_API_KEY", "sk-realsecret")
|
||||
monkeypatch.setenv("ALT_SECRET", "alt-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: tuzi
|
||||
base_url: https://api.tu-zi.com
|
||||
api_key: ${TU_ZI_API_KEY}
|
||||
headers:
|
||||
Authorization: Bearer ${ALT_SECRET}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["model"]["default"] = "doubao-pro"
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert "api_key: ${TU_ZI_API_KEY}" in saved
|
||||
assert "Authorization: Bearer ${ALT_SECRET}" in saved
|
||||
assert "sk-realsecret" not in saved
|
||||
assert "alt-secret" not in saved
|
||||
|
||||
|
||||
def test_save_config_preserves_unresolved_env_refs(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MISSING_SECRET", raising=False)
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: unresolved
|
||||
api_key: ${MISSING_SECRET}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["display"]["compact"] = True
|
||||
save_config(config)
|
||||
|
||||
assert "api_key: ${MISSING_SECRET}" in _read_config(tmp_path)
|
||||
|
||||
|
||||
def test_save_config_allows_intentional_secret_value_change(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("TU_ZI_API_KEY", "sk-old-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: tuzi
|
||||
api_key: ${TU_ZI_API_KEY}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["custom_providers"][0]["api_key"] = "sk-new-secret"
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert "api_key: sk-new-secret" in saved
|
||||
assert "${TU_ZI_API_KEY}" not in saved
|
||||
|
||||
|
||||
def test_save_config_preserves_template_when_env_rotates_after_load(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("TU_ZI_API_KEY", "sk-old-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: tuzi
|
||||
api_key: ${TU_ZI_API_KEY}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
monkeypatch.setenv("TU_ZI_API_KEY", "sk-rotated-secret")
|
||||
config["model"]["default"] = "doubao-pro"
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert "api_key: ${TU_ZI_API_KEY}" in saved
|
||||
assert "sk-old-secret" not in saved
|
||||
assert "sk-rotated-secret" not in saved
|
||||
|
||||
|
||||
def test_save_config_keeps_edited_partial_template_strings_literal(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("ALT_SECRET", "alt-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: tuzi
|
||||
headers:
|
||||
Authorization: Bearer ${ALT_SECRET}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["custom_providers"][0]["headers"]["Authorization"] = "Token alt-secret"
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert "Authorization: Token alt-secret" in saved
|
||||
assert "Authorization: Bearer ${ALT_SECRET}" not in saved
|
||||
|
||||
|
||||
def test_save_config_falls_back_to_positional_matching_for_duplicate_names(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("FIRST_SECRET", "first-secret")
|
||||
monkeypatch.setenv("SECOND_SECRET", "second-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: duplicate
|
||||
api_key: ${FIRST_SECRET}
|
||||
model: claude-opus-4-6
|
||||
- name: duplicate
|
||||
api_key: ${SECOND_SECRET}
|
||||
model: doubao-pro
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["display"]["compact"] = True
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert saved.count("name: duplicate") == 2
|
||||
assert "api_key: ${FIRST_SECRET}" in saved
|
||||
assert "api_key: ${SECOND_SECRET}" in saved
|
||||
assert "first-secret" not in saved
|
||||
assert "second-secret" not in saved
|
||||
@@ -13,7 +13,7 @@ class TestCustomProvidersValidation:
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": {
|
||||
"name": "Generativelanguage.googleapis.com",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"api_key": "xxx",
|
||||
"model": "models/gemini-2.5-flash",
|
||||
"rate_limit_delay": 2.0,
|
||||
|
||||
@@ -54,12 +54,12 @@ class TestCronCommandLifecycle:
|
||||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=["find-nearby", "blogwatcher"],
|
||||
skills=["maps", "blogwatcher"],
|
||||
clear_skills=False,
|
||||
)
|
||||
)
|
||||
updated = get_job(job["id"])
|
||||
assert updated["skills"] == ["find-nearby", "blogwatcher"]
|
||||
assert updated["skills"] == ["maps", "blogwatcher"]
|
||||
assert updated["name"] == "Edited Job"
|
||||
assert updated["prompt"] == "Revised prompt"
|
||||
assert updated["schedule_display"] == "every 120m"
|
||||
@@ -95,7 +95,7 @@ class TestCronCommandLifecycle:
|
||||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=["blogwatcher", "find-nearby"],
|
||||
skills=["blogwatcher", "maps"],
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
@@ -103,5 +103,5 @@ class TestCronCommandLifecycle:
|
||||
|
||||
jobs = list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["skills"] == ["blogwatcher", "find-nearby"]
|
||||
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
|
||||
assert jobs[0]["name"] == "Skill combo"
|
||||
|
||||
@@ -428,7 +428,9 @@ class TestRunDebug:
|
||||
run_debug(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "hermes debug share" in out
|
||||
assert "hermes debug" in out
|
||||
assert "share" in out
|
||||
assert "delete" in out
|
||||
|
||||
def test_share_subcommand_routes(self, hermes_home):
|
||||
from hermes_cli.debug import run_debug
|
||||
@@ -447,15 +449,417 @@ class TestRunDebug:
|
||||
# Argparse integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestArgparseIntegration:
|
||||
def test_module_imports_clean(self):
|
||||
from hermes_cli.debug import run_debug, run_debug_share
|
||||
assert callable(run_debug)
|
||||
assert callable(run_debug_share)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete / auto-delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cmd_debug_dispatches(self):
|
||||
from hermes_cli.main import cmd_debug
|
||||
class TestExtractPasteId:
|
||||
def test_paste_rs_url(self):
|
||||
from hermes_cli.debug import _extract_paste_id
|
||||
assert _extract_paste_id("https://paste.rs/abc123") == "abc123"
|
||||
|
||||
def test_paste_rs_trailing_slash(self):
|
||||
from hermes_cli.debug import _extract_paste_id
|
||||
assert _extract_paste_id("https://paste.rs/abc123/") == "abc123"
|
||||
|
||||
def test_http_variant(self):
|
||||
from hermes_cli.debug import _extract_paste_id
|
||||
assert _extract_paste_id("http://paste.rs/xyz") == "xyz"
|
||||
|
||||
def test_non_paste_rs_returns_none(self):
|
||||
from hermes_cli.debug import _extract_paste_id
|
||||
assert _extract_paste_id("https://dpaste.com/ABCDEF") is None
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
from hermes_cli.debug import _extract_paste_id
|
||||
assert _extract_paste_id("") is None
|
||||
|
||||
|
||||
class TestDeletePaste:
|
||||
def test_delete_sends_delete_request(self):
|
||||
from hermes_cli.debug import delete_paste
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("hermes_cli.debug.urllib.request.urlopen",
|
||||
return_value=mock_resp) as mock_open:
|
||||
result = delete_paste("https://paste.rs/abc123")
|
||||
|
||||
assert result is True
|
||||
req = mock_open.call_args[0][0]
|
||||
assert req.method == "DELETE"
|
||||
assert "paste.rs/abc123" in req.full_url
|
||||
|
||||
def test_delete_rejects_non_paste_rs(self):
|
||||
from hermes_cli.debug import delete_paste
|
||||
|
||||
with pytest.raises(ValueError, match="only paste.rs"):
|
||||
delete_paste("https://dpaste.com/something")
|
||||
|
||||
|
||||
class TestScheduleAutoDelete:
|
||||
"""``_schedule_auto_delete`` used to spawn a detached Python subprocess
|
||||
per call (one per paste URL batch). Those subprocesses slept 6 hours
|
||||
and accumulated forever under repeated use — 15+ orphaned interpreters
|
||||
were observed in production.
|
||||
|
||||
The new implementation is stateless: it records pending deletions to
|
||||
``~/.hermes/pastes/pending.json`` and lets ``_sweep_expired_pastes``
|
||||
handle the DELETE requests synchronously on the next ``hermes debug``
|
||||
invocation.
|
||||
"""
|
||||
|
||||
def test_does_not_spawn_subprocess(self, hermes_home):
|
||||
"""Regression guard: _schedule_auto_delete must NEVER spawn subprocesses.
|
||||
|
||||
We assert this structurally rather than by mocking Popen: the new
|
||||
implementation doesn't even import ``subprocess`` at module scope,
|
||||
so a mock patch wouldn't find it.
|
||||
"""
|
||||
import ast
|
||||
import inspect
|
||||
from hermes_cli.debug import _schedule_auto_delete
|
||||
|
||||
# Strip the docstring before scanning so the regression-rationale
|
||||
# prose inside it doesn't trigger our banned-word checks.
|
||||
source = inspect.getsource(_schedule_auto_delete)
|
||||
tree = ast.parse(source)
|
||||
func_node = tree.body[0]
|
||||
if (
|
||||
func_node.body
|
||||
and isinstance(func_node.body[0], ast.Expr)
|
||||
and isinstance(func_node.body[0].value, ast.Constant)
|
||||
and isinstance(func_node.body[0].value.value, str)
|
||||
):
|
||||
func_node.body = func_node.body[1:]
|
||||
code_only = ast.unparse(func_node)
|
||||
|
||||
assert "Popen" not in code_only, (
|
||||
"_schedule_auto_delete must not spawn subprocesses — "
|
||||
"use pending.json + _sweep_expired_pastes instead"
|
||||
)
|
||||
assert "subprocess" not in code_only, (
|
||||
"_schedule_auto_delete must not reference subprocess at all"
|
||||
)
|
||||
assert "time.sleep" not in code_only, (
|
||||
"Regression: sleeping in _schedule_auto_delete is the bug being fixed"
|
||||
)
|
||||
|
||||
# And verify that calling it doesn't produce any orphaned children
|
||||
# (it should just write pending.json synchronously).
|
||||
import os as _os
|
||||
before = set(_os.listdir("/proc")) if _os.path.exists("/proc") else None
|
||||
_schedule_auto_delete(
|
||||
["https://paste.rs/abc", "https://paste.rs/def"],
|
||||
delay_seconds=10,
|
||||
)
|
||||
if before is not None:
|
||||
after = set(_os.listdir("/proc"))
|
||||
new = after - before
|
||||
# Filter to only integer-named entries (process PIDs)
|
||||
new_pids = [p for p in new if p.isdigit()]
|
||||
# It's fine if unrelated processes appeared — we just need to make
|
||||
# sure we didn't spawn a long-sleeping one. The old bug spawned
|
||||
# a python interpreter whose cmdline contained "time.sleep".
|
||||
for pid in new_pids:
|
||||
try:
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
cmdline = f.read().decode("utf-8", errors="replace")
|
||||
assert "time.sleep" not in cmdline, (
|
||||
f"Leaked sleeper subprocess PID {pid}: {cmdline}"
|
||||
)
|
||||
except OSError:
|
||||
pass # process exited already
|
||||
|
||||
def test_records_pending_to_json(self, hermes_home):
|
||||
"""Scheduled URLs are persisted to pending.json with expiration."""
|
||||
from hermes_cli.debug import _schedule_auto_delete, _pending_file
|
||||
import json
|
||||
|
||||
_schedule_auto_delete(
|
||||
["https://paste.rs/abc", "https://paste.rs/def"],
|
||||
delay_seconds=10,
|
||||
)
|
||||
|
||||
pending_path = _pending_file()
|
||||
assert pending_path.exists()
|
||||
|
||||
entries = json.loads(pending_path.read_text())
|
||||
assert len(entries) == 2
|
||||
urls = {e["url"] for e in entries}
|
||||
assert urls == {"https://paste.rs/abc", "https://paste.rs/def"}
|
||||
|
||||
# expire_at is ~now + delay_seconds
|
||||
import time
|
||||
for e in entries:
|
||||
assert e["expire_at"] > time.time()
|
||||
assert e["expire_at"] <= time.time() + 15
|
||||
|
||||
def test_skips_non_paste_rs_urls(self, hermes_home):
|
||||
"""dpaste.com URLs auto-expire — don't track them."""
|
||||
from hermes_cli.debug import _schedule_auto_delete, _pending_file
|
||||
|
||||
_schedule_auto_delete(["https://dpaste.com/something"])
|
||||
|
||||
# pending.json should not be created for non-paste.rs URLs
|
||||
assert not _pending_file().exists()
|
||||
|
||||
def test_merges_with_existing_pending(self, hermes_home):
|
||||
"""Subsequent calls merge into existing pending.json."""
|
||||
from hermes_cli.debug import _schedule_auto_delete, _load_pending
|
||||
|
||||
_schedule_auto_delete(["https://paste.rs/first"], delay_seconds=10)
|
||||
_schedule_auto_delete(["https://paste.rs/second"], delay_seconds=10)
|
||||
|
||||
entries = _load_pending()
|
||||
urls = {e["url"] for e in entries}
|
||||
assert urls == {"https://paste.rs/first", "https://paste.rs/second"}
|
||||
|
||||
def test_dedupes_same_url(self, hermes_home):
|
||||
"""Same URL recorded twice → one entry with the later expire_at."""
|
||||
from hermes_cli.debug import _schedule_auto_delete, _load_pending
|
||||
|
||||
_schedule_auto_delete(["https://paste.rs/dup"], delay_seconds=10)
|
||||
_schedule_auto_delete(["https://paste.rs/dup"], delay_seconds=100)
|
||||
|
||||
entries = _load_pending()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["url"] == "https://paste.rs/dup"
|
||||
|
||||
|
||||
class TestSweepExpiredPastes:
|
||||
"""Test the opportunistic sweep that replaces the sleeping subprocess."""
|
||||
|
||||
def test_sweep_empty_is_noop(self, hermes_home):
|
||||
from hermes_cli.debug import _sweep_expired_pastes
|
||||
|
||||
deleted, remaining = _sweep_expired_pastes()
|
||||
assert deleted == 0
|
||||
assert remaining == 0
|
||||
|
||||
def test_sweep_deletes_expired_entries(self, hermes_home):
|
||||
from hermes_cli.debug import (
|
||||
_sweep_expired_pastes,
|
||||
_save_pending,
|
||||
_load_pending,
|
||||
)
|
||||
import time
|
||||
|
||||
# Seed pending.json with one expired + one future entry
|
||||
_save_pending([
|
||||
{"url": "https://paste.rs/expired", "expire_at": time.time() - 100},
|
||||
{"url": "https://paste.rs/future", "expire_at": time.time() + 3600},
|
||||
])
|
||||
|
||||
delete_calls = []
|
||||
|
||||
def fake_delete(url):
|
||||
delete_calls.append(url)
|
||||
return True
|
||||
|
||||
with patch("hermes_cli.debug.delete_paste", side_effect=fake_delete):
|
||||
deleted, remaining = _sweep_expired_pastes()
|
||||
|
||||
assert delete_calls == ["https://paste.rs/expired"]
|
||||
assert deleted == 1
|
||||
assert remaining == 1
|
||||
|
||||
entries = _load_pending()
|
||||
urls = {e["url"] for e in entries}
|
||||
assert urls == {"https://paste.rs/future"}
|
||||
|
||||
def test_sweep_leaves_future_entries_alone(self, hermes_home):
|
||||
from hermes_cli.debug import _sweep_expired_pastes, _save_pending
|
||||
import time
|
||||
|
||||
_save_pending([
|
||||
{"url": "https://paste.rs/future1", "expire_at": time.time() + 3600},
|
||||
{"url": "https://paste.rs/future2", "expire_at": time.time() + 7200},
|
||||
])
|
||||
|
||||
with patch("hermes_cli.debug.delete_paste") as mock_delete:
|
||||
deleted, remaining = _sweep_expired_pastes()
|
||||
|
||||
mock_delete.assert_not_called()
|
||||
assert deleted == 0
|
||||
assert remaining == 2
|
||||
|
||||
def test_sweep_survives_network_failure(self, hermes_home):
|
||||
"""Failed DELETEs stay in pending.json until the 24h grace window."""
|
||||
from hermes_cli.debug import (
|
||||
_sweep_expired_pastes,
|
||||
_save_pending,
|
||||
_load_pending,
|
||||
)
|
||||
import time
|
||||
|
||||
_save_pending([
|
||||
{"url": "https://paste.rs/flaky", "expire_at": time.time() - 100},
|
||||
])
|
||||
|
||||
with patch(
|
||||
"hermes_cli.debug.delete_paste",
|
||||
side_effect=Exception("network down"),
|
||||
):
|
||||
deleted, remaining = _sweep_expired_pastes()
|
||||
|
||||
# Failure within 24h grace → kept for retry
|
||||
assert deleted == 0
|
||||
assert remaining == 1
|
||||
assert len(_load_pending()) == 1
|
||||
|
||||
def test_sweep_drops_entries_past_grace_window(self, hermes_home):
|
||||
"""After 24h past expiration, give up even on network failures."""
|
||||
from hermes_cli.debug import (
|
||||
_sweep_expired_pastes,
|
||||
_save_pending,
|
||||
_load_pending,
|
||||
)
|
||||
import time
|
||||
|
||||
# Expired 25 hours ago → past the 24h grace window
|
||||
very_old = time.time() - (25 * 3600)
|
||||
_save_pending([
|
||||
{"url": "https://paste.rs/ancient", "expire_at": very_old},
|
||||
])
|
||||
|
||||
with patch(
|
||||
"hermes_cli.debug.delete_paste",
|
||||
side_effect=Exception("network down"),
|
||||
):
|
||||
deleted, remaining = _sweep_expired_pastes()
|
||||
|
||||
assert deleted == 1
|
||||
assert remaining == 0
|
||||
assert _load_pending() == []
|
||||
|
||||
|
||||
class TestRunDebugSweepsOnInvocation:
|
||||
"""``run_debug`` must sweep expired pastes on every invocation."""
|
||||
|
||||
def test_run_debug_calls_sweep(self, hermes_home):
|
||||
from hermes_cli.debug import run_debug
|
||||
|
||||
args = MagicMock()
|
||||
args.debug_command = None # default → prints help
|
||||
|
||||
with patch("hermes_cli.debug._sweep_expired_pastes") as mock_sweep:
|
||||
run_debug(args)
|
||||
|
||||
mock_sweep.assert_called_once()
|
||||
|
||||
def test_run_debug_survives_sweep_failure(self, hermes_home, capsys):
|
||||
"""If the sweep throws, the subcommand still runs."""
|
||||
from hermes_cli.debug import run_debug
|
||||
|
||||
args = MagicMock()
|
||||
args.debug_command = None
|
||||
cmd_debug(args)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.debug._sweep_expired_pastes",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
run_debug(args) # must not raise
|
||||
|
||||
# Default subcommand still printed help
|
||||
out = capsys.readouterr().out
|
||||
assert "Usage: hermes debug" in out
|
||||
|
||||
|
||||
class TestRunDebugDelete:
|
||||
def test_deletes_valid_url(self, capsys):
|
||||
from hermes_cli.debug import run_debug_delete
|
||||
|
||||
args = MagicMock()
|
||||
args.urls = ["https://paste.rs/abc"]
|
||||
|
||||
with patch("hermes_cli.debug.delete_paste", return_value=True):
|
||||
run_debug_delete(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Deleted" in out
|
||||
assert "paste.rs/abc" in out
|
||||
|
||||
def test_handles_delete_failure(self, capsys):
|
||||
from hermes_cli.debug import run_debug_delete
|
||||
|
||||
args = MagicMock()
|
||||
args.urls = ["https://paste.rs/abc"]
|
||||
|
||||
with patch("hermes_cli.debug.delete_paste",
|
||||
side_effect=Exception("network error")):
|
||||
run_debug_delete(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Could not delete" in out
|
||||
|
||||
def test_no_urls_shows_usage(self, capsys):
|
||||
from hermes_cli.debug import run_debug_delete
|
||||
|
||||
args = MagicMock()
|
||||
args.urls = []
|
||||
|
||||
run_debug_delete(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Usage" in out
|
||||
|
||||
|
||||
class TestShareIncludesAutoDelete:
|
||||
"""Verify that run_debug_share schedules auto-deletion and prints TTL."""
|
||||
|
||||
def test_share_schedules_auto_delete(self, hermes_home, capsys):
|
||||
from hermes_cli.debug import run_debug_share
|
||||
|
||||
args = MagicMock()
|
||||
args.lines = 50
|
||||
args.expire = 7
|
||||
args.local = False
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), \
|
||||
patch("hermes_cli.debug.upload_to_pastebin",
|
||||
return_value="https://paste.rs/test1"), \
|
||||
patch("hermes_cli.debug._schedule_auto_delete") as mock_sched:
|
||||
run_debug_share(args)
|
||||
|
||||
# auto-delete was scheduled with the uploaded URLs
|
||||
mock_sched.assert_called_once()
|
||||
urls_arg = mock_sched.call_args[0][0]
|
||||
assert "https://paste.rs/test1" in urls_arg
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "auto-delete" in out
|
||||
|
||||
def test_share_shows_privacy_notice(self, hermes_home, capsys):
|
||||
from hermes_cli.debug import run_debug_share
|
||||
|
||||
args = MagicMock()
|
||||
args.lines = 50
|
||||
args.expire = 7
|
||||
args.local = False
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), \
|
||||
patch("hermes_cli.debug.upload_to_pastebin",
|
||||
return_value="https://paste.rs/test"), \
|
||||
patch("hermes_cli.debug._schedule_auto_delete"):
|
||||
run_debug_share(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "public paste service" in out
|
||||
|
||||
def test_local_no_privacy_notice(self, hermes_home, capsys):
|
||||
from hermes_cli.debug import run_debug_share
|
||||
|
||||
args = MagicMock()
|
||||
args.lines = 50
|
||||
args.expire = 7
|
||||
args.local = True
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"):
|
||||
run_debug_share(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "public paste service" not in out
|
||||
|
||||
64
tests/hermes_cli/test_deprecated_cwd_warning.py
Normal file
64
tests/hermes_cli/test_deprecated_cwd_warning.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests for warn_deprecated_cwd_env_vars() migration warning."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDeprecatedCwdWarning:
|
||||
"""Warn when MESSAGING_CWD or TERMINAL_CWD is set in .env."""
|
||||
|
||||
def test_messaging_cwd_triggers_warning(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("MESSAGING_CWD", "/some/path")
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
warn_deprecated_cwd_env_vars(config={})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "MESSAGING_CWD" in captured.err
|
||||
assert "deprecated" in captured.err.lower()
|
||||
assert "config.yaml" in captured.err
|
||||
|
||||
def test_terminal_cwd_triggers_warning_when_config_placeholder(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/project")
|
||||
monkeypatch.delenv("MESSAGING_CWD", raising=False)
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
# config has placeholder cwd → TERMINAL_CWD likely from .env
|
||||
warn_deprecated_cwd_env_vars(config={"terminal": {"cwd": "."}})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "TERMINAL_CWD" in captured.err
|
||||
assert "deprecated" in captured.err.lower()
|
||||
|
||||
def test_no_warning_when_config_has_explicit_cwd(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/project")
|
||||
monkeypatch.delenv("MESSAGING_CWD", raising=False)
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
# config has explicit cwd → TERMINAL_CWD could be from config bridge
|
||||
warn_deprecated_cwd_env_vars(config={"terminal": {"cwd": "/project"}})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "TERMINAL_CWD" not in captured.err
|
||||
|
||||
def test_no_warning_when_env_clean(self, monkeypatch, capsys):
|
||||
monkeypatch.delenv("MESSAGING_CWD", raising=False)
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
warn_deprecated_cwd_env_vars(config={})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == ""
|
||||
|
||||
def test_both_deprecated_vars_warn(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("MESSAGING_CWD", "/msg/path")
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/term/path")
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
warn_deprecated_cwd_env_vars(config={})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "MESSAGING_CWD" in captured.err
|
||||
assert "TERMINAL_CWD" in captured.err
|
||||
70
tests/hermes_cli/test_detect_api_mode_for_url.py
Normal file
70
tests/hermes_cli/test_detect_api_mode_for_url.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for hermes_cli.runtime_provider._detect_api_mode_for_url.
|
||||
|
||||
The helper maps base URLs to api_modes for three cases:
|
||||
* api.openai.com → codex_responses
|
||||
* api.x.ai → codex_responses
|
||||
* */anthropic → anthropic_messages (third-party gateways like MiniMax,
|
||||
Zhipu GLM, LiteLLM proxies)
|
||||
|
||||
Consolidating the /anthropic detection in this helper (instead of three
|
||||
inline ``endswith`` checks spread across _resolve_runtime_from_pool_entry,
|
||||
the explicit-provider path, and the api-key-provider path) means every
|
||||
future update to the detection logic lives in one place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli.runtime_provider import _detect_api_mode_for_url
|
||||
|
||||
|
||||
class TestCodexResponsesDetection:
|
||||
def test_openai_api_returns_codex_responses(self):
|
||||
assert _detect_api_mode_for_url("https://api.openai.com/v1") == "codex_responses"
|
||||
|
||||
def test_xai_api_returns_codex_responses(self):
|
||||
assert _detect_api_mode_for_url("https://api.x.ai/v1") == "codex_responses"
|
||||
|
||||
def test_openrouter_is_not_codex_responses(self):
|
||||
# api.openai.com check must exclude openrouter (which routes to openai-hosted models).
|
||||
assert _detect_api_mode_for_url("https://openrouter.ai/api/v1") is None
|
||||
|
||||
|
||||
class TestAnthropicMessagesDetection:
|
||||
"""Third-party gateways that speak the Anthropic protocol under /anthropic."""
|
||||
|
||||
def test_minimax_anthropic_endpoint(self):
|
||||
assert _detect_api_mode_for_url("https://api.minimax.io/anthropic") == "anthropic_messages"
|
||||
|
||||
def test_minimax_cn_anthropic_endpoint(self):
|
||||
assert _detect_api_mode_for_url("https://api.minimaxi.com/anthropic") == "anthropic_messages"
|
||||
|
||||
def test_dashscope_anthropic_endpoint(self):
|
||||
assert (
|
||||
_detect_api_mode_for_url("https://dashscope.aliyuncs.com/api/v2/apps/anthropic")
|
||||
== "anthropic_messages"
|
||||
)
|
||||
|
||||
def test_trailing_slash_tolerated(self):
|
||||
assert _detect_api_mode_for_url("https://api.minimax.io/anthropic/") == "anthropic_messages"
|
||||
|
||||
def test_uppercase_path_tolerated(self):
|
||||
assert _detect_api_mode_for_url("https://API.MINIMAX.IO/Anthropic") == "anthropic_messages"
|
||||
|
||||
def test_anthropic_in_middle_of_path_does_not_match(self):
|
||||
# The helper requires ``/anthropic`` as the path SUFFIX, not anywhere.
|
||||
# Protects against false positives on e.g. /anthropic/v1/models.
|
||||
assert _detect_api_mode_for_url("https://api.example.com/anthropic/v1") is None
|
||||
|
||||
|
||||
class TestDefaultCase:
|
||||
def test_generic_url_returns_none(self):
|
||||
assert _detect_api_mode_for_url("https://api.together.xyz/v1") is None
|
||||
|
||||
def test_empty_string_returns_none(self):
|
||||
assert _detect_api_mode_for_url("") is None
|
||||
|
||||
def test_none_returns_none(self):
|
||||
assert _detect_api_mode_for_url(None) is None
|
||||
|
||||
def test_localhost_returns_none(self):
|
||||
assert _detect_api_mode_for_url("http://localhost:11434/v1") is None
|
||||
217
tests/hermes_cli/test_dingtalk_auth.py
Normal file
217
tests/hermes_cli/test_dingtalk_auth.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Unit tests for hermes_cli/dingtalk_auth.py (QR device-flow registration)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API layer — _api_post + error mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApiPost:
|
||||
|
||||
def test_raises_on_network_error(self):
|
||||
import requests
|
||||
from hermes_cli.dingtalk_auth import _api_post, RegistrationError
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth.requests.post",
|
||||
side_effect=requests.ConnectionError("nope")):
|
||||
with pytest.raises(RegistrationError, match="Network error"):
|
||||
_api_post("/app/registration/init", {"source": "hermes"})
|
||||
|
||||
def test_raises_on_nonzero_errcode(self):
|
||||
from hermes_cli.dingtalk_auth import _api_post, RegistrationError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"errcode": 42, "errmsg": "boom"}
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
|
||||
with pytest.raises(RegistrationError, match=r"boom \(errcode=42\)"):
|
||||
_api_post("/app/registration/init", {"source": "hermes"})
|
||||
|
||||
def test_returns_data_on_success(self):
|
||||
from hermes_cli.dingtalk_auth import _api_post
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"errcode": 0, "nonce": "abc"}
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
|
||||
result = _api_post("/app/registration/init", {"source": "hermes"})
|
||||
assert result["nonce"] == "abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# begin_registration — 2-step nonce → device_code chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBeginRegistration:
|
||||
|
||||
def test_chains_init_then_begin(self):
|
||||
from hermes_cli.dingtalk_auth import begin_registration
|
||||
|
||||
responses = [
|
||||
{"errcode": 0, "nonce": "nonce123"},
|
||||
{
|
||||
"errcode": 0,
|
||||
"device_code": "dev-xyz",
|
||||
"verification_uri_complete": "https://open-dev.dingtalk.com/openapp/registration/openClaw?user_code=ABCD",
|
||||
"expires_in": 7200,
|
||||
"interval": 2,
|
||||
},
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
|
||||
result = begin_registration()
|
||||
|
||||
assert result["device_code"] == "dev-xyz"
|
||||
assert "verification_uri_complete" in result
|
||||
assert result["interval"] == 2
|
||||
assert result["expires_in"] == 7200
|
||||
|
||||
def test_missing_nonce_raises(self):
|
||||
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth._api_post",
|
||||
return_value={"errcode": 0, "nonce": ""}):
|
||||
with pytest.raises(RegistrationError, match="missing nonce"):
|
||||
begin_registration()
|
||||
|
||||
def test_missing_device_code_raises(self):
|
||||
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
|
||||
|
||||
responses = [
|
||||
{"errcode": 0, "nonce": "n1"},
|
||||
{"errcode": 0, "verification_uri_complete": "http://x"}, # no device_code
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
|
||||
with pytest.raises(RegistrationError, match="missing device_code"):
|
||||
begin_registration()
|
||||
|
||||
def test_missing_verification_uri_raises(self):
|
||||
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
|
||||
|
||||
responses = [
|
||||
{"errcode": 0, "nonce": "n1"},
|
||||
{"errcode": 0, "device_code": "dev"}, # no verification_uri_complete
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
|
||||
with pytest.raises(RegistrationError,
|
||||
match="missing verification_uri_complete"):
|
||||
begin_registration()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wait_for_registration_success — polling loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWaitForSuccess:
|
||||
|
||||
def test_returns_credentials_on_success(self):
|
||||
from hermes_cli.dingtalk_auth import wait_for_registration_success
|
||||
|
||||
responses = [
|
||||
{"status": "WAITING"},
|
||||
{"status": "WAITING"},
|
||||
{"status": "SUCCESS", "client_id": "cid-1", "client_secret": "sec-1"},
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
|
||||
patch("hermes_cli.dingtalk_auth.time.sleep"):
|
||||
cid, secret = wait_for_registration_success(
|
||||
device_code="dev", interval=0, expires_in=60
|
||||
)
|
||||
assert cid == "cid-1"
|
||||
assert secret == "sec-1"
|
||||
|
||||
def test_success_without_credentials_raises(self):
|
||||
from hermes_cli.dingtalk_auth import wait_for_registration_success, RegistrationError
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth.poll_registration",
|
||||
return_value={"status": "SUCCESS", "client_id": "", "client_secret": ""}), \
|
||||
patch("hermes_cli.dingtalk_auth.time.sleep"):
|
||||
with pytest.raises(RegistrationError, match="credentials are missing"):
|
||||
wait_for_registration_success(
|
||||
device_code="dev", interval=0, expires_in=60
|
||||
)
|
||||
|
||||
def test_invokes_waiting_callback(self):
|
||||
from hermes_cli.dingtalk_auth import wait_for_registration_success
|
||||
|
||||
callback = MagicMock()
|
||||
responses = [
|
||||
{"status": "WAITING"},
|
||||
{"status": "WAITING"},
|
||||
{"status": "SUCCESS", "client_id": "cid", "client_secret": "sec"},
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
|
||||
patch("hermes_cli.dingtalk_auth.time.sleep"):
|
||||
wait_for_registration_success(
|
||||
device_code="dev", interval=0, expires_in=60, on_waiting=callback
|
||||
)
|
||||
assert callback.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR rendering — terminal output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderQR:
|
||||
|
||||
def test_returns_false_when_qrcode_missing(self, monkeypatch):
|
||||
from hermes_cli import dingtalk_auth
|
||||
|
||||
# Simulate qrcode import failure
|
||||
monkeypatch.setitem(sys.modules, "qrcode", None)
|
||||
assert dingtalk_auth.render_qr_to_terminal("https://example.com") is False
|
||||
|
||||
def test_prints_when_qrcode_available(self, capsys):
|
||||
"""End-to-end: render a real QR and verify SOMETHING got printed."""
|
||||
try:
|
||||
import qrcode # noqa: F401
|
||||
except ImportError:
|
||||
pytest.skip("qrcode library not available")
|
||||
|
||||
from hermes_cli.dingtalk_auth import render_qr_to_terminal
|
||||
result = render_qr_to_terminal("https://example.com/test")
|
||||
captured = capsys.readouterr()
|
||||
assert result is True
|
||||
assert len(captured.out) > 100 # rendered matrix is non-trivial
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration — env var overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigOverrides:
|
||||
|
||||
def test_base_url_default(self, monkeypatch):
|
||||
monkeypatch.delenv("DINGTALK_REGISTRATION_BASE_URL", raising=False)
|
||||
# Force module reload to pick up current env
|
||||
import importlib
|
||||
import hermes_cli.dingtalk_auth as mod
|
||||
importlib.reload(mod)
|
||||
assert mod.REGISTRATION_BASE_URL == "https://oapi.dingtalk.com"
|
||||
|
||||
def test_base_url_override_via_env(self, monkeypatch):
|
||||
monkeypatch.setenv("DINGTALK_REGISTRATION_BASE_URL",
|
||||
"https://test.example.com/")
|
||||
import importlib
|
||||
import hermes_cli.dingtalk_auth as mod
|
||||
importlib.reload(mod)
|
||||
# Trailing slash stripped
|
||||
assert mod.REGISTRATION_BASE_URL == "https://test.example.com"
|
||||
|
||||
def test_source_default(self, monkeypatch):
|
||||
monkeypatch.delenv("DINGTALK_REGISTRATION_SOURCE", raising=False)
|
||||
import importlib
|
||||
import hermes_cli.dingtalk_auth as mod
|
||||
importlib.reload(mod)
|
||||
assert mod.REGISTRATION_SOURCE == "openClaw"
|
||||
@@ -343,3 +343,57 @@ def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch,
|
||||
assert "Kimi / Moonshot (China)" in out
|
||||
assert "str expected, not NoneType" not in out
|
||||
assert any(url == "https://api.moonshot.cn/v1/models" for url, _, _ in calls)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"])
|
||||
def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
||||
(home / ".env").write_text("OPENCODE_GO_API_KEY=***\n", encoding="utf-8")
|
||||
project = tmp_path / "project"
|
||||
project.mkdir(exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
||||
monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test")
|
||||
if base_url:
|
||||
monkeypatch.setenv("OPENCODE_GO_BASE_URL", base_url)
|
||||
else:
|
||||
monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: ([], []),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
calls.append((url, headers, timeout))
|
||||
return types.SimpleNamespace(status_code=200)
|
||||
|
||||
import httpx
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
|
||||
import io, contextlib
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert any(
|
||||
"OpenCode Go" in line and "(key configured)" in line
|
||||
for line in out.splitlines()
|
||||
)
|
||||
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)
|
||||
|
||||
275
tests/hermes_cli/test_doctor_command_install.py
Normal file
275
tests/hermes_cli/test_doctor_command_install.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Tests for the Command Installation check in hermes doctor."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.doctor as doctor_mod
|
||||
|
||||
|
||||
def _setup_doctor_env(monkeypatch, tmp_path, venv_name="venv"):
|
||||
"""Create a minimal HERMES_HOME + PROJECT_ROOT for doctor tests."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir(exist_ok=True)
|
||||
|
||||
# Create a fake venv entry point
|
||||
venv_bin_dir = project / venv_name / "bin"
|
||||
venv_bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
hermes_bin = venv_bin_dir / "hermes"
|
||||
hermes_bin.write_text("#!/usr/bin/env python\n# entry point\n")
|
||||
hermes_bin.chmod(0o755)
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
||||
|
||||
# Stub model_tools so doctor doesn't fail on import
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: ([], []),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
# Stub auth checks
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Stub httpx.get to avoid network calls
|
||||
try:
|
||||
import httpx
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return home, project, hermes_bin
|
||||
|
||||
|
||||
def _run_doctor(fix=False):
|
||||
"""Run doctor and capture stdout."""
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=fix))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TestDoctorCommandInstallation:
|
||||
"""Tests for the ◆ Command Installation section."""
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
||||
def test_correct_symlink_shows_ok(self, monkeypatch, tmp_path):
|
||||
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
||||
|
||||
# Create the command link dir with correct symlink
|
||||
cmd_link_dir = tmp_path / ".local" / "bin"
|
||||
cmd_link_dir.mkdir(parents=True)
|
||||
cmd_link = cmd_link_dir / "hermes"
|
||||
cmd_link.symlink_to(hermes_bin)
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out = _run_doctor(fix=False)
|
||||
assert "Command Installation" in out
|
||||
assert "Venv entry point exists" in out
|
||||
assert "correct target" in out
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
||||
def test_missing_symlink_shows_fail(self, monkeypatch, tmp_path):
|
||||
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
# Don't create the symlink — it should be missing
|
||||
|
||||
out = _run_doctor(fix=False)
|
||||
assert "Command Installation" in out
|
||||
assert "Venv entry point exists" in out
|
||||
assert "not found" in out
|
||||
assert "hermes doctor --fix" in out
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
||||
def test_fix_creates_missing_symlink(self, monkeypatch, tmp_path):
|
||||
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out = _run_doctor(fix=True)
|
||||
assert "Command Installation" in out
|
||||
assert "Created symlink" in out
|
||||
|
||||
# Verify the symlink was actually created
|
||||
cmd_link = tmp_path / ".local" / "bin" / "hermes"
|
||||
assert cmd_link.is_symlink()
|
||||
assert cmd_link.resolve() == hermes_bin.resolve()
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
||||
def test_wrong_target_symlink_shows_warn(self, monkeypatch, tmp_path):
|
||||
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
||||
|
||||
# Create a symlink pointing to the wrong target
|
||||
cmd_link_dir = tmp_path / ".local" / "bin"
|
||||
cmd_link_dir.mkdir(parents=True)
|
||||
cmd_link = cmd_link_dir / "hermes"
|
||||
wrong_target = tmp_path / "wrong_hermes"
|
||||
wrong_target.write_text("#!/usr/bin/env python\n")
|
||||
cmd_link.symlink_to(wrong_target)
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out = _run_doctor(fix=False)
|
||||
assert "Command Installation" in out
|
||||
assert "wrong target" in out
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
||||
def test_fix_repairs_wrong_symlink(self, monkeypatch, tmp_path):
|
||||
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
||||
|
||||
# Create a symlink pointing to wrong target
|
||||
cmd_link_dir = tmp_path / ".local" / "bin"
|
||||
cmd_link_dir.mkdir(parents=True)
|
||||
cmd_link = cmd_link_dir / "hermes"
|
||||
wrong_target = tmp_path / "wrong_hermes"
|
||||
wrong_target.write_text("#!/usr/bin/env python\n")
|
||||
cmd_link.symlink_to(wrong_target)
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out = _run_doctor(fix=True)
|
||||
assert "Fixed symlink" in out
|
||||
|
||||
# Verify the symlink now points to the correct target
|
||||
assert cmd_link.is_symlink()
|
||||
assert cmd_link.resolve() == hermes_bin.resolve()
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
||||
def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir(exist_ok=True)
|
||||
# Do NOT create any venv entry point
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: ([], []),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import httpx
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
out = _run_doctor(fix=False)
|
||||
assert "Command Installation" in out
|
||||
assert "Venv entry point not found" in out
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
||||
def test_dot_venv_dir_is_found(self, monkeypatch, tmp_path):
|
||||
"""The check finds entry points in .venv/ as well as venv/."""
|
||||
home, project, _ = _setup_doctor_env(monkeypatch, tmp_path, venv_name=".venv")
|
||||
|
||||
# Create the command link with correct symlink
|
||||
hermes_bin = project / ".venv" / "bin" / "hermes"
|
||||
cmd_link_dir = tmp_path / ".local" / "bin"
|
||||
cmd_link_dir.mkdir(parents=True)
|
||||
cmd_link = cmd_link_dir / "hermes"
|
||||
cmd_link.symlink_to(hermes_bin)
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out = _run_doctor(fix=False)
|
||||
assert "Venv entry point exists" in out
|
||||
assert ".venv/bin/hermes" in out
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
||||
def test_non_symlink_regular_file_shows_ok(self, monkeypatch, tmp_path):
|
||||
"""If ~/.local/bin/hermes is a regular file (not symlink), accept it."""
|
||||
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
||||
|
||||
cmd_link_dir = tmp_path / ".local" / "bin"
|
||||
cmd_link_dir.mkdir(parents=True)
|
||||
cmd_link = cmd_link_dir / "hermes"
|
||||
cmd_link.write_text("#!/bin/sh\nexec python -m hermes_cli.main \"$@\"\n")
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out = _run_doctor(fix=False)
|
||||
assert "non-symlink" in out
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
|
||||
def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path):
|
||||
"""On Termux, the command link dir is $PREFIX/bin."""
|
||||
prefix_dir = tmp_path / "termux_prefix"
|
||||
prefix_bin = prefix_dir / "bin"
|
||||
prefix_bin.mkdir(parents=True)
|
||||
|
||||
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", str(prefix_dir))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out = _run_doctor(fix=False)
|
||||
assert "Command Installation" in out
|
||||
assert "$PREFIX/bin" in out
|
||||
|
||||
def test_windows_skips_check(self, monkeypatch, tmp_path):
|
||||
"""On Windows, the Command Installation section is skipped."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir(exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: ([], []),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import httpx
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
out = _run_doctor(fix=False)
|
||||
assert "Command Installation" not in out
|
||||
@@ -39,6 +39,76 @@ class TestSystemdLingerStatus:
|
||||
assert gateway.get_systemd_linger_status() == (None, "not supported in Termux")
|
||||
|
||||
|
||||
class TestContainerSystemdSupport:
|
||||
def test_supports_systemd_services_in_container_with_user_manager(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_container", lambda: True)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemctl")
|
||||
monkeypatch.setattr(gateway, "_systemd_operational", lambda system=False: not system)
|
||||
|
||||
assert gateway.supports_systemd_services() is True
|
||||
|
||||
def test_supports_systemd_services_in_container_with_system_manager(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_container", lambda: True)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemctl")
|
||||
monkeypatch.setattr(gateway, "_systemd_operational", lambda system=False: system)
|
||||
|
||||
assert gateway.supports_systemd_services() is True
|
||||
|
||||
def test_supports_systemd_services_in_container_without_systemd(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_container", lambda: True)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemctl")
|
||||
monkeypatch.setattr(gateway, "_systemd_operational", lambda system=False: False)
|
||||
|
||||
assert gateway.supports_systemd_services() is False
|
||||
|
||||
|
||||
def test_gateway_install_in_container_with_operational_systemd_uses_systemd(monkeypatch):
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_managed", lambda: False)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"systemd_install",
|
||||
lambda force=False, system=False, run_as_user=None: calls.append((force, system, run_as_user)),
|
||||
)
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="install",
|
||||
force=False,
|
||||
system=False,
|
||||
run_as_user=None,
|
||||
)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
assert calls == [(False, False, None)]
|
||||
|
||||
|
||||
def test_gateway_start_in_container_with_operational_systemd_uses_systemd(monkeypatch):
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(gateway, "systemd_start", lambda system=False: calls.append(system))
|
||||
|
||||
args = SimpleNamespace(gateway_command="start", system=False, all=False)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
assert calls == [False]
|
||||
|
||||
|
||||
def test_systemd_status_warns_when_linger_disabled(monkeypatch, tmp_path, capsys):
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("[Unit]\n")
|
||||
@@ -179,6 +249,21 @@ def test_install_linux_gateway_from_setup_system_choice_as_root_installs(monkeyp
|
||||
assert calls == [(True, True, "alice")]
|
||||
|
||||
|
||||
def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkeypatch):
|
||||
monkeypatch.setattr(gateway, "_get_service_pids", lambda: set())
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: False)
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: 321)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[:4] == ["ps", "-A", "eww", "-o"]:
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="ps failed")
|
||||
raise AssertionError(f"Unexpected command: {cmd}")
|
||||
|
||||
monkeypatch.setattr(gateway.subprocess, "run", fake_run)
|
||||
|
||||
assert gateway.find_gateway_pids() == [321]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _wait_for_gateway_exit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -450,9 +450,8 @@ class TestGatewayServiceDetection:
|
||||
|
||||
assert gateway_cli._is_service_running() is False
|
||||
|
||||
|
||||
class TestGatewaySystemServiceRouting:
|
||||
def test_systemd_restart_self_requests_graceful_restart_without_reload_or_restart(self, monkeypatch, capsys):
|
||||
def test_systemd_restart_self_requests_graceful_restart_and_waits(self, monkeypatch, capsys):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
|
||||
@@ -466,16 +465,37 @@ class TestGatewaySystemServiceRouting:
|
||||
"_request_gateway_self_restart",
|
||||
lambda pid: calls.append(("self", pid)) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("systemctl should not run")),
|
||||
)
|
||||
|
||||
# Simulate: old process dies immediately, new process becomes active
|
||||
kill_call_count = [0]
|
||||
def fake_kill(pid, sig):
|
||||
kill_call_count[0] += 1
|
||||
if kill_call_count[0] >= 2: # first call checks, second = dead
|
||||
raise ProcessLookupError()
|
||||
monkeypatch.setattr(os, "kill", fake_kill)
|
||||
|
||||
# Simulate systemctl is-active returning "active" with a new PID
|
||||
new_pid = [None]
|
||||
def fake_subprocess_run(cmd, **kwargs):
|
||||
if "is-active" in cmd:
|
||||
result = SimpleNamespace(stdout="active\n", returncode=0)
|
||||
new_pid[0] = 999 # new PID
|
||||
return result
|
||||
raise AssertionError(f"Unexpected systemctl call: {cmd}")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_subprocess_run)
|
||||
# get_running_pid returns new PID after restart
|
||||
pid_calls = [0]
|
||||
def fake_get_pid():
|
||||
pid_calls[0] += 1
|
||||
return 999 if pid_calls[0] > 1 else 654
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", fake_get_pid)
|
||||
|
||||
gateway_cli.systemd_restart()
|
||||
|
||||
assert calls == [("refresh", False), ("self", 654)]
|
||||
assert "restart requested" in capsys.readouterr().out.lower()
|
||||
assert ("self", 654) in calls
|
||||
out = capsys.readouterr().out.lower()
|
||||
assert "restarted" in out
|
||||
|
||||
def test_gateway_install_passes_system_flags(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
@@ -533,6 +553,38 @@ class TestGatewaySystemServiceRouting:
|
||||
|
||||
assert calls == [(False, False)]
|
||||
|
||||
def test_gateway_status_reports_manual_process_when_service_is_stopped(self, monkeypatch, capsys):
|
||||
user_unit = SimpleNamespace(exists=lambda: True)
|
||||
system_unit = SimpleNamespace(exists=lambda: False)
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"get_systemd_unit_path",
|
||||
lambda system=False: system_unit if system else user_unit,
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "systemd_status", lambda deep=False, system=False: print("service stopped"))
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"get_gateway_runtime_snapshot",
|
||||
lambda system=False: gateway_cli.GatewayRuntimeSnapshot(
|
||||
manager="systemd (user)",
|
||||
service_installed=True,
|
||||
service_running=False,
|
||||
gateway_pids=(4321,),
|
||||
service_scope="user",
|
||||
),
|
||||
)
|
||||
|
||||
gateway_cli.gateway_command(SimpleNamespace(gateway_command="status", deep=False, system=False))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "service stopped" in out
|
||||
assert "Gateway process is running for this profile" in out
|
||||
assert "PID(s): 4321" in out
|
||||
|
||||
def test_gateway_status_on_termux_shows_manual_guidance(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: True)
|
||||
@@ -592,6 +644,7 @@ class TestDetectVenvDir:
|
||||
# Not inside a virtualenv
|
||||
monkeypatch.setattr("sys.prefix", "/usr")
|
||||
monkeypatch.setattr("sys.base_prefix", "/usr")
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path)
|
||||
|
||||
dot_venv = tmp_path / ".venv"
|
||||
@@ -603,6 +656,7 @@ class TestDetectVenvDir:
|
||||
def test_falls_back_to_venv_directory(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.prefix", "/usr")
|
||||
monkeypatch.setattr("sys.base_prefix", "/usr")
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path)
|
||||
|
||||
venv = tmp_path / "venv"
|
||||
@@ -614,6 +668,7 @@ class TestDetectVenvDir:
|
||||
def test_prefers_dot_venv_over_venv(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.prefix", "/usr")
|
||||
monkeypatch.setattr("sys.base_prefix", "/usr")
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path)
|
||||
|
||||
(tmp_path / ".venv").mkdir()
|
||||
@@ -625,6 +680,7 @@ class TestDetectVenvDir:
|
||||
def test_returns_none_when_no_virtualenv(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.prefix", "/usr")
|
||||
monkeypatch.setattr("sys.base_prefix", "/usr")
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path)
|
||||
|
||||
result = gateway_cli._detect_venv_dir()
|
||||
@@ -1121,3 +1177,556 @@ class TestDockerAwareGateway:
|
||||
out = capsys.readouterr().out
|
||||
assert "docker" in out.lower()
|
||||
assert "hermes gateway run" in out
|
||||
|
||||
|
||||
class TestLegacyHermesUnitDetection:
|
||||
"""Tests for _find_legacy_hermes_units / has_legacy_hermes_units.
|
||||
|
||||
These guard against the scenario that tripped Luis in April 2026: an
|
||||
older install left a ``hermes.service`` unit behind when the service was
|
||||
renamed to ``hermes-gateway.service``. After PR #5646 (signal recovery
|
||||
via systemd), the two services began SIGTERM-flapping over the same
|
||||
Telegram bot token in a 30-second cycle.
|
||||
|
||||
The detector must flag ``hermes.service`` ONLY when it actually runs our
|
||||
gateway, and must NEVER flag profile units
|
||||
(``hermes-gateway-<profile>.service``) or unrelated third-party services.
|
||||
"""
|
||||
|
||||
# Minimal ExecStart that looks like our gateway
|
||||
_OUR_UNIT_TEXT = (
|
||||
"[Unit]\nDescription=Hermes Gateway\n[Service]\n"
|
||||
"ExecStart=/usr/bin/python -m hermes_cli.main gateway run --replace\n"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _setup_search_paths(tmp_path, monkeypatch):
|
||||
"""Redirect the legacy search to user_dir + system_dir under tmp_path."""
|
||||
user_dir = tmp_path / "user"
|
||||
system_dir = tmp_path / "system"
|
||||
user_dir.mkdir()
|
||||
system_dir.mkdir()
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_legacy_unit_search_paths",
|
||||
lambda: [(False, user_dir), (True, system_dir)],
|
||||
)
|
||||
return user_dir, system_dir
|
||||
|
||||
def test_detects_legacy_hermes_service_in_user_scope(self, tmp_path, monkeypatch):
|
||||
user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch)
|
||||
legacy = user_dir / "hermes.service"
|
||||
legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
results = gateway_cli._find_legacy_hermes_units()
|
||||
|
||||
assert len(results) == 1
|
||||
name, path, is_system = results[0]
|
||||
assert name == "hermes.service"
|
||||
assert path == legacy
|
||||
assert is_system is False
|
||||
assert gateway_cli.has_legacy_hermes_units() is True
|
||||
|
||||
def test_detects_legacy_hermes_service_in_system_scope(self, tmp_path, monkeypatch):
|
||||
_, system_dir = self._setup_search_paths(tmp_path, monkeypatch)
|
||||
legacy = system_dir / "hermes.service"
|
||||
legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
results = gateway_cli._find_legacy_hermes_units()
|
||||
|
||||
assert len(results) == 1
|
||||
name, path, is_system = results[0]
|
||||
assert name == "hermes.service"
|
||||
assert path == legacy
|
||||
assert is_system is True
|
||||
|
||||
def test_ignores_profile_unit_hermes_gateway_coder(self, tmp_path, monkeypatch):
|
||||
"""CRITICAL: profile units must NOT be flagged as legacy.
|
||||
|
||||
Teknium's concern — ``hermes-gateway-coder.service`` is our standard
|
||||
naming for the ``coder`` profile. The legacy detector is an explicit
|
||||
allowlist, not a glob, so profile units are safe.
|
||||
"""
|
||||
user_dir, system_dir = self._setup_search_paths(tmp_path, monkeypatch)
|
||||
# Drop profile units in BOTH scopes with our ExecStart
|
||||
for base in (user_dir, system_dir):
|
||||
(base / "hermes-gateway-coder.service").write_text(
|
||||
self._OUR_UNIT_TEXT, encoding="utf-8"
|
||||
)
|
||||
(base / "hermes-gateway-orcha.service").write_text(
|
||||
self._OUR_UNIT_TEXT, encoding="utf-8"
|
||||
)
|
||||
(base / "hermes-gateway.service").write_text(
|
||||
self._OUR_UNIT_TEXT, encoding="utf-8"
|
||||
)
|
||||
|
||||
results = gateway_cli._find_legacy_hermes_units()
|
||||
|
||||
assert results == []
|
||||
assert gateway_cli.has_legacy_hermes_units() is False
|
||||
|
||||
def test_ignores_unrelated_hermes_service(self, tmp_path, monkeypatch):
|
||||
"""Third-party ``hermes.service`` that isn't ours stays untouched.
|
||||
|
||||
If a user has some other package named ``hermes`` installed as a
|
||||
service, we must not flag it.
|
||||
"""
|
||||
user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch)
|
||||
(user_dir / "hermes.service").write_text(
|
||||
"[Unit]\nDescription=Some Other Hermes\n[Service]\n"
|
||||
"ExecStart=/opt/other-hermes/bin/daemon --foreground\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
results = gateway_cli._find_legacy_hermes_units()
|
||||
|
||||
assert results == []
|
||||
assert gateway_cli.has_legacy_hermes_units() is False
|
||||
|
||||
def test_returns_empty_when_no_legacy_files_exist(self, tmp_path, monkeypatch):
|
||||
self._setup_search_paths(tmp_path, monkeypatch)
|
||||
|
||||
assert gateway_cli._find_legacy_hermes_units() == []
|
||||
assert gateway_cli.has_legacy_hermes_units() is False
|
||||
|
||||
def test_detects_both_scopes_simultaneously(self, tmp_path, monkeypatch):
|
||||
"""When a user has BOTH user-scope and system-scope legacy units,
|
||||
both are reported so the migration step can remove them together."""
|
||||
user_dir, system_dir = self._setup_search_paths(tmp_path, monkeypatch)
|
||||
(user_dir / "hermes.service").write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
(system_dir / "hermes.service").write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
results = gateway_cli._find_legacy_hermes_units()
|
||||
|
||||
scopes = sorted(is_system for _, _, is_system in results)
|
||||
assert scopes == [False, True]
|
||||
|
||||
def test_accepts_alternate_execstart_formats(self, tmp_path, monkeypatch):
|
||||
"""Older installs may have used different python invocations.
|
||||
|
||||
ExecStart variants we've seen in the wild:
|
||||
- python -m hermes_cli.main gateway run
|
||||
- python path/to/hermes_cli/main.py gateway run
|
||||
- hermes gateway run (direct binary)
|
||||
- python path/to/gateway/run.py
|
||||
"""
|
||||
user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch)
|
||||
variants = [
|
||||
"ExecStart=/venv/bin/python -m hermes_cli.main gateway run --replace",
|
||||
"ExecStart=/venv/bin/python /opt/hermes/hermes_cli/main.py gateway run",
|
||||
"ExecStart=/usr/local/bin/hermes gateway run --replace",
|
||||
"ExecStart=/venv/bin/python /opt/hermes/gateway/run.py",
|
||||
]
|
||||
for i, execstart in enumerate(variants):
|
||||
name = f"hermes.service" if i == 0 else f"hermes.service" # same name
|
||||
# Test each variant fresh
|
||||
(user_dir / "hermes.service").write_text(
|
||||
f"[Unit]\nDescription=Old Hermes\n[Service]\n{execstart}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
results = gateway_cli._find_legacy_hermes_units()
|
||||
assert len(results) == 1, f"Variant {i} not detected: {execstart!r}"
|
||||
|
||||
def test_print_legacy_unit_warning_is_noop_when_empty(self, tmp_path, monkeypatch, capsys):
|
||||
self._setup_search_paths(tmp_path, monkeypatch)
|
||||
|
||||
gateway_cli.print_legacy_unit_warning()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert out == ""
|
||||
|
||||
def test_print_legacy_unit_warning_shows_migration_hint(self, tmp_path, monkeypatch, capsys):
|
||||
user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch)
|
||||
(user_dir / "hermes.service").write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
gateway_cli.print_legacy_unit_warning()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Legacy" in out
|
||||
assert "hermes.service" in out
|
||||
assert "hermes gateway migrate-legacy" in out
|
||||
|
||||
def test_handles_unreadable_unit_file_gracefully(self, tmp_path, monkeypatch):
|
||||
"""A permission error reading a unit file must not crash detection."""
|
||||
user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch)
|
||||
unreadable = user_dir / "hermes.service"
|
||||
unreadable.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
# Simulate a read failure — monkeypatch Path.read_text to raise
|
||||
original_read_text = gateway_cli.Path.read_text
|
||||
|
||||
def raising_read_text(self, *args, **kwargs):
|
||||
if self == unreadable:
|
||||
raise PermissionError("simulated")
|
||||
return original_read_text(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(gateway_cli.Path, "read_text", raising_read_text)
|
||||
|
||||
# Should not raise
|
||||
results = gateway_cli._find_legacy_hermes_units()
|
||||
assert results == []
|
||||
|
||||
|
||||
class TestRemoveLegacyHermesUnits:
|
||||
"""Tests for remove_legacy_hermes_units (the migration action)."""
|
||||
|
||||
_OUR_UNIT_TEXT = (
|
||||
"[Unit]\nDescription=Hermes Gateway\n[Service]\n"
|
||||
"ExecStart=/usr/bin/python -m hermes_cli.main gateway run --replace\n"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _setup(tmp_path, monkeypatch, as_root=False):
|
||||
user_dir = tmp_path / "user"
|
||||
system_dir = tmp_path / "system"
|
||||
user_dir.mkdir()
|
||||
system_dir.mkdir()
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_legacy_unit_search_paths",
|
||||
lambda: [(False, user_dir), (True, system_dir)],
|
||||
)
|
||||
# Mock systemctl — return success for everything
|
||||
systemctl_calls: list[list[str]] = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
systemctl_calls.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 0 if as_root else 1000)
|
||||
return user_dir, system_dir, systemctl_calls
|
||||
|
||||
def test_returns_zero_when_no_legacy_units(self, tmp_path, monkeypatch, capsys):
|
||||
self._setup(tmp_path, monkeypatch)
|
||||
|
||||
removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False)
|
||||
|
||||
assert removed == 0
|
||||
assert remaining == []
|
||||
assert "No legacy" in capsys.readouterr().out
|
||||
|
||||
def test_dry_run_lists_without_removing(self, tmp_path, monkeypatch, capsys):
|
||||
user_dir, _, calls = self._setup(tmp_path, monkeypatch)
|
||||
legacy = user_dir / "hermes.service"
|
||||
legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
removed, remaining = gateway_cli.remove_legacy_hermes_units(
|
||||
interactive=False, dry_run=True
|
||||
)
|
||||
|
||||
assert removed == 0
|
||||
assert remaining == [legacy]
|
||||
assert legacy.exists() # Not removed
|
||||
assert calls == [] # No systemctl invocations
|
||||
out = capsys.readouterr().out
|
||||
assert "dry-run" in out
|
||||
|
||||
def test_removes_user_scope_legacy_unit(self, tmp_path, monkeypatch, capsys):
|
||||
user_dir, _, calls = self._setup(tmp_path, monkeypatch)
|
||||
legacy = user_dir / "hermes.service"
|
||||
legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False)
|
||||
|
||||
assert removed == 1
|
||||
assert remaining == []
|
||||
assert not legacy.exists()
|
||||
# Must have invoked stop → disable → daemon-reload on user scope
|
||||
cmds_joined = [" ".join(c) for c in calls]
|
||||
assert any("--user stop hermes.service" in c for c in cmds_joined)
|
||||
assert any("--user disable hermes.service" in c for c in cmds_joined)
|
||||
assert any("--user daemon-reload" in c for c in cmds_joined)
|
||||
|
||||
def test_system_scope_without_root_defers_removal(self, tmp_path, monkeypatch, capsys):
|
||||
_, system_dir, calls = self._setup(tmp_path, monkeypatch, as_root=False)
|
||||
legacy = system_dir / "hermes.service"
|
||||
legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False)
|
||||
|
||||
assert removed == 0
|
||||
assert remaining == [legacy]
|
||||
assert legacy.exists() # Not removed — requires sudo
|
||||
out = capsys.readouterr().out
|
||||
assert "sudo hermes gateway migrate-legacy" in out
|
||||
|
||||
def test_system_scope_with_root_removes(self, tmp_path, monkeypatch, capsys):
|
||||
_, system_dir, calls = self._setup(tmp_path, monkeypatch, as_root=True)
|
||||
legacy = system_dir / "hermes.service"
|
||||
legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False)
|
||||
|
||||
assert removed == 1
|
||||
assert remaining == []
|
||||
assert not legacy.exists()
|
||||
cmds_joined = [" ".join(c) for c in calls]
|
||||
# System-scope uses plain "systemctl" (no --user)
|
||||
assert any(
|
||||
c.startswith("systemctl stop hermes.service") for c in cmds_joined
|
||||
)
|
||||
assert any(
|
||||
c.startswith("systemctl disable hermes.service") for c in cmds_joined
|
||||
)
|
||||
|
||||
def test_removes_both_scopes_with_root(self, tmp_path, monkeypatch, capsys):
|
||||
user_dir, system_dir, _ = self._setup(tmp_path, monkeypatch, as_root=True)
|
||||
user_legacy = user_dir / "hermes.service"
|
||||
system_legacy = system_dir / "hermes.service"
|
||||
user_legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
system_legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False)
|
||||
|
||||
assert removed == 2
|
||||
assert remaining == []
|
||||
assert not user_legacy.exists()
|
||||
assert not system_legacy.exists()
|
||||
|
||||
def test_does_not_touch_profile_units_during_migration(
|
||||
self, tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""Teknium's constraint: profile units (hermes-gateway-coder.service)
|
||||
must survive a migration call, even if we somehow include them in the
|
||||
search dir."""
|
||||
user_dir, _, _ = self._setup(tmp_path, monkeypatch, as_root=True)
|
||||
profile_unit = user_dir / "hermes-gateway-coder.service"
|
||||
profile_unit.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
default_unit = user_dir / "hermes-gateway.service"
|
||||
default_unit.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False)
|
||||
|
||||
assert removed == 0
|
||||
assert remaining == []
|
||||
# Both the profile unit and the current default unit must survive
|
||||
assert profile_unit.exists()
|
||||
assert default_unit.exists()
|
||||
|
||||
def test_interactive_prompt_no_skips_removal(self, tmp_path, monkeypatch, capsys):
|
||||
"""When interactive=True and user answers no, no removal happens."""
|
||||
user_dir, _, _ = self._setup(tmp_path, monkeypatch)
|
||||
legacy = user_dir / "hermes.service"
|
||||
legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "prompt_yes_no", lambda *a, **k: False)
|
||||
|
||||
removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=True)
|
||||
|
||||
assert removed == 0
|
||||
assert remaining == [legacy]
|
||||
assert legacy.exists()
|
||||
|
||||
|
||||
class TestMigrateLegacyCommand:
|
||||
"""Tests for the `hermes gateway migrate-legacy` subcommand dispatch."""
|
||||
|
||||
def test_migrate_legacy_subparser_accepts_dry_run_and_yes(self):
|
||||
"""Verify the argparse subparser is registered and parses flags."""
|
||||
import hermes_cli.main as cli_main
|
||||
|
||||
parser = cli_main.build_parser() if hasattr(cli_main, "build_parser") else None
|
||||
# Fall back to calling main's setup helper if direct access isn't exposed
|
||||
# The key thing: the subparser must exist. We verify by constructing
|
||||
# a namespace through argparse directly — but if build_parser isn't
|
||||
# public, just confirm that `hermes gateway --help` shows it.
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
project_root = cli_main.PROJECT_ROOT if hasattr(cli_main, "PROJECT_ROOT") else None
|
||||
if project_root is None:
|
||||
import hermes_cli.gateway as gw
|
||||
project_root = gw.PROJECT_ROOT
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "gateway", "--help"],
|
||||
cwd=str(project_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "migrate-legacy" in result.stdout
|
||||
|
||||
def test_gateway_command_migrate_legacy_dispatches(
|
||||
self, tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""gateway_command(args) with subcmd='migrate-legacy' calls the helper."""
|
||||
called = {}
|
||||
|
||||
def fake_remove(interactive=True, dry_run=False):
|
||||
called["interactive"] = interactive
|
||||
called["dry_run"] = dry_run
|
||||
return 0, []
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="migrate-legacy", dry_run=False, yes=True
|
||||
)
|
||||
gateway_cli.gateway_command(args)
|
||||
|
||||
assert called == {"interactive": False, "dry_run": False}
|
||||
|
||||
def test_gateway_command_migrate_legacy_dry_run_passes_through(
|
||||
self, monkeypatch
|
||||
):
|
||||
called = {}
|
||||
|
||||
def fake_remove(interactive=True, dry_run=False):
|
||||
called["interactive"] = interactive
|
||||
called["dry_run"] = dry_run
|
||||
return 0, []
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="migrate-legacy", dry_run=True, yes=False
|
||||
)
|
||||
gateway_cli.gateway_command(args)
|
||||
|
||||
assert called == {"interactive": True, "dry_run": True}
|
||||
|
||||
def test_migrate_legacy_on_unsupported_platform_prints_message(
|
||||
self, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="migrate-legacy", dry_run=False, yes=True
|
||||
)
|
||||
gateway_cli.gateway_command(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "only applies to systemd" in out
|
||||
|
||||
|
||||
class TestSystemdInstallOffersLegacyRemoval:
|
||||
"""Verify that systemd_install prompts to remove legacy units first."""
|
||||
|
||||
def test_install_offers_removal_when_legacy_detected(
|
||||
self, tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""When legacy units exist, install flow should call the removal
|
||||
helper before writing the new unit."""
|
||||
remove_called = {}
|
||||
|
||||
def fake_remove(interactive=True, dry_run=False):
|
||||
remove_called["invoked"] = True
|
||||
remove_called["interactive"] = interactive
|
||||
return 1, []
|
||||
|
||||
# has_legacy_hermes_units must return True
|
||||
monkeypatch.setattr(gateway_cli, "has_legacy_hermes_units", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove)
|
||||
monkeypatch.setattr(gateway_cli, "print_legacy_unit_warning", lambda: None)
|
||||
# Answer "yes" to the legacy-removal prompt
|
||||
monkeypatch.setattr(gateway_cli, "prompt_yes_no", lambda *a, **k: True)
|
||||
|
||||
# Mock the rest of the install flow
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: "unit text\n",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess,
|
||||
"run",
|
||||
lambda cmd, **kw: SimpleNamespace(returncode=0, stdout="", stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "_ensure_linger_enabled", lambda: None)
|
||||
|
||||
gateway_cli.systemd_install()
|
||||
|
||||
assert remove_called.get("invoked") is True
|
||||
assert remove_called.get("interactive") is False # prompted elsewhere
|
||||
|
||||
def test_install_declines_legacy_removal_when_user_says_no(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""When legacy units exist and user declines, install still proceeds
|
||||
but doesn't touch them."""
|
||||
remove_called = {"invoked": False}
|
||||
|
||||
def fake_remove(interactive=True, dry_run=False):
|
||||
remove_called["invoked"] = True
|
||||
return 0, []
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "has_legacy_hermes_units", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove)
|
||||
monkeypatch.setattr(gateway_cli, "print_legacy_unit_warning", lambda: None)
|
||||
monkeypatch.setattr(gateway_cli, "prompt_yes_no", lambda *a, **k: False)
|
||||
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: "unit text\n",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess,
|
||||
"run",
|
||||
lambda cmd, **kw: SimpleNamespace(returncode=0, stdout="", stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "_ensure_linger_enabled", lambda: None)
|
||||
|
||||
gateway_cli.systemd_install()
|
||||
|
||||
# Helper must NOT have been called
|
||||
assert remove_called["invoked"] is False
|
||||
# New unit should still have been written
|
||||
assert unit_path.exists()
|
||||
assert unit_path.read_text() == "unit text\n"
|
||||
|
||||
def test_install_skips_legacy_check_when_none_present(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""No legacy → no prompt, no helper call."""
|
||||
prompt_called = {"count": 0}
|
||||
|
||||
def counting_prompt(*a, **k):
|
||||
prompt_called["count"] += 1
|
||||
return True
|
||||
|
||||
remove_called = {"invoked": False}
|
||||
|
||||
def fake_remove(interactive=True, dry_run=False):
|
||||
remove_called["invoked"] = True
|
||||
return 0, []
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "has_legacy_hermes_units", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove)
|
||||
monkeypatch.setattr(gateway_cli, "prompt_yes_no", counting_prompt)
|
||||
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: "unit text\n",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess,
|
||||
"run",
|
||||
lambda cmd, **kw: SimpleNamespace(returncode=0, stdout="", stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "_ensure_linger_enabled", lambda: None)
|
||||
|
||||
gateway_cli.systemd_install()
|
||||
|
||||
assert prompt_called["count"] == 0
|
||||
assert remove_called["invoked"] is False
|
||||
|
||||
@@ -22,7 +22,7 @@ class TestGeminiProviderRegistry:
|
||||
assert pconfig.id == "gemini"
|
||||
assert pconfig.name == "Google AI Studio"
|
||||
assert pconfig.auth_type == "api_key"
|
||||
assert pconfig.inference_base_url == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
assert pconfig.inference_base_url == "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
def test_gemini_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["gemini"]
|
||||
@@ -99,7 +99,7 @@ class TestGeminiCredentials:
|
||||
creds = resolve_api_key_provider_credentials("gemini")
|
||||
assert creds["provider"] == "gemini"
|
||||
assert creds["api_key"] == "google-secret"
|
||||
assert creds["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
assert creds["base_url"] == "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
def test_resolve_with_gemini_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "gemini-secret")
|
||||
@@ -119,7 +119,7 @@ class TestGeminiCredentials:
|
||||
assert result["provider"] == "gemini"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "google-key"
|
||||
assert result["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
assert result["base_url"] == "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
|
||||
# ── Model Catalog ──
|
||||
@@ -130,7 +130,7 @@ class TestGeminiModelCatalog:
|
||||
models = _PROVIDER_MODELS["gemini"]
|
||||
assert "gemini-2.5-pro" in models
|
||||
assert "gemini-2.5-flash" in models
|
||||
assert "gemma-4-31b-it" in models
|
||||
assert "gemma-4-31b-it" not in models
|
||||
|
||||
def test_provider_models_has_3x(self):
|
||||
models = _PROVIDER_MODELS["gemini"]
|
||||
@@ -178,10 +178,6 @@ class TestGeminiContextLength:
|
||||
ctx = get_model_context_length("gemma-4-31b-it", provider="gemini")
|
||||
assert ctx == 256000
|
||||
|
||||
def test_gemma_4_26b_context(self):
|
||||
ctx = get_model_context_length("gemma-4-26b-it", provider="gemini")
|
||||
assert ctx == 256000
|
||||
|
||||
def test_gemini_3_context(self):
|
||||
ctx = get_model_context_length("gemini-3.1-pro-preview", provider="gemini")
|
||||
assert ctx == 1048576
|
||||
@@ -197,20 +193,90 @@ class TestGeminiAgentInit:
|
||||
importlib.reload(run_agent)
|
||||
|
||||
def test_gemini_agent_uses_chat_completions(self, monkeypatch):
|
||||
"""Gemini falls through to chat_completions — no special elif needed."""
|
||||
"""Gemini still reports chat_completions even though the transport is native."""
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
|
||||
with patch("run_agent.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client:
|
||||
mock_client.return_value = MagicMock()
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent(
|
||||
model="gemini-2.5-flash",
|
||||
provider="gemini",
|
||||
api_key="test-key",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta",
|
||||
)
|
||||
assert agent.api_mode == "chat_completions"
|
||||
assert agent.provider == "gemini"
|
||||
|
||||
def test_gemini_agent_uses_native_client(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSy_REAL_KEY")
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \
|
||||
patch("run_agent.OpenAI") as mock_openai, \
|
||||
patch("run_agent.ContextCompressor") as mock_compressor:
|
||||
mock_client.return_value = MagicMock()
|
||||
mock_compressor.return_value = MagicMock(context_length=1048576, threshold_tokens=524288)
|
||||
from run_agent import AIAgent
|
||||
AIAgent(
|
||||
model="gemini-2.5-flash",
|
||||
provider="gemini",
|
||||
api_key="AIzaSy_REAL_KEY",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta",
|
||||
)
|
||||
assert mock_client.called
|
||||
mock_openai.assert_not_called()
|
||||
|
||||
def test_gemini_custom_base_url_keeps_openai_client(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSy_REAL_KEY")
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \
|
||||
patch("run_agent.OpenAI") as mock_openai, \
|
||||
patch("run_agent.ContextCompressor") as mock_compressor:
|
||||
mock_openai.return_value = MagicMock()
|
||||
mock_compressor.return_value = MagicMock(context_length=128000, threshold_tokens=64000)
|
||||
from run_agent import AIAgent
|
||||
AIAgent(
|
||||
model="gemini-2.5-flash",
|
||||
provider="gemini",
|
||||
api_key="AIzaSy_REAL_KEY",
|
||||
base_url="https://proxy.example.com/v1",
|
||||
)
|
||||
mock_openai.assert_called_once()
|
||||
|
||||
def test_gemini_openai_compat_base_url_keeps_openai_client(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSy_REAL_KEY")
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \
|
||||
patch("run_agent.OpenAI") as mock_openai, \
|
||||
patch("run_agent.ContextCompressor") as mock_compressor:
|
||||
mock_openai.return_value = MagicMock()
|
||||
mock_compressor.return_value = MagicMock(context_length=1048576, threshold_tokens=524288)
|
||||
from run_agent import AIAgent
|
||||
AIAgent(
|
||||
model="gemini-2.5-flash",
|
||||
provider="gemini",
|
||||
api_key="AIzaSy_REAL_KEY",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
)
|
||||
mock_openai.assert_called_once()
|
||||
|
||||
def test_gemini_resolve_provider_client_uses_native_client(self, monkeypatch):
|
||||
"""resolve_provider_client('gemini') should build GeminiNativeClient."""
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "AIzaSy_TEST_KEY")
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
mock_client.return_value = MagicMock()
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
resolve_provider_client("gemini")
|
||||
assert mock_client.called
|
||||
mock_openai.assert_not_called()
|
||||
|
||||
def test_gemini_resolve_provider_client_keeps_openai_for_non_native_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSy_TEST_KEY")
|
||||
monkeypatch.setenv("GEMINI_BASE_URL", "https://proxy.example.com/v1")
|
||||
with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
resolve_provider_client("gemini")
|
||||
mock_openai.assert_called_once()
|
||||
|
||||
|
||||
# ── models.dev Integration ──
|
||||
|
||||
@@ -265,9 +331,32 @@ class TestGeminiModelsDev:
|
||||
result = list_agentic_models("gemini")
|
||||
assert "gemini-3-flash-preview" in result
|
||||
assert "gemini-2.5-pro" in result
|
||||
assert "gemma-4-31b-it" in result
|
||||
assert "gemma-4-31b-it" not in result
|
||||
# Filtered out:
|
||||
assert "gemini-embedding-001" not in result # no tool_call
|
||||
assert "gemini-2.5-flash-preview-tts" not in result # no tool_call
|
||||
assert "gemini-live-2.5-flash" not in result # noise: live-
|
||||
assert "gemini-2.5-flash-preview-04-17" not in result # noise: dated preview
|
||||
|
||||
def test_list_provider_models_hides_low_tpm_google_gemmas(self):
|
||||
mock_data = {
|
||||
"google": {
|
||||
"models": {
|
||||
"gemini-2.5-pro": {},
|
||||
"gemma-4-31b-it": {},
|
||||
"gemma-3-27b-it": {},
|
||||
"gemini-1.5-pro": {},
|
||||
"gemini-2.0-flash": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_data):
|
||||
from agent.models_dev import list_provider_models
|
||||
|
||||
result = list_provider_models("gemini")
|
||||
|
||||
assert "gemini-2.5-pro" in result
|
||||
assert "gemma-4-31b-it" not in result
|
||||
assert "gemma-3-27b-it" not in result
|
||||
assert "gemini-1.5-pro" not in result
|
||||
assert "gemini-2.0-flash" not in result
|
||||
|
||||
@@ -539,3 +539,64 @@ class TestDispatcher:
|
||||
mcp_command(_make_args(mcp_action=None))
|
||||
out = capsys.readouterr().out
|
||||
assert "Commands:" in out or "No MCP servers" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Task 7 consolidation — cmd_mcp_remove evicts manager cache,
|
||||
# cmd_mcp_login forces re-auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMcpRemoveEvictsManager:
|
||||
def test_remove_evicts_in_memory_provider(self, tmp_path, capsys, monkeypatch):
|
||||
"""After cmd_mcp_remove, the MCPOAuthManager no longer caches the provider."""
|
||||
_seed_config(tmp_path, {
|
||||
"oauth-srv": {"url": "https://example.com/mcp", "auth": "oauth"},
|
||||
})
|
||||
monkeypatch.setattr("builtins.input", lambda _: "y")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.mcp_config.get_hermes_home", lambda: tmp_path
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
from tools.mcp_oauth_manager import get_manager, reset_manager_for_tests
|
||||
reset_manager_for_tests()
|
||||
|
||||
mgr = get_manager()
|
||||
mgr.get_or_build_provider(
|
||||
"oauth-srv", "https://example.com/mcp", None,
|
||||
)
|
||||
assert "oauth-srv" in mgr._entries
|
||||
|
||||
from hermes_cli.mcp_config import cmd_mcp_remove
|
||||
cmd_mcp_remove(_make_args(name="oauth-srv"))
|
||||
|
||||
assert "oauth-srv" not in mgr._entries
|
||||
|
||||
|
||||
class TestMcpLogin:
|
||||
def test_login_rejects_unknown_server(self, tmp_path, capsys):
|
||||
_seed_config(tmp_path, {})
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
cmd_mcp_login(_make_args(name="ghost"))
|
||||
out = capsys.readouterr().out
|
||||
assert "not found" in out
|
||||
|
||||
def test_login_rejects_non_oauth_server(self, tmp_path, capsys):
|
||||
_seed_config(tmp_path, {
|
||||
"srv": {"url": "https://example.com/mcp", "auth": "header"},
|
||||
})
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
cmd_mcp_login(_make_args(name="srv"))
|
||||
out = capsys.readouterr().out
|
||||
assert "not configured for OAuth" in out
|
||||
|
||||
def test_login_rejects_stdio_server(self, tmp_path, capsys):
|
||||
_seed_config(tmp_path, {
|
||||
"srv": {"command": "npx", "args": ["some-server"]},
|
||||
})
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
cmd_mcp_login(_make_args(name="srv"))
|
||||
out = capsys.readouterr().out
|
||||
assert "no URL" in out or "not an OAuth" in out
|
||||
|
||||
|
||||
157
tests/hermes_cli/test_memory_reset.py
Normal file
157
tests/hermes_cli/test_memory_reset.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Tests for the `hermes memory reset` CLI command.
|
||||
|
||||
Covers:
|
||||
- Reset both stores (MEMORY.md + USER.md)
|
||||
- Reset individual stores (--target memory / --target user)
|
||||
- Skip confirmation with --yes
|
||||
- Graceful handling when no memory files exist
|
||||
- Profile-scoped reset (uses HERMES_HOME)
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_env(tmp_path, monkeypatch):
|
||||
"""Set up a fake HERMES_HOME with memory files."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
memories = hermes_home / "memories"
|
||||
memories.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Create sample memory files
|
||||
(memories / "MEMORY.md").write_text(
|
||||
"§\nHermes repo is at ~/.hermes/hermes-agent\n§\nUser prefers dark themes",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(memories / "USER.md").write_text(
|
||||
"§\nUser is Teknium\n§\nTimezone: US Pacific",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return hermes_home, memories
|
||||
|
||||
|
||||
def _run_memory_reset(target="all", yes=False, monkeypatch=None, confirm_input="no"):
|
||||
"""Invoke the memory reset logic from cmd_memory in main.py.
|
||||
|
||||
Simulates what happens when `hermes memory reset` is run.
|
||||
"""
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
|
||||
mem_dir = get_hermes_home() / "memories"
|
||||
files_to_reset = []
|
||||
if target in ("all", "memory"):
|
||||
files_to_reset.append(("MEMORY.md", "agent notes"))
|
||||
if target in ("all", "user"):
|
||||
files_to_reset.append(("USER.md", "user profile"))
|
||||
|
||||
existing = [(f, desc) for f, desc in files_to_reset if (mem_dir / f).exists()]
|
||||
if not existing:
|
||||
return "nothing"
|
||||
|
||||
if not yes:
|
||||
if confirm_input != "yes":
|
||||
return "cancelled"
|
||||
|
||||
for f, desc in existing:
|
||||
(mem_dir / f).unlink()
|
||||
|
||||
return "deleted"
|
||||
|
||||
|
||||
class TestMemoryReset:
|
||||
"""Tests for `hermes memory reset` subcommand."""
|
||||
|
||||
def test_reset_all_with_yes_flag(self, memory_env):
|
||||
"""--yes flag should skip confirmation and delete both files."""
|
||||
hermes_home, memories = memory_env
|
||||
assert (memories / "MEMORY.md").exists()
|
||||
assert (memories / "USER.md").exists()
|
||||
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
assert not (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_memory_only(self, memory_env):
|
||||
"""--target memory should only delete MEMORY.md."""
|
||||
hermes_home, memories = memory_env
|
||||
|
||||
result = _run_memory_reset(target="memory", yes=True)
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
assert (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_user_only(self, memory_env):
|
||||
"""--target user should only delete USER.md."""
|
||||
hermes_home, memories = memory_env
|
||||
|
||||
result = _run_memory_reset(target="user", yes=True)
|
||||
assert result == "deleted"
|
||||
assert (memories / "MEMORY.md").exists()
|
||||
assert not (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_no_files_exist(self, tmp_path, monkeypatch):
|
||||
"""Should return 'nothing' when no memory files exist."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
(hermes_home / "memories").mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "nothing"
|
||||
|
||||
def test_reset_confirmation_denied(self, memory_env):
|
||||
"""Without --yes and without typing 'yes', should be cancelled."""
|
||||
hermes_home, memories = memory_env
|
||||
|
||||
result = _run_memory_reset(target="all", yes=False, confirm_input="no")
|
||||
assert result == "cancelled"
|
||||
# Files should still exist
|
||||
assert (memories / "MEMORY.md").exists()
|
||||
assert (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_confirmation_accepted(self, memory_env):
|
||||
"""Typing 'yes' should proceed with deletion."""
|
||||
hermes_home, memories = memory_env
|
||||
|
||||
result = _run_memory_reset(target="all", yes=False, confirm_input="yes")
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
assert not (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_profile_scoped(self, tmp_path, monkeypatch):
|
||||
"""Reset should work on the active profile's HERMES_HOME."""
|
||||
profile_home = tmp_path / "profiles" / "myprofile"
|
||||
memories = profile_home / "memories"
|
||||
memories.mkdir(parents=True)
|
||||
(memories / "MEMORY.md").write_text("profile memory", encoding="utf-8")
|
||||
(memories / "USER.md").write_text("profile user", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
assert not (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_partial_files(self, memory_env):
|
||||
"""Reset should work when only one memory file exists."""
|
||||
hermes_home, memories = memory_env
|
||||
(memories / "USER.md").unlink()
|
||||
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
|
||||
def test_reset_empty_memories_dir(self, tmp_path, monkeypatch):
|
||||
"""No memories dir at all should report nothing."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(parents=True)
|
||||
# No memories dir
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# The memories dir won't exist; get_hermes_home() / "memories" won't have files
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "nothing"
|
||||
@@ -93,6 +93,59 @@ class TestCopilotDotPreservation:
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ── Copilot model-name normalization (issue #6879 regression) ──────────
|
||||
|
||||
class TestCopilotModelNormalization:
|
||||
"""Copilot requires bare dot-notation model IDs.
|
||||
|
||||
Regression coverage for issue #6879 and the broken Copilot branch
|
||||
that previously left vendor-prefixed Anthropic IDs (e.g.
|
||||
``anthropic/claude-sonnet-4.6``) and dash-notation Claude IDs (e.g.
|
||||
``claude-sonnet-4-6``) unchanged, causing the Copilot API to reject
|
||||
the request with HTTP 400 "model_not_supported".
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
# Vendor-prefixed Anthropic IDs — prefix must be stripped.
|
||||
("anthropic/claude-opus-4.6", "claude-opus-4.6"),
|
||||
("anthropic/claude-sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("anthropic/claude-sonnet-4.5", "claude-sonnet-4.5"),
|
||||
("anthropic/claude-haiku-4.5", "claude-haiku-4.5"),
|
||||
# Vendor-prefixed OpenAI IDs — prefix must be stripped.
|
||||
("openai/gpt-5.4", "gpt-5.4"),
|
||||
("openai/gpt-4o", "gpt-4o"),
|
||||
("openai/gpt-4o-mini", "gpt-4o-mini"),
|
||||
# Dash-notation Claude IDs — must be converted to dot-notation.
|
||||
("claude-opus-4-6", "claude-opus-4.6"),
|
||||
("claude-sonnet-4-6", "claude-sonnet-4.6"),
|
||||
("claude-sonnet-4-5", "claude-sonnet-4.5"),
|
||||
("claude-haiku-4-5", "claude-haiku-4.5"),
|
||||
# Combined: vendor-prefixed + dash-notation.
|
||||
("anthropic/claude-opus-4-6", "claude-opus-4.6"),
|
||||
("anthropic/claude-sonnet-4-6", "claude-sonnet-4.6"),
|
||||
# Already-canonical inputs pass through unchanged.
|
||||
("claude-sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("gpt-5.4", "gpt-5.4"),
|
||||
("gpt-5-mini", "gpt-5-mini"),
|
||||
])
|
||||
def test_copilot_normalization(self, model, expected):
|
||||
assert normalize_model_for_provider(model, "copilot") == expected
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("anthropic/claude-sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("claude-sonnet-4-6", "claude-sonnet-4.6"),
|
||||
("claude-opus-4-6", "claude-opus-4.6"),
|
||||
("openai/gpt-5.4", "gpt-5.4"),
|
||||
])
|
||||
def test_copilot_acp_normalization(self, model, expected):
|
||||
"""Copilot ACP shares the same API expectations as HTTP Copilot."""
|
||||
assert normalize_model_for_provider(model, "copilot-acp") == expected
|
||||
|
||||
def test_openai_codex_still_strips_openai_prefix(self):
|
||||
"""Regression: openai-codex must still strip the openai/ prefix."""
|
||||
assert normalize_model_for_provider("openai/gpt-5.4", "openai-codex") == "gpt-5.4"
|
||||
|
||||
|
||||
# ── Aggregator providers (regression) ──────────────────────────────────
|
||||
|
||||
class TestAggregatorProviders:
|
||||
|
||||
62
tests/hermes_cli/test_model_picker_viewport.py
Normal file
62
tests/hermes_cli/test_model_picker_viewport.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Tests for the prompt_toolkit /model picker scroll viewport.
|
||||
|
||||
Regression for: when a provider exposes many models (e.g. Ollama Cloud's
|
||||
36+), the picker rendered every choice into a Window with no max height,
|
||||
clipping the bottom border and any items past the terminal's last row.
|
||||
The viewport helper now caps visible items and slides the offset to keep
|
||||
the cursor on screen.
|
||||
"""
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
_compute = HermesCLI._compute_model_picker_viewport
|
||||
|
||||
|
||||
class TestPickerViewport:
|
||||
def test_short_list_no_scroll(self):
|
||||
offset, visible = _compute(selected=0, scroll_offset=0, n=5, term_rows=30)
|
||||
assert offset == 0
|
||||
assert visible == 5
|
||||
|
||||
def test_long_list_caps_visible_to_chrome_budget(self):
|
||||
# 30 rows minus reserved_below=6 minus panel_chrome=6 → max_visible=18.
|
||||
offset, visible = _compute(selected=0, scroll_offset=0, n=36, term_rows=30)
|
||||
assert visible == 18
|
||||
assert offset == 0
|
||||
|
||||
def test_cursor_past_window_scrolls_down(self):
|
||||
offset, visible = _compute(selected=22, scroll_offset=0, n=36, term_rows=30)
|
||||
assert visible == 18
|
||||
assert 22 in range(offset, offset + visible)
|
||||
|
||||
def test_cursor_above_window_scrolls_up(self):
|
||||
offset, visible = _compute(selected=3, scroll_offset=15, n=36, term_rows=30)
|
||||
assert offset == 3
|
||||
assert 3 in range(offset, offset + visible)
|
||||
|
||||
def test_offset_clamped_to_bottom(self):
|
||||
# Selected on the last item — offset must keep the visible window
|
||||
# full, not walk past the end of the list.
|
||||
offset, visible = _compute(selected=35, scroll_offset=0, n=36, term_rows=30)
|
||||
assert offset + visible == 36
|
||||
assert 35 in range(offset, offset + visible)
|
||||
|
||||
def test_tiny_terminal_uses_minimum_visible(self):
|
||||
# term_rows below the chrome budget falls back to the floor of 3 rows.
|
||||
_, visible = _compute(selected=0, scroll_offset=0, n=20, term_rows=10)
|
||||
assert visible == 3
|
||||
|
||||
def test_offset_recovers_after_stage_switch(self):
|
||||
# When the user backs out of the model stage and re-enters with
|
||||
# selected=0, a stale offset from the previous stage must collapse.
|
||||
offset, visible = _compute(selected=0, scroll_offset=25, n=36, term_rows=30)
|
||||
assert offset == 0
|
||||
assert 0 in range(offset, offset + visible)
|
||||
|
||||
def test_full_navigation_keeps_cursor_visible(self):
|
||||
offset = 0
|
||||
for cursor in list(range(36)) + list(range(35, -1, -1)):
|
||||
offset, visible = _compute(cursor, offset, n=36, term_rows=30)
|
||||
assert cursor in range(offset, offset + visible), (
|
||||
f"cursor={cursor} out of view: offset={offset} visible={visible}"
|
||||
)
|
||||
101
tests/hermes_cli/test_model_switch_copilot_api_mode.py
Normal file
101
tests/hermes_cli/test_model_switch_copilot_api_mode.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Regression tests for Copilot api_mode recomputation during /model switch.
|
||||
|
||||
When switching models within the Copilot provider (e.g. GPT-5 → Claude),
|
||||
the stale api_mode from resolve_runtime_provider must be overridden with
|
||||
a fresh value computed from the *new* model. Without the fix, Claude
|
||||
requests went through the Responses API and failed with
|
||||
``unsupported_api_for_model``.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import switch_model
|
||||
|
||||
|
||||
_MOCK_VALIDATION = {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"message": None,
|
||||
}
|
||||
|
||||
|
||||
def _run_copilot_switch(
|
||||
raw_input: str,
|
||||
current_provider: str = "copilot",
|
||||
current_model: str = "gpt-5.4",
|
||||
explicit_provider: str = "",
|
||||
runtime_api_mode: str = "codex_responses",
|
||||
):
|
||||
"""Run switch_model with Copilot mocks and return the result."""
|
||||
with (
|
||||
patch("hermes_cli.model_switch.resolve_alias", return_value=None),
|
||||
patch("hermes_cli.model_switch.list_provider_models", return_value=[]),
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "ghu_test_token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"api_mode": runtime_api_mode,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.models.validate_requested_model",
|
||||
return_value=_MOCK_VALIDATION,
|
||||
),
|
||||
patch("hermes_cli.model_switch.get_model_info", return_value=None),
|
||||
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None),
|
||||
patch("hermes_cli.models.detect_provider_for_model", return_value=None),
|
||||
):
|
||||
return switch_model(
|
||||
raw_input=raw_input,
|
||||
current_provider=current_provider,
|
||||
current_model=current_model,
|
||||
explicit_provider=explicit_provider,
|
||||
)
|
||||
|
||||
|
||||
def test_same_provider_copilot_switch_recomputes_api_mode():
|
||||
"""GPT-5 → Claude on copilot: api_mode must flip to chat_completions."""
|
||||
result = _run_copilot_switch(
|
||||
raw_input="claude-opus-4.6",
|
||||
current_provider="copilot",
|
||||
current_model="gpt-5.4",
|
||||
)
|
||||
|
||||
assert result.success, f"switch_model failed: {result.error_message}"
|
||||
assert result.new_model == "claude-opus-4.6"
|
||||
assert result.target_provider == "copilot"
|
||||
assert result.api_mode == "chat_completions"
|
||||
|
||||
|
||||
def test_explicit_copilot_switch_uses_selected_model_api_mode():
|
||||
"""Cross-provider switch to copilot: api_mode from new model, not stale runtime."""
|
||||
result = _run_copilot_switch(
|
||||
raw_input="claude-opus-4.6",
|
||||
current_provider="openrouter",
|
||||
current_model="anthropic/claude-sonnet-4.6",
|
||||
explicit_provider="copilot",
|
||||
)
|
||||
|
||||
assert result.success, f"switch_model failed: {result.error_message}"
|
||||
assert result.new_model == "claude-opus-4.6"
|
||||
assert result.target_provider == "github-copilot"
|
||||
assert result.api_mode == "chat_completions"
|
||||
|
||||
|
||||
def test_copilot_gpt5_keeps_codex_responses():
|
||||
"""GPT-5 → GPT-5 on copilot: api_mode must stay codex_responses."""
|
||||
result = _run_copilot_switch(
|
||||
raw_input="gpt-5.4-mini",
|
||||
current_provider="copilot",
|
||||
current_model="gpt-5.4",
|
||||
runtime_api_mode="codex_responses",
|
||||
)
|
||||
|
||||
assert result.success, f"switch_model failed: {result.error_message}"
|
||||
assert result.new_model == "gpt-5.4-mini"
|
||||
assert result.target_provider == "copilot"
|
||||
# gpt-5.4-mini is a GPT-5 variant — should use codex_responses
|
||||
# (gpt-5-mini is the special case that uses chat_completions)
|
||||
assert result.api_mode == "codex_responses"
|
||||
@@ -156,3 +156,100 @@ def test_list_deduplicates_same_model_in_group(monkeypatch):
|
||||
assert len(my_rows) == 1
|
||||
assert my_rows[0]["models"] == ["llama3", "mistral"]
|
||||
assert my_rows[0]["total_models"] == 2
|
||||
|
||||
|
||||
def test_list_enumerates_dict_format_models_alongside_default(monkeypatch):
|
||||
"""custom_providers entry with dict-format ``models:`` plus singular
|
||||
``model:`` should surface the default and every dict key.
|
||||
|
||||
Regression: Hermes's own writer stores configured models as a dict
|
||||
keyed by model id, but the /model picker previously only honored the
|
||||
singular ``model:`` field, so multi-model custom providers appeared
|
||||
to have only the active model.
|
||||
"""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openai-codex",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "DeepSeek",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"api_mode": "chat_completions",
|
||||
"model": "deepseek-chat",
|
||||
"models": {
|
||||
"deepseek-chat": {"context_length": 128000},
|
||||
"deepseek-reasoner": {"context_length": 128000},
|
||||
},
|
||||
}
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
ds_rows = [p for p in providers if p["name"] == "DeepSeek"]
|
||||
assert len(ds_rows) == 1
|
||||
assert ds_rows[0]["models"] == ["deepseek-chat", "deepseek-reasoner"]
|
||||
assert ds_rows[0]["total_models"] == 2
|
||||
|
||||
|
||||
def test_list_enumerates_dict_format_models_without_singular_model(monkeypatch):
|
||||
"""Dict-format ``models:`` with no singular ``model:`` should still
|
||||
enumerate every dict key (previously the picker reported 0 models)."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openai-codex",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "Thor",
|
||||
"base_url": "http://thor.lab:8337/v1",
|
||||
"models": {
|
||||
"gemma-4-26B-A4B-it-MXFP4_MOE": {"context_length": 262144},
|
||||
"Qwen3.5-35B-A3B-MXFP4_MOE": {"context_length": 262144},
|
||||
"gemma-4-31B-it-Q4_K_M": {"context_length": 262144},
|
||||
},
|
||||
}
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
thor_rows = [p for p in providers if p["name"] == "Thor"]
|
||||
assert len(thor_rows) == 1
|
||||
assert set(thor_rows[0]["models"]) == {
|
||||
"gemma-4-26B-A4B-it-MXFP4_MOE",
|
||||
"Qwen3.5-35B-A3B-MXFP4_MOE",
|
||||
"gemma-4-31B-it-Q4_K_M",
|
||||
}
|
||||
assert thor_rows[0]["total_models"] == 3
|
||||
|
||||
|
||||
def test_list_dedupes_dict_model_matching_singular_default(monkeypatch):
|
||||
"""When the singular ``model:`` is also a key in the ``models:`` dict,
|
||||
it must appear exactly once in the picker."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openai-codex",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "DeepSeek",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"models": {
|
||||
"deepseek-chat": {"context_length": 128000},
|
||||
"deepseek-reasoner": {"context_length": 128000},
|
||||
},
|
||||
}
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
ds_rows = [p for p in providers if p["name"] == "DeepSeek"]
|
||||
assert ds_rows[0]["models"].count("deepseek-chat") == 1
|
||||
assert ds_rows[0]["models"] == ["deepseek-chat", "deepseek-reasoner"]
|
||||
|
||||
252
tests/hermes_cli/test_model_switch_opencode_anthropic.py
Normal file
252
tests/hermes_cli/test_model_switch_opencode_anthropic.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Regression tests for OpenCode /v1 stripping during /model switch.
|
||||
|
||||
When switching to an Anthropic-routed OpenCode model mid-session (e.g.
|
||||
``/model minimax-m2.7`` on opencode-go, or ``/model claude-sonnet-4-6``
|
||||
on opencode-zen), the resolved base_url must have its trailing ``/v1``
|
||||
stripped before being handed to the Anthropic SDK.
|
||||
|
||||
Without the strip, the SDK prepends its own ``/v1/messages`` path and
|
||||
requests hit ``https://opencode.ai/zen/go/v1/v1/messages`` — a double
|
||||
``/v1`` that returns OpenCode's website 404 page with HTML body.
|
||||
|
||||
``hermes_cli.runtime_provider.resolve_runtime_provider`` already strips
|
||||
``/v1`` at fresh agent init (PR #4918), but the ``/model`` mid-session
|
||||
switch path in ``hermes_cli.model_switch.switch_model`` was missing the
|
||||
same logic — these tests guard against that regression.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.model_switch import switch_model
|
||||
|
||||
|
||||
_MOCK_VALIDATION = {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"message": None,
|
||||
}
|
||||
|
||||
|
||||
def _run_opencode_switch(
|
||||
raw_input: str,
|
||||
current_provider: str,
|
||||
current_model: str,
|
||||
current_base_url: str,
|
||||
explicit_provider: str = "",
|
||||
runtime_base_url: str = "",
|
||||
):
|
||||
"""Run switch_model with OpenCode mocks and return the result.
|
||||
|
||||
runtime_base_url defaults to current_base_url; tests can override it
|
||||
to simulate the credential resolver returning a base_url different
|
||||
from the session's current one.
|
||||
"""
|
||||
effective_runtime_base = runtime_base_url or current_base_url
|
||||
with (
|
||||
patch("hermes_cli.model_switch.resolve_alias", return_value=None),
|
||||
patch("hermes_cli.model_switch.list_provider_models", return_value=[]),
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "sk-opencode-fake",
|
||||
"base_url": effective_runtime_base,
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.models.validate_requested_model",
|
||||
return_value=_MOCK_VALIDATION,
|
||||
),
|
||||
patch("hermes_cli.model_switch.get_model_info", return_value=None),
|
||||
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None),
|
||||
patch("hermes_cli.models.detect_provider_for_model", return_value=None),
|
||||
):
|
||||
return switch_model(
|
||||
raw_input=raw_input,
|
||||
current_provider=current_provider,
|
||||
current_model=current_model,
|
||||
current_base_url=current_base_url,
|
||||
current_api_key="sk-opencode-fake",
|
||||
explicit_provider=explicit_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestOpenCodeGoV1Strip:
|
||||
"""OpenCode Go: ``/model minimax-*`` must strip /v1."""
|
||||
|
||||
def test_switch_to_minimax_m27_strips_v1(self):
|
||||
"""GLM-5 → MiniMax-M2.7: base_url loses trailing /v1."""
|
||||
result = _run_opencode_switch(
|
||||
raw_input="minimax-m2.7",
|
||||
current_provider="opencode-go",
|
||||
current_model="glm-5",
|
||||
current_base_url="https://opencode.ai/zen/go/v1",
|
||||
)
|
||||
|
||||
assert result.success, f"switch_model failed: {result.error_message}"
|
||||
assert result.api_mode == "anthropic_messages"
|
||||
assert result.base_url == "https://opencode.ai/zen/go", (
|
||||
f"Expected /v1 stripped for anthropic_messages; got {result.base_url}"
|
||||
)
|
||||
|
||||
def test_switch_to_minimax_m25_strips_v1(self):
|
||||
"""Same behavior for M2.5."""
|
||||
result = _run_opencode_switch(
|
||||
raw_input="minimax-m2.5",
|
||||
current_provider="opencode-go",
|
||||
current_model="kimi-k2.5",
|
||||
current_base_url="https://opencode.ai/zen/go/v1",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.api_mode == "anthropic_messages"
|
||||
assert result.base_url == "https://opencode.ai/zen/go"
|
||||
|
||||
def test_switch_to_glm_leaves_v1_intact(self):
|
||||
"""OpenAI-compatible models (GLM, Kimi, MiMo) keep /v1."""
|
||||
result = _run_opencode_switch(
|
||||
raw_input="glm-5.1",
|
||||
current_provider="opencode-go",
|
||||
current_model="minimax-m2.7",
|
||||
current_base_url="https://opencode.ai/zen/go", # stripped from previous Anthropic model
|
||||
runtime_base_url="https://opencode.ai/zen/go/v1",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.api_mode == "chat_completions"
|
||||
assert result.base_url == "https://opencode.ai/zen/go/v1", (
|
||||
f"chat_completions must keep /v1; got {result.base_url}"
|
||||
)
|
||||
|
||||
def test_switch_to_kimi_leaves_v1_intact(self):
|
||||
result = _run_opencode_switch(
|
||||
raw_input="kimi-k2.5",
|
||||
current_provider="opencode-go",
|
||||
current_model="glm-5",
|
||||
current_base_url="https://opencode.ai/zen/go/v1",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.api_mode == "chat_completions"
|
||||
assert result.base_url == "https://opencode.ai/zen/go/v1"
|
||||
|
||||
def test_trailing_slash_also_stripped(self):
|
||||
"""``/v1/`` with trailing slash is also stripped cleanly."""
|
||||
result = _run_opencode_switch(
|
||||
raw_input="minimax-m2.7",
|
||||
current_provider="opencode-go",
|
||||
current_model="glm-5",
|
||||
current_base_url="https://opencode.ai/zen/go/v1/",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.api_mode == "anthropic_messages"
|
||||
assert result.base_url == "https://opencode.ai/zen/go"
|
||||
|
||||
|
||||
class TestOpenCodeZenV1Strip:
|
||||
"""OpenCode Zen: ``/model claude-*`` must strip /v1."""
|
||||
|
||||
def test_switch_to_claude_sonnet_strips_v1(self):
|
||||
"""Gemini → Claude on opencode-zen: /v1 stripped."""
|
||||
result = _run_opencode_switch(
|
||||
raw_input="claude-sonnet-4-6",
|
||||
current_provider="opencode-zen",
|
||||
current_model="gemini-3-flash",
|
||||
current_base_url="https://opencode.ai/zen/v1",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.api_mode == "anthropic_messages"
|
||||
assert result.base_url == "https://opencode.ai/zen"
|
||||
|
||||
def test_switch_to_gemini_leaves_v1_intact(self):
|
||||
"""Gemini on opencode-zen stays on chat_completions with /v1."""
|
||||
result = _run_opencode_switch(
|
||||
raw_input="gemini-3-flash",
|
||||
current_provider="opencode-zen",
|
||||
current_model="claude-sonnet-4-6",
|
||||
current_base_url="https://opencode.ai/zen", # stripped from previous Claude
|
||||
runtime_base_url="https://opencode.ai/zen/v1",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.api_mode == "chat_completions"
|
||||
assert result.base_url == "https://opencode.ai/zen/v1"
|
||||
|
||||
def test_switch_to_gpt_uses_codex_responses_keeps_v1(self):
|
||||
"""GPT on opencode-zen uses codex_responses api_mode — /v1 kept."""
|
||||
result = _run_opencode_switch(
|
||||
raw_input="gpt-5.4",
|
||||
current_provider="opencode-zen",
|
||||
current_model="claude-sonnet-4-6",
|
||||
current_base_url="https://opencode.ai/zen",
|
||||
runtime_base_url="https://opencode.ai/zen/v1",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.api_mode == "codex_responses"
|
||||
assert result.base_url == "https://opencode.ai/zen/v1"
|
||||
|
||||
|
||||
class TestAgentSwitchModelDefenseInDepth:
|
||||
"""run_agent.AIAgent.switch_model() also strips /v1 as defense-in-depth."""
|
||||
|
||||
def test_agent_switch_model_strips_v1_for_anthropic_messages(self):
|
||||
"""Even if a caller hands in a /v1 URL, the agent strips it."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
# Build a bare agent instance without running __init__; we only want
|
||||
# to exercise switch_model's base_url normalization logic.
|
||||
agent = AIAgent.__new__(AIAgent)
|
||||
agent.model = "glm-5"
|
||||
agent.provider = "opencode-go"
|
||||
agent.base_url = "https://opencode.ai/zen/go/v1"
|
||||
agent.api_key = "sk-opencode-fake"
|
||||
agent.api_mode = "chat_completions"
|
||||
agent._client_kwargs = {}
|
||||
|
||||
# Intercept the expensive client rebuild — we only need to verify
|
||||
# that base_url was normalized before it reached the Anthropic
|
||||
# client factory.
|
||||
captured = {}
|
||||
|
||||
def _fake_build_anthropic_client(api_key, base_url, **kwargs):
|
||||
captured["api_key"] = api_key
|
||||
captured["base_url"] = base_url
|
||||
return object() # placeholder client — no real calls expected
|
||||
|
||||
# The downstream cache/plumbing touches a bunch of private state
|
||||
# that wasn't initialized above; we don't want to rebuild the full
|
||||
# runtime for this single assertion, so short-circuit after the
|
||||
# strip by raising inside the stubbed factory.
|
||||
class _Sentinel(Exception):
|
||||
pass
|
||||
|
||||
def _raise_after_capture(api_key, base_url, **kwargs):
|
||||
captured["api_key"] = api_key
|
||||
captured["base_url"] = base_url
|
||||
raise _Sentinel("strip verified")
|
||||
|
||||
with patch(
|
||||
"agent.anthropic_adapter.build_anthropic_client",
|
||||
side_effect=_raise_after_capture,
|
||||
), patch("agent.anthropic_adapter.resolve_anthropic_token", return_value=""), patch(
|
||||
"agent.anthropic_adapter._is_oauth_token", return_value=False
|
||||
):
|
||||
with pytest.raises(_Sentinel):
|
||||
agent.switch_model(
|
||||
new_model="minimax-m2.7",
|
||||
new_provider="opencode-go",
|
||||
api_key="sk-opencode-fake",
|
||||
base_url="https://opencode.ai/zen/go/v1",
|
||||
api_mode="anthropic_messages",
|
||||
)
|
||||
|
||||
assert captured.get("base_url") == "https://opencode.ai/zen/go", (
|
||||
f"agent.switch_model did not strip /v1; passed {captured.get('base_url')} "
|
||||
"to build_anthropic_client"
|
||||
)
|
||||
@@ -163,7 +163,7 @@ class TestNormalizeProvider:
|
||||
class TestProviderLabel:
|
||||
def test_known_labels_and_auto(self):
|
||||
assert provider_label("anthropic") == "Anthropic"
|
||||
assert provider_label("kimi") == "Kimi / Moonshot"
|
||||
assert provider_label("kimi") == "Kimi / Kimi Coding Plan"
|
||||
assert provider_label("copilot") == "GitHub Copilot"
|
||||
assert provider_label("copilot-acp") == "GitHub Copilot ACP"
|
||||
assert provider_label("auto") == "Auto"
|
||||
@@ -370,6 +370,8 @@ class TestCopilotNormalization:
|
||||
assert opencode_model_api_mode("opencode-zen", "minimax-m2.5") == "chat_completions"
|
||||
|
||||
def test_opencode_go_api_modes_match_docs(self):
|
||||
assert opencode_model_api_mode("opencode-go", "glm-5.1") == "chat_completions"
|
||||
assert opencode_model_api_mode("opencode-go", "opencode-go/glm-5.1") == "chat_completions"
|
||||
assert opencode_model_api_mode("opencode-go", "glm-5") == "chat_completions"
|
||||
assert opencode_model_api_mode("opencode-go", "opencode-go/glm-5") == "chat_completions"
|
||||
assert opencode_model_api_mode("opencode-go", "kimi-k2.5") == "chat_completions"
|
||||
@@ -401,7 +403,8 @@ class TestValidateFormatChecks:
|
||||
|
||||
def test_no_slash_model_rejected_if_not_in_api(self):
|
||||
result = _validate("gpt-5.4", api_models=["openai/gpt-5.4"])
|
||||
assert result["accepted"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
|
||||
@@ -427,10 +430,10 @@ class TestValidateApiFound:
|
||||
# -- validate — API not found ------------------------------------------------
|
||||
|
||||
class TestValidateApiNotFound:
|
||||
def test_model_not_in_api_accepted_with_warning(self):
|
||||
def test_model_not_in_api_rejected_with_guidance(self):
|
||||
result = _validate("anthropic/claude-nonexistent")
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
def test_warning_includes_suggestions(self):
|
||||
@@ -447,37 +450,36 @@ class TestValidateApiNotFound:
|
||||
assert result["recognized"] is True
|
||||
|
||||
def test_dissimilar_model_shows_suggestions_not_autocorrect(self):
|
||||
"""Models too different for auto-correction still get suggestions."""
|
||||
"""Models too different for auto-correction are rejected with suggestions."""
|
||||
result = _validate("anthropic/claude-nonexistent")
|
||||
assert result["accepted"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result.get("corrected_model") is None
|
||||
assert "not found" in result["message"]
|
||||
|
||||
|
||||
# -- validate — API unreachable — accept and persist everything ----------------
|
||||
# -- validate — API unreachable — reject with guidance ----------------
|
||||
|
||||
class TestValidateApiFallback:
|
||||
def test_any_model_accepted_when_api_down(self):
|
||||
def test_any_model_rejected_when_api_down(self):
|
||||
result = _validate("anthropic/claude-opus-4.6", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
|
||||
def test_unknown_model_also_accepted_when_api_down(self):
|
||||
"""No hardcoded catalog gatekeeping — accept, persist, and warn."""
|
||||
def test_unknown_model_also_rejected_when_api_down(self):
|
||||
result = _validate("anthropic/claude-next-gen", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "could not reach" in result["message"].lower()
|
||||
|
||||
def test_zai_model_accepted_when_api_down(self):
|
||||
def test_zai_model_rejected_when_api_down(self):
|
||||
result = _validate("glm-5", provider="zai", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
|
||||
def test_unknown_provider_accepted_when_api_down(self):
|
||||
def test_unknown_provider_rejected_when_api_down(self):
|
||||
result = _validate("some-model", provider="totally-unknown", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
|
||||
def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self):
|
||||
with patch(
|
||||
@@ -497,8 +499,8 @@ class TestValidateApiFallback:
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "http://localhost:8000/v1/models" in result["message"]
|
||||
assert "http://localhost:8000/v1" in result["message"]
|
||||
|
||||
@@ -530,11 +532,71 @@ class TestValidateCodexAutoCorrection:
|
||||
assert result["message"] is None
|
||||
|
||||
def test_very_different_name_falls_to_suggestions(self):
|
||||
"""Names too different for auto-correction get the suggestion list."""
|
||||
"""Names too different for auto-correction are rejected with a suggestion list."""
|
||||
codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex"]
|
||||
with patch("hermes_cli.models.provider_model_ids", return_value=codex_models):
|
||||
result = validate_requested_model("totally-wrong", "openai-codex")
|
||||
assert result["accepted"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["recognized"] is False
|
||||
assert result.get("corrected_model") is None
|
||||
assert "not found" in result["message"]
|
||||
|
||||
|
||||
# -- probe_api_models — Cloudflare UA mitigation --------------------------------
|
||||
|
||||
class TestProbeApiModelsUserAgent:
|
||||
"""Probing custom /v1/models must send a Hermes User-Agent.
|
||||
|
||||
Some custom Claude proxies (e.g. ``packyapi.com``) sit behind Cloudflare with
|
||||
Browser Integrity Check enabled. The default ``Python-urllib/3.x`` signature
|
||||
is rejected with HTTP 403 ``error code: 1010``, which ``probe_api_models``
|
||||
swallowed into ``{"models": None}``, surfacing to users as a misleading
|
||||
"Could not reach the ... API to validate ..." error — even though the
|
||||
endpoint is reachable and the listing exists.
|
||||
"""
|
||||
|
||||
def _make_mock_response(self, body: bytes):
|
||||
from unittest.mock import MagicMock
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_resp.read = MagicMock(return_value=body)
|
||||
return mock_resp
|
||||
|
||||
def test_probe_sends_hermes_user_agent(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
body = b'{"data":[{"id":"claude-opus-4.7"}]}'
|
||||
with patch(
|
||||
"hermes_cli.models.urllib.request.urlopen",
|
||||
return_value=self._make_mock_response(body),
|
||||
) as mock_urlopen:
|
||||
result = probe_api_models("sk-test", "https://example.com/v1")
|
||||
|
||||
assert result["models"] == ["claude-opus-4.7"]
|
||||
# The urlopen call receives a Request object as its first positional arg
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
ua = req.get_header("User-agent") # urllib title-cases header names
|
||||
assert ua, "probe_api_models must send a User-Agent header"
|
||||
assert ua.startswith("hermes-cli/"), (
|
||||
f"User-Agent must advertise hermes-cli, got {ua!r}"
|
||||
)
|
||||
# Must not fall back to urllib's default — that's what Cloudflare 1010 blocks.
|
||||
assert not ua.startswith("Python-urllib")
|
||||
|
||||
def test_probe_user_agent_sent_without_api_key(self):
|
||||
"""UA must be present even for endpoints that don't need auth."""
|
||||
from unittest.mock import patch
|
||||
|
||||
body = b'{"data":[]}'
|
||||
with patch(
|
||||
"hermes_cli.models.urllib.request.urlopen",
|
||||
return_value=self._make_mock_response(body),
|
||||
) as mock_urlopen:
|
||||
probe_api_models(None, "https://example.com/v1")
|
||||
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
ua = req.get_header("User-agent")
|
||||
assert ua and ua.startswith("hermes-cli/")
|
||||
# No Authorization was set, but UA must still be present.
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
83
tests/hermes_cli/test_non_ascii_credential.py
Normal file
83
tests/hermes_cli/test_non_ascii_credential.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Tests for non-ASCII credential detection and sanitization.
|
||||
|
||||
Covers the fix for issue #6843 — API keys containing Unicode lookalike
|
||||
characters (e.g. ʋ U+028B instead of v) cause UnicodeEncodeError when
|
||||
httpx tries to encode the Authorization header as ASCII.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import _check_non_ascii_credential
|
||||
|
||||
|
||||
class TestCheckNonAsciiCredential:
|
||||
"""Tests for _check_non_ascii_credential()."""
|
||||
|
||||
def test_ascii_key_unchanged(self):
|
||||
key = "sk-proj-" + "a" * 100
|
||||
result = _check_non_ascii_credential("TEST_API_KEY", key)
|
||||
assert result == key
|
||||
|
||||
def test_strips_unicode_v_lookalike(self, capsys):
|
||||
"""The exact scenario from issue #6843: ʋ instead of v."""
|
||||
key = "sk-proj-abc" + "ʋ" + "def" # \u028b
|
||||
result = _check_non_ascii_credential("OPENROUTER_API_KEY", key)
|
||||
assert result == "sk-proj-abcdef"
|
||||
assert "ʋ" not in result
|
||||
# Should print a warning
|
||||
captured = capsys.readouterr()
|
||||
assert "non-ASCII" in captured.err
|
||||
|
||||
def test_strips_multiple_non_ascii(self, capsys):
|
||||
key = "sk-proj-aʋbécd"
|
||||
result = _check_non_ascii_credential("OPENAI_API_KEY", key)
|
||||
assert result == "sk-proj-abcd"
|
||||
captured = capsys.readouterr()
|
||||
assert "U+028B" in captured.err # reports the char
|
||||
|
||||
def test_empty_key(self):
|
||||
result = _check_non_ascii_credential("TEST_KEY", "")
|
||||
assert result == ""
|
||||
|
||||
def test_all_ascii_no_warning(self, capsys):
|
||||
result = _check_non_ascii_credential("KEY", "all-ascii-value-123")
|
||||
assert result == "all-ascii-value-123"
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
class TestEnvLoaderSanitization:
|
||||
"""Tests for _sanitize_loaded_credentials in env_loader."""
|
||||
|
||||
def test_strips_non_ascii_from_api_key(self, monkeypatch):
|
||||
from hermes_cli.env_loader import _sanitize_loaded_credentials
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-proj-abcʋdef")
|
||||
_sanitize_loaded_credentials()
|
||||
assert os.environ["OPENROUTER_API_KEY"] == "sk-proj-abcdef"
|
||||
|
||||
def test_strips_non_ascii_from_token(self, monkeypatch):
|
||||
from hermes_cli.env_loader import _sanitize_loaded_credentials
|
||||
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tokénvalue")
|
||||
_sanitize_loaded_credentials()
|
||||
assert os.environ["DISCORD_BOT_TOKEN"] == "toknvalue"
|
||||
|
||||
def test_ignores_non_credential_vars(self, monkeypatch):
|
||||
from hermes_cli.env_loader import _sanitize_loaded_credentials
|
||||
|
||||
monkeypatch.setenv("MY_UNICODE_VAR", "héllo wörld")
|
||||
_sanitize_loaded_credentials()
|
||||
# Not a credential suffix — should be left alone
|
||||
assert os.environ["MY_UNICODE_VAR"] == "héllo wörld"
|
||||
|
||||
def test_ascii_credentials_untouched(self, monkeypatch):
|
||||
from hermes_cli.env_loader import _sanitize_loaded_credentials
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-proj-allascii123")
|
||||
_sanitize_loaded_credentials()
|
||||
assert os.environ["OPENAI_API_KEY"] == "sk-proj-allascii123"
|
||||
@@ -24,7 +24,7 @@ def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatc
|
||||
|
||||
|
||||
def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
|
||||
410
tests/hermes_cli/test_ollama_cloud_provider.py
Normal file
410
tests/hermes_cli/test_ollama_cloud_provider.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""Tests for Ollama Cloud provider integration."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider, resolve_api_key_provider_credentials
|
||||
from hermes_cli.models import _PROVIDER_MODELS, _PROVIDER_LABELS, _PROVIDER_ALIASES, normalize_provider
|
||||
from hermes_cli.model_normalize import normalize_model_for_provider
|
||||
from agent.model_metadata import _URL_TO_PROVIDER, _PROVIDER_PREFIXES
|
||||
from agent.models_dev import PROVIDER_TO_MODELS_DEV, list_agentic_models
|
||||
|
||||
|
||||
# ── Provider Registry ──
|
||||
|
||||
class TestOllamaCloudProviderRegistry:
|
||||
def test_ollama_cloud_in_registry(self):
|
||||
assert "ollama-cloud" in PROVIDER_REGISTRY
|
||||
|
||||
def test_ollama_cloud_config(self):
|
||||
pconfig = PROVIDER_REGISTRY["ollama-cloud"]
|
||||
assert pconfig.id == "ollama-cloud"
|
||||
assert pconfig.name == "Ollama Cloud"
|
||||
assert pconfig.auth_type == "api_key"
|
||||
assert pconfig.inference_base_url == "https://ollama.com/v1"
|
||||
|
||||
def test_ollama_cloud_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["ollama-cloud"]
|
||||
assert pconfig.api_key_env_vars == ("OLLAMA_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "OLLAMA_BASE_URL"
|
||||
|
||||
def test_ollama_cloud_base_url(self):
|
||||
assert "ollama.com" in PROVIDER_REGISTRY["ollama-cloud"].inference_base_url
|
||||
|
||||
|
||||
# ── Provider Aliases ──
|
||||
|
||||
PROVIDER_ENV_VARS = (
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"GOOGLE_API_KEY", "GEMINI_API_KEY", "OLLAMA_API_KEY",
|
||||
"GLM_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY",
|
||||
"MINIMAX_API_KEY", "DEEPSEEK_API_KEY",
|
||||
)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_provider_env(monkeypatch):
|
||||
for var in PROVIDER_ENV_VARS:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
class TestOllamaCloudAliases:
|
||||
def test_explicit_ollama_cloud(self):
|
||||
assert resolve_provider("ollama-cloud") == "ollama-cloud"
|
||||
|
||||
def test_alias_ollama_underscore(self):
|
||||
"""ollama_cloud (underscore) is the unambiguous cloud alias."""
|
||||
assert resolve_provider("ollama_cloud") == "ollama-cloud"
|
||||
|
||||
def test_bare_ollama_stays_local(self):
|
||||
"""Bare 'ollama' alias routes to 'custom' (local) — not cloud."""
|
||||
assert resolve_provider("ollama") == "custom"
|
||||
|
||||
def test_models_py_aliases(self):
|
||||
assert _PROVIDER_ALIASES.get("ollama_cloud") == "ollama-cloud"
|
||||
# bare "ollama" stays local
|
||||
assert _PROVIDER_ALIASES.get("ollama") == "custom"
|
||||
|
||||
def test_normalize_provider(self):
|
||||
assert normalize_provider("ollama-cloud") == "ollama-cloud"
|
||||
|
||||
|
||||
# ── Auto-detection ──
|
||||
|
||||
class TestOllamaCloudAutoDetection:
|
||||
def test_auto_detects_ollama_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-ollama-key")
|
||||
assert resolve_provider("auto") == "ollama-cloud"
|
||||
|
||||
|
||||
# ── Credential Resolution ──
|
||||
|
||||
class TestOllamaCloudCredentials:
|
||||
def test_resolve_with_ollama_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-secret")
|
||||
creds = resolve_api_key_provider_credentials("ollama-cloud")
|
||||
assert creds["provider"] == "ollama-cloud"
|
||||
assert creds["api_key"] == "ollama-secret"
|
||||
assert creds["base_url"] == "https://ollama.com/v1"
|
||||
|
||||
def test_resolve_with_custom_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "key")
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "https://custom.ollama/v1")
|
||||
creds = resolve_api_key_provider_credentials("ollama-cloud")
|
||||
assert creds["base_url"] == "https://custom.ollama/v1"
|
||||
|
||||
def test_runtime_ollama_cloud(self, monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="ollama-cloud")
|
||||
assert result["provider"] == "ollama-cloud"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "ollama-key"
|
||||
assert result["base_url"] == "https://ollama.com/v1"
|
||||
|
||||
|
||||
# ── Model Catalog (dynamic — no static list) ──
|
||||
|
||||
class TestOllamaCloudModelCatalog:
|
||||
def test_no_static_model_list(self):
|
||||
"""Ollama Cloud models are fetched dynamically — no static list to maintain."""
|
||||
assert "ollama-cloud" not in _PROVIDER_MODELS
|
||||
|
||||
def test_provider_label(self):
|
||||
assert "ollama-cloud" in _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS["ollama-cloud"] == "Ollama Cloud"
|
||||
|
||||
def test_provider_model_ids_returns_dynamic_models(self, tmp_path, monkeypatch):
|
||||
"""provider_model_ids('ollama-cloud') should call fetch_ollama_cloud_models()."""
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {
|
||||
"qwen3.5:397b": {"tool_call": True},
|
||||
"glm-5": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.5:397b"]), \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = provider_model_ids("ollama-cloud", force_refresh=True)
|
||||
|
||||
assert len(result) > 0
|
||||
assert "qwen3.5:397b" in result
|
||||
|
||||
|
||||
# ── Model Picker (list_authenticated_providers) ──
|
||||
|
||||
class TestOllamaCloudModelPicker:
|
||||
def test_ollama_cloud_shows_model_count(self, tmp_path, monkeypatch):
|
||||
"""Ollama Cloud should show non-zero model count in provider picker."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {
|
||||
"qwen3.5:397b": {"tool_call": True},
|
||||
"glm-5": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.5:397b"]), \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
providers = list_authenticated_providers(current_provider="ollama-cloud")
|
||||
|
||||
ollama = next((p for p in providers if p["slug"] == "ollama-cloud"), None)
|
||||
assert ollama is not None, "ollama-cloud should appear when OLLAMA_API_KEY is set"
|
||||
assert ollama["total_models"] > 0, "ollama-cloud should show non-zero model count"
|
||||
|
||||
def test_ollama_cloud_not_shown_without_creds(self, monkeypatch):
|
||||
"""Ollama Cloud should not appear without credentials."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
|
||||
providers = list_authenticated_providers(current_provider="openrouter")
|
||||
ollama = next((p for p in providers if p["slug"] == "ollama-cloud"), None)
|
||||
assert ollama is None, "ollama-cloud should not appear without OLLAMA_API_KEY"
|
||||
|
||||
|
||||
# ── Merged Model Discovery ──
|
||||
|
||||
class TestOllamaCloudMergedDiscovery:
|
||||
def test_merges_live_and_models_dev(self, tmp_path, monkeypatch):
|
||||
"""Live API models appear first, models.dev additions fill gaps."""
|
||||
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": {
|
||||
"glm-5": {"tool_call": True},
|
||||
"kimi-k2.5": {"tool_call": True},
|
||||
"nemotron-3-super": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.5:397b", "glm-5"]), \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
# Live models first, then models.dev additions (deduped)
|
||||
assert result[0] == "qwen3.5:397b" # from live API
|
||||
assert result[1] == "glm-5" # from live API (also in models.dev)
|
||||
assert "kimi-k2.5" in result # from models.dev only
|
||||
assert "nemotron-3-super" in result # from models.dev only
|
||||
assert result.count("glm-5") == 1 # no duplicates
|
||||
|
||||
def test_falls_back_to_models_dev_without_api_key(self, tmp_path, monkeypatch):
|
||||
"""Without API key, only models.dev results are returned."""
|
||||
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": {
|
||||
"glm-5": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert result == ["glm-5"]
|
||||
|
||||
def test_uses_disk_cache(self, tmp_path, monkeypatch):
|
||||
"""Second call returns cached results without hitting APIs."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["model-a"]) as mock_api, \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value={}):
|
||||
first = fetch_ollama_cloud_models(force_refresh=True)
|
||||
assert first == ["model-a"]
|
||||
assert mock_api.call_count == 1
|
||||
|
||||
# Second call — should use disk cache, not call API
|
||||
second = fetch_ollama_cloud_models()
|
||||
assert second == ["model-a"]
|
||||
assert mock_api.call_count == 1 # no extra API call
|
||||
|
||||
def test_force_refresh_bypasses_cache(self, tmp_path, monkeypatch):
|
||||
"""force_refresh=True always hits the API even with fresh cache."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["model-a"]) as mock_api, \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value={}):
|
||||
fetch_ollama_cloud_models(force_refresh=True)
|
||||
fetch_ollama_cloud_models(force_refresh=True)
|
||||
assert mock_api.call_count == 2
|
||||
|
||||
def test_stale_cache_used_on_total_failure(self, tmp_path, monkeypatch):
|
||||
"""If both API and models.dev fail, stale cache is returned."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models, _save_ollama_cloud_cache
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
|
||||
# Pre-populate a stale cache
|
||||
_save_ollama_cloud_cache(["stale-model"])
|
||||
|
||||
# Make the cache appear stale by backdating it
|
||||
import json
|
||||
cache_path = tmp_path / "ollama_cloud_models_cache.json"
|
||||
with open(cache_path) as f:
|
||||
data = json.load(f)
|
||||
data["cached_at"] = 0 # epoch = very stale
|
||||
with open(cache_path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=None), \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value={}):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert result == ["stale-model"]
|
||||
|
||||
def test_empty_on_total_failure_no_cache(self, tmp_path, monkeypatch):
|
||||
"""Returns empty list when everything fails and no cache exists."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value={}):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
# ── Model Normalization ──
|
||||
|
||||
class TestOllamaCloudModelNormalization:
|
||||
def test_passthrough_bare_name(self):
|
||||
"""Ollama Cloud is a passthrough provider — model names used as-is."""
|
||||
assert normalize_model_for_provider("qwen3.5:397b", "ollama-cloud") == "qwen3.5:397b"
|
||||
|
||||
def test_passthrough_with_tag(self):
|
||||
assert normalize_model_for_provider("cogito-2.1:671b", "ollama-cloud") == "cogito-2.1:671b"
|
||||
|
||||
def test_passthrough_no_tag(self):
|
||||
assert normalize_model_for_provider("glm-5", "ollama-cloud") == "glm-5"
|
||||
|
||||
|
||||
# ── URL-to-Provider Mapping ──
|
||||
|
||||
class TestOllamaCloudUrlMapping:
|
||||
def test_url_to_provider(self):
|
||||
assert _URL_TO_PROVIDER.get("ollama.com") == "ollama-cloud"
|
||||
|
||||
def test_provider_prefix_canonical(self):
|
||||
assert "ollama-cloud" in _PROVIDER_PREFIXES
|
||||
|
||||
def test_provider_prefix_alias(self):
|
||||
assert "ollama" in _PROVIDER_PREFIXES
|
||||
|
||||
|
||||
# ── models.dev Integration ──
|
||||
|
||||
class TestOllamaCloudModelsDev:
|
||||
def test_ollama_cloud_mapped(self):
|
||||
assert PROVIDER_TO_MODELS_DEV.get("ollama-cloud") == "ollama-cloud"
|
||||
|
||||
def test_list_agentic_models_with_mock_data(self):
|
||||
"""list_agentic_models filters correctly from mock models.dev data."""
|
||||
mock_data = {
|
||||
"ollama-cloud": {
|
||||
"models": {
|
||||
"qwen3.5:397b": {"tool_call": True},
|
||||
"glm-5": {"tool_call": True},
|
||||
"nemotron-3-nano:30b": {"tool_call": True},
|
||||
"some-embedding:latest": {"tool_call": False},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_data):
|
||||
result = list_agentic_models("ollama-cloud")
|
||||
assert "qwen3.5:397b" in result
|
||||
assert "glm-5" in result
|
||||
assert "nemotron-3-nano:30b" in result
|
||||
assert "some-embedding:latest" not in result # no tool_call
|
||||
|
||||
|
||||
# ── Agent Init (no SyntaxError) ──
|
||||
|
||||
class TestOllamaCloudAgentInit:
|
||||
def test_agent_imports_without_error(self):
|
||||
"""Verify run_agent.py has no SyntaxError."""
|
||||
import importlib
|
||||
import run_agent
|
||||
importlib.reload(run_agent)
|
||||
|
||||
def test_ollama_cloud_agent_uses_chat_completions(self, monkeypatch):
|
||||
"""Ollama Cloud falls through to chat_completions — no special elif needed."""
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
with patch("run_agent.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent(
|
||||
model="qwen3.5:397b",
|
||||
provider="ollama-cloud",
|
||||
api_key="test-key",
|
||||
base_url="https://ollama.com/v1",
|
||||
)
|
||||
assert agent.api_mode == "chat_completions"
|
||||
assert agent.provider == "ollama-cloud"
|
||||
|
||||
|
||||
# ── providers.py New System ──
|
||||
|
||||
class TestOllamaCloudProvidersNew:
|
||||
def test_overlay_exists(self):
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
assert "ollama-cloud" in HERMES_OVERLAYS
|
||||
overlay = HERMES_OVERLAYS["ollama-cloud"]
|
||||
assert overlay.transport == "openai_chat"
|
||||
assert overlay.base_url_env_var == "OLLAMA_BASE_URL"
|
||||
|
||||
def test_alias_resolves(self):
|
||||
from hermes_cli.providers import normalize_provider as np
|
||||
assert np("ollama") == "custom" # bare "ollama" = local
|
||||
assert np("ollama-cloud") == "ollama-cloud"
|
||||
|
||||
def test_label_override(self):
|
||||
from hermes_cli.providers import _LABEL_OVERRIDES
|
||||
assert _LABEL_OVERRIDES.get("ollama-cloud") == "Ollama Cloud"
|
||||
|
||||
def test_get_label(self):
|
||||
from hermes_cli.providers import get_label
|
||||
assert get_label("ollama-cloud") == "Ollama Cloud"
|
||||
|
||||
def test_get_provider(self):
|
||||
from hermes_cli.providers import get_provider
|
||||
pdef = get_provider("ollama-cloud")
|
||||
assert pdef is not None
|
||||
assert pdef.id == "ollama-cloud"
|
||||
assert pdef.transport == "openai_chat"
|
||||
|
||||
|
||||
# ── Auxiliary Model ──
|
||||
|
||||
class TestOllamaCloudAuxiliary:
|
||||
def test_aux_model_defined(self):
|
||||
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
|
||||
assert "ollama-cloud" in _API_KEY_PROVIDER_AUX_MODELS
|
||||
assert _API_KEY_PROVIDER_AUX_MODELS["ollama-cloud"] == "nemotron-3-nano:30b"
|
||||
@@ -15,7 +15,7 @@ def test_opencode_go_appears_when_api_key_set():
|
||||
opencode_go = next((p for p in providers if p["slug"] == "opencode-go"), None)
|
||||
|
||||
assert opencode_go is not None, "opencode-go should appear when OPENCODE_GO_API_KEY is set"
|
||||
assert opencode_go["models"] == ["glm-5", "kimi-k2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
|
||||
assert opencode_go["models"] == ["kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
|
||||
# opencode-go can appear as "built-in" (from PROVIDER_TO_MODELS_DEV when
|
||||
# models.dev is reachable) or "hermes" (from HERMES_OVERLAYS fallback when
|
||||
# the API is unavailable, e.g. in CI).
|
||||
|
||||
@@ -173,60 +173,6 @@ class TestMemoryPluginCliDiscovery:
|
||||
# ── Honcho register_cli ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHonchoRegisterCli:
|
||||
def test_builds_subcommand_tree(self):
|
||||
"""register_cli creates the expected subparser tree."""
|
||||
from plugins.memory.honcho.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
|
||||
# Verify key subcommands exist by parsing them
|
||||
args = parser.parse_args(["status"])
|
||||
assert args.honcho_command == "status"
|
||||
|
||||
args = parser.parse_args(["peer", "--user", "alice"])
|
||||
assert args.honcho_command == "peer"
|
||||
assert args.user == "alice"
|
||||
|
||||
args = parser.parse_args(["mode", "tools"])
|
||||
assert args.honcho_command == "mode"
|
||||
assert args.mode == "tools"
|
||||
|
||||
args = parser.parse_args(["tokens", "--context", "500"])
|
||||
assert args.honcho_command == "tokens"
|
||||
assert args.context == 500
|
||||
|
||||
args = parser.parse_args(["--target-profile", "coder", "status"])
|
||||
assert args.target_profile == "coder"
|
||||
assert args.honcho_command == "status"
|
||||
|
||||
def test_setup_redirects_to_memory_setup(self):
|
||||
"""hermes honcho setup redirects to memory setup."""
|
||||
from plugins.memory.honcho.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
args = parser.parse_args(["setup"])
|
||||
assert args.honcho_command == "setup"
|
||||
|
||||
def test_mode_choices_are_recall_modes(self):
|
||||
"""Mode subcommand uses recall mode choices (hybrid/context/tools)."""
|
||||
from plugins.memory.honcho.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
|
||||
# Valid recall modes should parse
|
||||
for mode in ("hybrid", "context", "tools"):
|
||||
args = parser.parse_args(["mode", mode])
|
||||
assert args.mode == mode
|
||||
|
||||
# Old memoryMode values should fail
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["mode", "honcho"])
|
||||
|
||||
|
||||
# ── ProviderCollector no-op ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from hermes_cli.plugins import (
|
||||
PluginManager,
|
||||
PluginManifest,
|
||||
get_plugin_manager,
|
||||
get_plugin_command_handler,
|
||||
get_plugin_commands,
|
||||
get_pre_tool_call_block_message,
|
||||
discover_plugins,
|
||||
invoke_hook,
|
||||
@@ -28,8 +30,19 @@ from hermes_cli.plugins import (
|
||||
|
||||
|
||||
def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
|
||||
manifest_extra: dict | None = None) -> Path:
|
||||
"""Create a minimal plugin directory with plugin.yaml + __init__.py."""
|
||||
manifest_extra: dict | None = None,
|
||||
auto_enable: bool = True) -> Path:
|
||||
"""Create a minimal plugin directory with plugin.yaml + __init__.py.
|
||||
|
||||
If *auto_enable* is True (default), also write the plugin's name into
|
||||
``<hermes_home>/config.yaml`` under ``plugins.enabled``. Plugins are
|
||||
opt-in by default, so tests that expect the plugin to actually load
|
||||
need this. Pass ``auto_enable=False`` for tests that exercise the
|
||||
unenabled path.
|
||||
|
||||
*base* is expected to be ``<hermes_home>/plugins/``; we derive
|
||||
``<hermes_home>`` from it by walking one level up.
|
||||
"""
|
||||
plugin_dir = base / name
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -41,6 +54,31 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
f"def register(ctx):\n {register_body}\n"
|
||||
)
|
||||
|
||||
if auto_enable:
|
||||
# Write/merge plugins.enabled in <HERMES_HOME>/config.yaml.
|
||||
# Config is always read from HERMES_HOME (not from the project
|
||||
# dir for project plugins), so that's where we opt in.
|
||||
import os
|
||||
hermes_home_str = os.environ.get("HERMES_HOME")
|
||||
if hermes_home_str:
|
||||
hermes_home = Path(hermes_home_str)
|
||||
else:
|
||||
hermes_home = base.parent
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path = hermes_home / "config.yaml"
|
||||
cfg: dict = {}
|
||||
if cfg_path.exists():
|
||||
try:
|
||||
cfg = yaml.safe_load(cfg_path.read_text()) or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
plugins_cfg = cfg.setdefault("plugins", {})
|
||||
enabled = plugins_cfg.setdefault("enabled", [])
|
||||
if isinstance(enabled, list) and name not in enabled:
|
||||
enabled.append(name)
|
||||
cfg_path.write_text(yaml.safe_dump(cfg))
|
||||
|
||||
return plugin_dir
|
||||
|
||||
|
||||
@@ -100,7 +138,12 @@ class TestPluginDiscovery:
|
||||
mgr.discover_and_load()
|
||||
mgr.discover_and_load() # second call should no-op
|
||||
|
||||
assert len(mgr._plugins) == 1
|
||||
# Filter out bundled plugins — they're always discovered.
|
||||
non_bundled = {
|
||||
n: p for n, p in mgr._plugins.items()
|
||||
if p.manifest.source != "bundled"
|
||||
}
|
||||
assert len(non_bundled) == 1
|
||||
|
||||
def test_discover_skips_dir_without_manifest(self, tmp_path, monkeypatch):
|
||||
"""Directories without plugin.yaml are silently skipped."""
|
||||
@@ -111,7 +154,12 @@ class TestPluginDiscovery:
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert len(mgr._plugins) == 0
|
||||
# Filter out bundled plugins — they're always discovered.
|
||||
non_bundled = {
|
||||
n: p for n, p in mgr._plugins.items()
|
||||
if p.manifest.source != "bundled"
|
||||
}
|
||||
assert len(non_bundled) == 0
|
||||
|
||||
def test_entry_points_scanned(self, tmp_path, monkeypatch):
|
||||
"""Entry-point based plugins are discovered (mocked)."""
|
||||
@@ -150,7 +198,13 @@ class TestPluginLoading:
|
||||
plugin_dir = plugins_dir / "bad_plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "bad_plugin"}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
# Explicitly enable so the loader tries to import it and hits the
|
||||
# missing-init error.
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["bad_plugin"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
@@ -158,6 +212,8 @@ class TestPluginLoading:
|
||||
assert "bad_plugin" in mgr._plugins
|
||||
assert not mgr._plugins["bad_plugin"].enabled
|
||||
assert mgr._plugins["bad_plugin"].error is not None
|
||||
# Should be the missing-init error, not "not enabled".
|
||||
assert "not enabled" not in mgr._plugins["bad_plugin"].error
|
||||
|
||||
def test_load_missing_register_fn(self, tmp_path, monkeypatch):
|
||||
"""Plugin without register() function records an error."""
|
||||
@@ -166,7 +222,12 @@ class TestPluginLoading:
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "no_reg"}))
|
||||
(plugin_dir / "__init__.py").write_text("# no register function\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
# Explicitly enable it so the loader actually tries to import.
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["no_reg"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
@@ -199,6 +260,8 @@ class TestPluginHooks:
|
||||
def test_valid_hooks_include_request_scoped_api_hooks(self):
|
||||
assert "pre_api_request" in VALID_HOOKS
|
||||
assert "post_api_request" in VALID_HOOKS
|
||||
assert "transform_terminal_output" in VALID_HOOKS
|
||||
assert "transform_tool_result" in VALID_HOOKS
|
||||
|
||||
def test_register_and_invoke_hook(self, tmp_path, monkeypatch):
|
||||
"""Registered hooks are called on invoke_hook()."""
|
||||
@@ -295,6 +358,30 @@ class TestPluginHooks:
|
||||
)
|
||||
assert results == [{"seen": 2, "mc": 5, "tc": 3}]
|
||||
|
||||
def test_transform_terminal_output_hook_can_be_registered_and_invoked(self, tmp_path, monkeypatch):
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "transform_hook",
|
||||
register_body=(
|
||||
'ctx.register_hook("transform_terminal_output", '
|
||||
'lambda **kw: f"{kw[\'command\']}|{kw[\'returncode\']}|{kw[\'env_type\']}|{kw[\'task_id\']}|{len(kw[\'output\'])}")'
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
results = mgr.invoke_hook(
|
||||
"transform_terminal_output",
|
||||
command="echo hello",
|
||||
output="abcdef",
|
||||
returncode=7,
|
||||
task_id="task-1",
|
||||
env_type="local",
|
||||
)
|
||||
assert results == ["echo hello|7|local|task-1|6"]
|
||||
|
||||
def test_invalid_hook_name_warns(self, tmp_path, monkeypatch, caplog):
|
||||
"""Registering an unknown hook name logs a warning."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
@@ -376,7 +463,11 @@ class TestPluginContext:
|
||||
' handler=lambda args, **kw: "echo",\n'
|
||||
' )\n'
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["tool_plugin"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
@@ -410,7 +501,11 @@ class TestPluginToolVisibility:
|
||||
' handler=lambda args, **kw: "ok",\n'
|
||||
' )\n'
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["vis_plugin"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
@@ -605,7 +700,371 @@ class TestPreLlmCallTargetRouting:
|
||||
assert "plain text C" in _plugin_user_context
|
||||
|
||||
|
||||
# NOTE: TestPluginCommands removed – register_command() was never implemented
|
||||
# in PluginContext (hermes_cli/plugins.py). The tests referenced _plugin_commands,
|
||||
# commands_registered, get_plugin_command_handler, and GATEWAY_KNOWN_COMMANDS
|
||||
# integration — all of which are unimplemented features.
|
||||
# ── TestPluginCommands ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginCommands:
|
||||
"""Tests for plugin slash command registration via register_command()."""
|
||||
|
||||
def test_register_command_basic(self):
|
||||
"""register_command() stores handler, description, and plugin name."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
handler = lambda args: f"echo {args}"
|
||||
ctx.register_command("mycmd", handler, description="My custom command")
|
||||
|
||||
assert "mycmd" in mgr._plugin_commands
|
||||
entry = mgr._plugin_commands["mycmd"]
|
||||
assert entry["handler"] is handler
|
||||
assert entry["description"] == "My custom command"
|
||||
assert entry["plugin"] == "test-plugin"
|
||||
|
||||
def test_register_command_normalizes_name(self):
|
||||
"""Names are lowercased, stripped, and leading slashes removed."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
ctx.register_command("/MyCmd ", lambda a: a, description="test")
|
||||
assert "mycmd" in mgr._plugin_commands
|
||||
assert "/MyCmd " not in mgr._plugin_commands
|
||||
|
||||
def test_register_command_empty_name_rejected(self, caplog):
|
||||
"""Empty name after normalization is rejected with a warning."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
|
||||
ctx.register_command("", lambda a: a)
|
||||
assert len(mgr._plugin_commands) == 0
|
||||
assert "empty name" in caplog.text
|
||||
|
||||
def test_register_command_builtin_conflict_rejected(self, caplog):
|
||||
"""Commands that conflict with built-in names are rejected."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
|
||||
ctx.register_command("help", lambda a: a)
|
||||
assert "help" not in mgr._plugin_commands
|
||||
assert "conflicts" in caplog.text.lower()
|
||||
|
||||
def test_register_command_default_description(self):
|
||||
"""Missing description defaults to 'Plugin command'."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
ctx.register_command("status-cmd", lambda a: a)
|
||||
assert mgr._plugin_commands["status-cmd"]["description"] == "Plugin command"
|
||||
|
||||
def test_get_plugin_command_handler_found(self):
|
||||
"""get_plugin_command_handler() returns the handler for a registered command."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
handler = lambda args: f"result: {args}"
|
||||
ctx.register_command("mycmd", handler, description="test")
|
||||
|
||||
with patch("hermes_cli.plugins._plugin_manager", mgr):
|
||||
result = get_plugin_command_handler("mycmd")
|
||||
assert result is handler
|
||||
|
||||
def test_get_plugin_command_handler_not_found(self):
|
||||
"""get_plugin_command_handler() returns None for unregistered commands."""
|
||||
mgr = PluginManager()
|
||||
with patch("hermes_cli.plugins._plugin_manager", mgr):
|
||||
assert get_plugin_command_handler("nonexistent") is None
|
||||
|
||||
def test_get_plugin_commands_returns_dict(self):
|
||||
"""get_plugin_commands() returns the full commands dict."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
ctx.register_command("cmd-a", lambda a: a, description="A")
|
||||
ctx.register_command("cmd-b", lambda a: a, description="B")
|
||||
|
||||
with patch("hermes_cli.plugins._plugin_manager", mgr):
|
||||
cmds = get_plugin_commands()
|
||||
assert "cmd-a" in cmds
|
||||
assert "cmd-b" in cmds
|
||||
assert cmds["cmd-a"]["description"] == "A"
|
||||
|
||||
def test_get_plugin_command_handler_discovers_plugins_lazily(self, tmp_path, monkeypatch):
|
||||
"""Handler lookup should work before any explicit discover_plugins() call."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir,
|
||||
"cmd-plugin",
|
||||
register_body='ctx.register_command("lazycmd", lambda a: f"ok:{a}", description="Lazy")',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
import hermes_cli.plugins as plugins_mod
|
||||
|
||||
with patch.object(plugins_mod, "_plugin_manager", None):
|
||||
handler = get_plugin_command_handler("lazycmd")
|
||||
assert handler is not None
|
||||
assert handler("x") == "ok:x"
|
||||
|
||||
def test_get_plugin_commands_discovers_plugins_lazily(self, tmp_path, monkeypatch):
|
||||
"""Command listing should trigger plugin discovery on first access."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir,
|
||||
"cmd-plugin",
|
||||
register_body='ctx.register_command("lazycmd", lambda a: a, description="Lazy")',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
import hermes_cli.plugins as plugins_mod
|
||||
|
||||
with patch.object(plugins_mod, "_plugin_manager", None):
|
||||
cmds = get_plugin_commands()
|
||||
assert "lazycmd" in cmds
|
||||
assert cmds["lazycmd"]["description"] == "Lazy"
|
||||
|
||||
def test_get_plugin_context_engine_discovers_plugins_lazily(self, tmp_path, monkeypatch):
|
||||
"""Context engine lookup should work before any explicit discover_plugins() call."""
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
plugins_dir = hermes_home / "plugins"
|
||||
plugin_dir = plugins_dir / "engine-plugin"
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
yaml.dump({
|
||||
"name": "engine-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Test engine plugin",
|
||||
})
|
||||
)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"from agent.context_engine import ContextEngine\n\n"
|
||||
"class StubEngine(ContextEngine):\n"
|
||||
" @property\n"
|
||||
" def name(self):\n"
|
||||
" return 'stub-engine'\n\n"
|
||||
" def update_from_response(self, usage):\n"
|
||||
" return None\n\n"
|
||||
" def should_compress(self, prompt_tokens):\n"
|
||||
" return False\n\n"
|
||||
" def compress(self, messages, current_tokens):\n"
|
||||
" return messages\n\n"
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_context_engine(StubEngine())\n"
|
||||
)
|
||||
# Opt-in: plugins are opt-in by default, so enable in config.yaml
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["engine-plugin"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
import hermes_cli.plugins as plugins_mod
|
||||
|
||||
with patch.object(plugins_mod, "_plugin_manager", None):
|
||||
engine = plugins_mod.get_plugin_context_engine()
|
||||
assert engine is not None
|
||||
assert engine.name == "stub-engine"
|
||||
|
||||
def test_commands_tracked_on_loaded_plugin(self, tmp_path, monkeypatch):
|
||||
"""Commands registered during discover_and_load() are tracked on LoadedPlugin."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "cmd-plugin",
|
||||
register_body=(
|
||||
'ctx.register_command("mycmd", lambda a: "ok", description="Test")'
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
loaded = mgr._plugins["cmd-plugin"]
|
||||
assert loaded.enabled
|
||||
assert "mycmd" in loaded.commands_registered
|
||||
|
||||
def test_commands_in_list_plugins_output(self, tmp_path, monkeypatch):
|
||||
"""list_plugins() includes command count."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
# Set HERMES_HOME BEFORE _make_plugin_dir so auto-enable targets
|
||||
# the right config.yaml.
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "cmd-plugin",
|
||||
register_body=(
|
||||
'ctx.register_command("mycmd", lambda a: "ok", description="Test")'
|
||||
),
|
||||
)
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
info = mgr.list_plugins()
|
||||
# Filter out bundled plugins — they're always discovered.
|
||||
cmd_info = [p for p in info if p["name"] == "cmd-plugin"]
|
||||
assert len(cmd_info) == 1
|
||||
assert cmd_info[0]["commands"] == 1
|
||||
|
||||
def test_handler_receives_raw_args(self):
|
||||
"""The handler is called with the raw argument string."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
received = []
|
||||
ctx.register_command("echo", lambda args: received.append(args) or "ok")
|
||||
|
||||
handler = mgr._plugin_commands["echo"]["handler"]
|
||||
handler("hello world")
|
||||
assert received == ["hello world"]
|
||||
|
||||
def test_multiple_plugins_register_different_commands(self):
|
||||
"""Multiple plugins can each register their own commands."""
|
||||
mgr = PluginManager()
|
||||
|
||||
for plugin_name, cmd_name in [("plugin-a", "cmd-a"), ("plugin-b", "cmd-b")]:
|
||||
manifest = PluginManifest(name=plugin_name, source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
ctx.register_command(cmd_name, lambda a: a, description=f"From {plugin_name}")
|
||||
|
||||
assert "cmd-a" in mgr._plugin_commands
|
||||
assert "cmd-b" in mgr._plugin_commands
|
||||
assert mgr._plugin_commands["cmd-a"]["plugin"] == "plugin-a"
|
||||
assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b"
|
||||
|
||||
|
||||
# ── TestPluginDispatchTool ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginDispatchTool:
|
||||
"""Tests for PluginContext.dispatch_tool() — tool dispatch with agent context."""
|
||||
|
||||
def test_dispatch_tool_calls_registry(self):
|
||||
"""dispatch_tool() delegates to registry.dispatch()."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.dispatch.return_value = '{"result": "ok"}'
|
||||
|
||||
with patch("hermes_cli.plugins.PluginContext.dispatch_tool.__module__", "hermes_cli.plugins"):
|
||||
with patch.dict("sys.modules", {}):
|
||||
with patch("tools.registry.registry", mock_registry):
|
||||
result = ctx.dispatch_tool("web_search", {"query": "test"})
|
||||
|
||||
assert result == '{"result": "ok"}'
|
||||
|
||||
def test_dispatch_tool_injects_parent_agent_from_cli_ref(self):
|
||||
"""When _cli_ref has an agent, it's passed as parent_agent."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_cli = MagicMock()
|
||||
mock_cli.agent = mock_agent
|
||||
mgr._cli_ref = mock_cli
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.dispatch.return_value = '{"ok": true}'
|
||||
|
||||
with patch("tools.registry.registry", mock_registry):
|
||||
ctx.dispatch_tool("delegate_task", {"goal": "test"})
|
||||
|
||||
mock_registry.dispatch.assert_called_once()
|
||||
call_kwargs = mock_registry.dispatch.call_args
|
||||
assert call_kwargs[1].get("parent_agent") is mock_agent
|
||||
|
||||
def test_dispatch_tool_no_parent_agent_when_no_cli_ref(self):
|
||||
"""When _cli_ref is None (gateway mode), no parent_agent is injected."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
mgr._cli_ref = None
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.dispatch.return_value = '{"ok": true}'
|
||||
|
||||
with patch("tools.registry.registry", mock_registry):
|
||||
ctx.dispatch_tool("delegate_task", {"goal": "test"})
|
||||
|
||||
call_kwargs = mock_registry.dispatch.call_args
|
||||
assert "parent_agent" not in call_kwargs[1]
|
||||
|
||||
def test_dispatch_tool_no_parent_agent_when_agent_is_none(self):
|
||||
"""When cli_ref exists but agent is None (not yet initialized), skip parent_agent."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
mock_cli = MagicMock()
|
||||
mock_cli.agent = None
|
||||
mgr._cli_ref = mock_cli
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.dispatch.return_value = '{"ok": true}'
|
||||
|
||||
with patch("tools.registry.registry", mock_registry):
|
||||
ctx.dispatch_tool("delegate_task", {"goal": "test"})
|
||||
|
||||
call_kwargs = mock_registry.dispatch.call_args
|
||||
assert "parent_agent" not in call_kwargs[1]
|
||||
|
||||
def test_dispatch_tool_respects_explicit_parent_agent(self):
|
||||
"""Explicit parent_agent kwarg is not overwritten by _cli_ref.agent."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
cli_agent = MagicMock(name="cli_agent")
|
||||
mock_cli = MagicMock()
|
||||
mock_cli.agent = cli_agent
|
||||
mgr._cli_ref = mock_cli
|
||||
|
||||
explicit_agent = MagicMock(name="explicit_agent")
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.dispatch.return_value = '{"ok": true}'
|
||||
|
||||
with patch("tools.registry.registry", mock_registry):
|
||||
ctx.dispatch_tool("delegate_task", {"goal": "test"}, parent_agent=explicit_agent)
|
||||
|
||||
call_kwargs = mock_registry.dispatch.call_args
|
||||
assert call_kwargs[1]["parent_agent"] is explicit_agent
|
||||
|
||||
def test_dispatch_tool_forwards_extra_kwargs(self):
|
||||
"""Extra kwargs are forwarded to registry.dispatch()."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
mgr._cli_ref = None
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.dispatch.return_value = '{"ok": true}'
|
||||
|
||||
with patch("tools.registry.registry", mock_registry):
|
||||
ctx.dispatch_tool("some_tool", {"x": 1}, task_id="test-123")
|
||||
|
||||
call_kwargs = mock_registry.dispatch.call_args
|
||||
assert call_kwargs[1]["task_id"] == "test-123"
|
||||
|
||||
def test_dispatch_tool_returns_json_string(self):
|
||||
"""dispatch_tool() returns the raw JSON string from the registry."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
mgr._cli_ref = None
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.dispatch.return_value = '{"error": "Unknown tool: fake"}'
|
||||
|
||||
with patch("tools.registry.registry", mock_registry):
|
||||
result = ctx.dispatch_tool("fake", {})
|
||||
|
||||
assert '"error"' in result
|
||||
|
||||
@@ -126,59 +126,6 @@ class TestRepoNameFromUrl:
|
||||
# ── plugins_command dispatch ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginsCommandDispatch:
|
||||
"""Verify alias routing in plugins_command()."""
|
||||
|
||||
def _make_args(self, action, **extras):
|
||||
args = MagicMock()
|
||||
args.plugins_action = action
|
||||
for k, v in extras.items():
|
||||
setattr(args, k, v)
|
||||
return args
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_remove")
|
||||
def test_rm_alias(self, mock_remove):
|
||||
args = self._make_args("rm", name="some-plugin")
|
||||
plugins_command(args)
|
||||
mock_remove.assert_called_once_with("some-plugin")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_remove")
|
||||
def test_uninstall_alias(self, mock_remove):
|
||||
args = self._make_args("uninstall", name="some-plugin")
|
||||
plugins_command(args)
|
||||
mock_remove.assert_called_once_with("some-plugin")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_list")
|
||||
def test_ls_alias(self, mock_list):
|
||||
args = self._make_args("ls")
|
||||
plugins_command(args)
|
||||
mock_list.assert_called_once()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_toggle")
|
||||
def test_none_falls_through_to_toggle(self, mock_toggle):
|
||||
args = self._make_args(None)
|
||||
plugins_command(args)
|
||||
mock_toggle.assert_called_once()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_install")
|
||||
def test_install_dispatches(self, mock_install):
|
||||
args = self._make_args("install", identifier="owner/repo", force=False)
|
||||
plugins_command(args)
|
||||
mock_install.assert_called_once_with("owner/repo", force=False)
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_update")
|
||||
def test_update_dispatches(self, mock_update):
|
||||
args = self._make_args("update", name="foo")
|
||||
plugins_command(args)
|
||||
mock_update.assert_called_once_with("foo")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_remove")
|
||||
def test_remove_dispatches(self, mock_remove):
|
||||
args = self._make_args("remove", name="bar")
|
||||
plugins_command(args)
|
||||
mock_remove.assert_called_once_with("bar")
|
||||
|
||||
|
||||
# ── _read_manifest ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -799,35 +799,30 @@ class TestEdgeCases:
|
||||
assert default.skill_count == 0
|
||||
|
||||
def test_gateway_running_check_with_pid_file(self, profile_env):
|
||||
"""Verify _check_gateway_running reads pid file and probes os.kill."""
|
||||
"""Verify _check_gateway_running uses the shared gateway PID validator."""
|
||||
from hermes_cli.profiles import _check_gateway_running
|
||||
tmp_path = profile_env
|
||||
default_home = tmp_path / ".hermes"
|
||||
|
||||
# No pid file -> not running
|
||||
assert _check_gateway_running(default_home) is False
|
||||
|
||||
# Write a PID file with a JSON payload
|
||||
pid_file = default_home / "gateway.pid"
|
||||
pid_file.write_text(json.dumps({"pid": 99999}))
|
||||
|
||||
# os.kill(99999, 0) should raise ProcessLookupError -> not running
|
||||
assert _check_gateway_running(default_home) is False
|
||||
|
||||
# Mock os.kill to simulate a running process
|
||||
with patch("os.kill", return_value=None):
|
||||
with patch("gateway.status.get_running_pid", return_value=99999) as mock_get_running_pid:
|
||||
assert _check_gateway_running(default_home) is True
|
||||
mock_get_running_pid.assert_called_once_with(
|
||||
default_home / "gateway.pid",
|
||||
cleanup_stale=False,
|
||||
)
|
||||
|
||||
def test_gateway_running_check_plain_pid(self, profile_env):
|
||||
"""Pid file containing just a number (legacy format)."""
|
||||
"""Shared PID validator returning None means the profile is not running."""
|
||||
from hermes_cli.profiles import _check_gateway_running
|
||||
tmp_path = profile_env
|
||||
default_home = tmp_path / ".hermes"
|
||||
pid_file = default_home / "gateway.pid"
|
||||
pid_file.write_text("99999")
|
||||
|
||||
with patch("os.kill", return_value=None):
|
||||
assert _check_gateway_running(default_home) is True
|
||||
with patch("gateway.status.get_running_pid", return_value=None) as mock_get_running_pid:
|
||||
assert _check_gateway_running(default_home) is False
|
||||
mock_get_running_pid.assert_called_once_with(
|
||||
default_home / "gateway.pid",
|
||||
cleanup_stale=False,
|
||||
)
|
||||
|
||||
def test_profile_name_boundary_single_char(self):
|
||||
"""Single alphanumeric character is valid."""
|
||||
|
||||
137
tests/hermes_cli/test_provider_config_validation.py
Normal file
137
tests/hermes_cli/test_provider_config_validation.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Tests for providers config entry validation and normalization.
|
||||
|
||||
Covers Issue #9332: camelCase keys silently ignored, non-URL strings
|
||||
accepted as base_url, and unknown keys go unreported.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import _normalize_custom_provider_entry
|
||||
|
||||
|
||||
class TestNormalizeCustomProviderEntry:
|
||||
"""Tests for _normalize_custom_provider_entry validation."""
|
||||
|
||||
def test_valid_entry_snake_case(self):
|
||||
"""Standard snake_case entry should normalize correctly."""
|
||||
entry = {
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"api_key": "sk-test-key",
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="myhost")
|
||||
assert result is not None
|
||||
assert result["name"] == "myhost"
|
||||
assert result["base_url"] == "https://api.example.com/v1"
|
||||
assert result["api_key"] == "sk-test-key"
|
||||
|
||||
def test_camel_case_api_key_mapped(self):
|
||||
"""camelCase apiKey should be auto-mapped to api_key."""
|
||||
entry = {
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"apiKey": "sk-test-key",
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="myhost")
|
||||
assert result is not None
|
||||
assert result["api_key"] == "sk-test-key"
|
||||
|
||||
def test_camel_case_base_url_mapped(self):
|
||||
"""camelCase baseUrl should be auto-mapped to base_url."""
|
||||
entry = {
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"api_key": "sk-test-key",
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="myhost")
|
||||
assert result is not None
|
||||
assert result["base_url"] == "https://api.example.com/v1"
|
||||
|
||||
def test_non_url_api_field_rejected(self):
|
||||
"""Non-URL string in 'api' field should be skipped with a warning."""
|
||||
entry = {
|
||||
"api": "openai-reverse-proxy",
|
||||
"api_key": "sk-test-key",
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="nvidia")
|
||||
# Should return None because no valid URL was found
|
||||
assert result is None
|
||||
|
||||
def test_valid_url_in_api_field_accepted(self):
|
||||
"""Valid URL in 'api' field should still be accepted."""
|
||||
entry = {
|
||||
"api": "https://integrate.api.nvidia.com/v1",
|
||||
"api_key": "sk-test-key",
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="nvidia")
|
||||
assert result is not None
|
||||
assert result["base_url"] == "https://integrate.api.nvidia.com/v1"
|
||||
|
||||
def test_base_url_preferred_over_api(self):
|
||||
"""base_url should be checked before api field."""
|
||||
entry = {
|
||||
"base_url": "https://correct.example.com/v1",
|
||||
"api": "https://wrong.example.com/v1",
|
||||
"api_key": "sk-test-key",
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="test")
|
||||
assert result is not None
|
||||
assert result["base_url"] == "https://correct.example.com/v1"
|
||||
|
||||
def test_unknown_keys_logged(self, caplog):
|
||||
"""Unknown config keys should produce a warning."""
|
||||
entry = {
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"api_key": "sk-test-key",
|
||||
"unknownField": "value",
|
||||
"anotherBad": 42,
|
||||
}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="test")
|
||||
assert result is not None
|
||||
assert any("unknown config keys" in r.message.lower() for r in caplog.records)
|
||||
|
||||
def test_camel_case_warning_logged(self, caplog):
|
||||
"""camelCase alias mapping should produce a warning."""
|
||||
entry = {
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"apiKey": "sk-test-key",
|
||||
}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="test")
|
||||
assert result is not None
|
||||
camel_warnings = [r for r in caplog.records if "camelcase" in r.message.lower() or "auto-mapped" in r.message.lower()]
|
||||
assert len(camel_warnings) >= 1
|
||||
|
||||
def test_snake_case_takes_precedence_over_camel(self):
|
||||
"""If both snake_case and camelCase exist, snake_case wins."""
|
||||
entry = {
|
||||
"api_key": "snake-key",
|
||||
"apiKey": "camel-key",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="test")
|
||||
assert result is not None
|
||||
assert result["api_key"] == "snake-key"
|
||||
|
||||
def test_non_dict_returns_none(self):
|
||||
"""Non-dict entry should return None."""
|
||||
assert _normalize_custom_provider_entry("not-a-dict") is None
|
||||
assert _normalize_custom_provider_entry(42) is None
|
||||
assert _normalize_custom_provider_entry(None) is None
|
||||
|
||||
def test_no_url_returns_none(self):
|
||||
"""Entry with no valid URL in any field should return None."""
|
||||
entry = {
|
||||
"api_key": "sk-test-key",
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="test")
|
||||
assert result is None
|
||||
|
||||
def test_no_name_returns_none(self):
|
||||
"""Entry with no name and no provider_key should return None."""
|
||||
entry = {
|
||||
"base_url": "https://api.example.com/v1",
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="")
|
||||
assert result is None
|
||||
@@ -363,7 +363,7 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon
|
||||
|
||||
|
||||
def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr("hermes_cli.setup.managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config = load_config()
|
||||
|
||||
@@ -405,7 +405,7 @@ def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, mon
|
||||
|
||||
|
||||
def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
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)
|
||||
|
||||
29
tests/hermes_cli/test_setup_agent_settings.py
Normal file
29
tests/hermes_cli/test_setup_agent_settings.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Tests for agent-settings copy in the interactive setup wizard."""
|
||||
|
||||
from hermes_cli.setup import setup_agent_settings
|
||||
|
||||
|
||||
def test_setup_agent_settings_uses_displayed_max_iterations_value(tmp_path, monkeypatch, capsys):
|
||||
"""The helper text should match the value shown in the prompt."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
config = {
|
||||
"agent": {"max_turns": 90},
|
||||
"display": {"tool_progress": "all"},
|
||||
"compression": {"threshold": 0.50},
|
||||
"session_reset": {"mode": "both", "idle_minutes": 1440, "at_hour": 4},
|
||||
}
|
||||
|
||||
prompt_answers = iter(["60", "all", "0.5"])
|
||||
|
||||
monkeypatch.setattr("hermes_cli.setup.get_env_value", lambda key: "60" if key == "HERMES_MAX_ITERATIONS" else "")
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_answers))
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 4)
|
||||
monkeypatch.setattr("hermes_cli.setup.save_env_value", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("hermes_cli.setup.save_config", lambda *args, **kwargs: None)
|
||||
|
||||
setup_agent_settings(config)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Press Enter to keep 60." in out
|
||||
assert "Default is 90" not in out
|
||||
@@ -2,7 +2,7 @@ from hermes_cli import setup as setup_mod
|
||||
|
||||
|
||||
def test_prompt_choice_uses_curses_helper(monkeypatch):
|
||||
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0: 1)
|
||||
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0, description=None: 1)
|
||||
|
||||
idx = setup_mod.prompt_choice("Pick one", ["a", "b", "c"], default=0)
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_prompt_choice_uses_curses_helper(monkeypatch):
|
||||
|
||||
|
||||
def test_prompt_choice_falls_back_to_numbered_input(monkeypatch):
|
||||
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0: -1)
|
||||
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0, description=None: -1)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": "2")
|
||||
|
||||
idx = setup_mod.prompt_choice("Pick one", ["a", "b", "c"], default=0)
|
||||
|
||||
@@ -152,6 +152,24 @@ class TestSkinManagement:
|
||||
init_skin_from_config({})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
def test_init_skin_from_null_display(self):
|
||||
"""display: null should fall back to default, not crash."""
|
||||
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name
|
||||
init_skin_from_config({"display": None})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
def test_init_skin_from_non_dict_display(self):
|
||||
"""display: <non-dict> should fall back to default."""
|
||||
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name
|
||||
init_skin_from_config({"display": "invalid"})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
init_skin_from_config({"display": 42})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
init_skin_from_config({"display": []})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
|
||||
class TestUserSkins:
|
||||
def test_load_user_skin_from_yaml(self, tmp_path, monkeypatch):
|
||||
|
||||
@@ -64,7 +64,7 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc
|
||||
|
||||
|
||||
def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: True)
|
||||
from hermes_cli import status as status_mod
|
||||
|
||||
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
||||
@@ -98,13 +98,13 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Nous Subscription Features" in out
|
||||
assert "Nous Tool Gateway" in out
|
||||
assert "Browser automation" in out
|
||||
assert "active via Nous subscription" in out
|
||||
|
||||
|
||||
def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(monkeypatch, capsys, tmp_path):
|
||||
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
|
||||
monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: False)
|
||||
from hermes_cli import status as status_mod
|
||||
|
||||
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
||||
@@ -121,4 +121,4 @@ def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(mo
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Nous Subscription Features" not in out
|
||||
assert "Nous Tool Gateway" not in out
|
||||
|
||||
66
tests/hermes_cli/test_subparser_routing_fallback.py
Normal file
66
tests/hermes_cli/test_subparser_routing_fallback.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Tests for the defensive subparser routing workaround (bpo-9338).
|
||||
|
||||
The main() function in hermes_cli/main.py sets subparsers.required=True
|
||||
when argv contains a known subcommand name. This forces deterministic
|
||||
routing on Python versions where argparse fails to match subcommand tokens
|
||||
when the parent parser has nargs='?' optional arguments (--continue).
|
||||
|
||||
If the subcommand token is consumed as a flag value (e.g. `hermes -c model`
|
||||
to resume a session named 'model'), the required=True parse raises
|
||||
SystemExit and the code falls back to the default required=False behaviour.
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _build_parser():
|
||||
"""Build a minimal replica of the hermes top-level parser."""
|
||||
parser = argparse.ArgumentParser(prog="hermes")
|
||||
parser.add_argument("--version", "-V", action="store_true")
|
||||
parser.add_argument("--resume", "-r", metavar="SESSION", default=None)
|
||||
parser.add_argument(
|
||||
"--continue", "-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_NAME",
|
||||
)
|
||||
parser.add_argument("--worktree", "-w", action="store_true", default=False)
|
||||
parser.add_argument("--skills", "-s", action="append", default=None)
|
||||
parser.add_argument("--yolo", action="store_true", default=False)
|
||||
parser.add_argument("--pass-session-id", action="store_true", default=False)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
chat_p = subparsers.add_parser("chat")
|
||||
chat_p.add_argument("-q", "--query", default=None)
|
||||
subparsers.add_parser("model")
|
||||
subparsers.add_parser("gateway")
|
||||
subparsers.add_parser("setup")
|
||||
return parser, subparsers
|
||||
|
||||
|
||||
def _safe_parse(parser, subparsers, argv):
|
||||
"""Replica of the defensive parsing logic from main()."""
|
||||
known_cmds = set(subparsers.choices.keys()) if hasattr(subparsers, "choices") else set()
|
||||
has_cmd_token = any(t in known_cmds for t in argv if not t.startswith("-"))
|
||||
|
||||
if has_cmd_token:
|
||||
subparsers.required = True
|
||||
saved_stderr = sys.stderr
|
||||
try:
|
||||
sys.stderr = io.StringIO()
|
||||
args = parser.parse_args(argv)
|
||||
sys.stderr = saved_stderr
|
||||
return args
|
||||
except SystemExit:
|
||||
sys.stderr = saved_stderr
|
||||
subparsers.required = False
|
||||
return parser.parse_args(argv)
|
||||
else:
|
||||
subparsers.required = False
|
||||
return parser.parse_args(argv)
|
||||
|
||||
308
tests/hermes_cli/test_timeouts.py
Normal file
308
tests/hermes_cli/test_timeouts.py
Normal file
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
|
||||
from hermes_cli.timeouts import (
|
||||
get_provider_request_timeout,
|
||||
get_provider_stale_timeout,
|
||||
)
|
||||
|
||||
|
||||
def _write_config(tmp_path, body: str) -> None:
|
||||
(tmp_path / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
|
||||
|
||||
|
||||
def test_model_timeout_override_wins(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
providers:
|
||||
anthropic:
|
||||
request_timeout_seconds: 30
|
||||
models:
|
||||
claude-opus-4.6:
|
||||
timeout_seconds: 120
|
||||
""",
|
||||
)
|
||||
|
||||
assert get_provider_request_timeout("anthropic", "claude-opus-4.6") == 120.0
|
||||
|
||||
|
||||
def test_provider_timeout_used_when_no_model_override(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
providers:
|
||||
ollama-local:
|
||||
request_timeout_seconds: 300
|
||||
""",
|
||||
)
|
||||
|
||||
assert get_provider_request_timeout("ollama-local", "qwen3:32b") == 300.0
|
||||
|
||||
|
||||
def test_model_stale_timeout_override_wins(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
providers:
|
||||
openai-codex:
|
||||
stale_timeout_seconds: 600
|
||||
models:
|
||||
gpt-5.4:
|
||||
stale_timeout_seconds: 1800
|
||||
""",
|
||||
)
|
||||
|
||||
assert get_provider_stale_timeout("openai-codex", "gpt-5.4") == 1800.0
|
||||
|
||||
|
||||
def test_provider_stale_timeout_used_when_no_model_override(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
providers:
|
||||
openai-codex:
|
||||
stale_timeout_seconds: 900
|
||||
""",
|
||||
)
|
||||
|
||||
assert get_provider_stale_timeout("openai-codex", "gpt-5.4") == 900.0
|
||||
|
||||
|
||||
def test_missing_timeout_returns_none(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
providers:
|
||||
anthropic:
|
||||
models:
|
||||
claude-opus-4.6:
|
||||
context_length: 200000
|
||||
""",
|
||||
)
|
||||
|
||||
assert get_provider_request_timeout("anthropic", "claude-opus-4.6") is None
|
||||
assert get_provider_request_timeout("missing-provider", "claude-opus-4.6") is None
|
||||
|
||||
|
||||
def test_invalid_timeout_values_return_none(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
providers:
|
||||
anthropic:
|
||||
request_timeout_seconds: "fast"
|
||||
models:
|
||||
claude-opus-4.6:
|
||||
timeout_seconds: -5
|
||||
ollama-local:
|
||||
request_timeout_seconds: -1
|
||||
""",
|
||||
)
|
||||
|
||||
assert get_provider_request_timeout("anthropic", "claude-opus-4.6") is None
|
||||
assert get_provider_request_timeout("anthropic", "claude-sonnet-4.5") is None
|
||||
assert get_provider_request_timeout("ollama-local") is None
|
||||
|
||||
|
||||
def test_invalid_stale_timeout_values_return_none(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
providers:
|
||||
openai-codex:
|
||||
stale_timeout_seconds: "slow"
|
||||
models:
|
||||
gpt-5.4:
|
||||
stale_timeout_seconds: -1
|
||||
""",
|
||||
)
|
||||
|
||||
assert get_provider_stale_timeout("openai-codex", "gpt-5.4") is None
|
||||
assert get_provider_stale_timeout("openai-codex", "gpt-5.5") is None
|
||||
|
||||
|
||||
def test_anthropic_adapter_honors_timeout_kwarg():
|
||||
"""build_anthropic_client(timeout=X) overrides the 900s default read timeout."""
|
||||
pytest = __import__("pytest")
|
||||
anthropic = pytest.importorskip("anthropic") # skip if optional SDK missing
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
|
||||
c_default = build_anthropic_client("sk-ant-dummy", None)
|
||||
c_custom = build_anthropic_client("sk-ant-dummy", None, timeout=45.0)
|
||||
c_invalid = build_anthropic_client("sk-ant-dummy", None, timeout=-1)
|
||||
|
||||
# Default stays at 900s; custom overrides; invalid falls back to default
|
||||
assert c_default.timeout.read == 900.0
|
||||
assert c_custom.timeout.read == 45.0
|
||||
assert c_invalid.timeout.read == 900.0
|
||||
# Connect timeout always stays at 10s regardless
|
||||
assert c_default.timeout.connect == 10.0
|
||||
assert c_custom.timeout.connect == 10.0
|
||||
|
||||
|
||||
def test_resolved_api_call_timeout_priority(monkeypatch, tmp_path):
|
||||
"""AIAgent._resolved_api_call_timeout() honors config > env > default priority."""
|
||||
# Isolate HERMES_HOME
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / ".env").write_text("", encoding="utf-8")
|
||||
|
||||
# Case A: config wins over env var
|
||||
_write_config(tmp_path, """\
|
||||
providers:
|
||||
openrouter:
|
||||
request_timeout_seconds: 77
|
||||
models:
|
||||
openai/gpt-4o-mini:
|
||||
timeout_seconds: 42
|
||||
""")
|
||||
monkeypatch.setenv("HERMES_API_TIMEOUT", "999")
|
||||
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent(
|
||||
model="openai/gpt-4o-mini",
|
||||
provider="openrouter",
|
||||
api_key="sk-dummy",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
platform="cli",
|
||||
)
|
||||
# Per-model override wins
|
||||
assert agent._resolved_api_call_timeout() == 42.0
|
||||
|
||||
# Provider-level (different model, no per-model override)
|
||||
agent.model = "some/other-model"
|
||||
assert agent._resolved_api_call_timeout() == 77.0
|
||||
|
||||
# Case B: no config → env wins
|
||||
_write_config(tmp_path, "")
|
||||
# Clear the cached config load
|
||||
import importlib
|
||||
from hermes_cli import config as cfg_mod
|
||||
importlib.reload(cfg_mod)
|
||||
from hermes_cli import timeouts as to_mod
|
||||
importlib.reload(to_mod)
|
||||
import run_agent as ra_mod
|
||||
importlib.reload(ra_mod)
|
||||
|
||||
agent2 = ra_mod.AIAgent(
|
||||
model="some/model",
|
||||
provider="openrouter",
|
||||
api_key="sk-dummy",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
platform="cli",
|
||||
)
|
||||
assert agent2._resolved_api_call_timeout() == 999.0
|
||||
|
||||
# Case C: no config, no env → 1800.0 default
|
||||
monkeypatch.delenv("HERMES_API_TIMEOUT", raising=False)
|
||||
assert agent2._resolved_api_call_timeout() == 1800.0
|
||||
|
||||
|
||||
def test_resolved_api_call_stale_timeout_priority(monkeypatch, tmp_path):
|
||||
"""AIAgent stale timeout honors config > env > default priority."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / ".env").write_text("", encoding="utf-8")
|
||||
|
||||
_write_config(tmp_path, """\
|
||||
providers:
|
||||
openai-codex:
|
||||
stale_timeout_seconds: 600
|
||||
models:
|
||||
gpt-5.4:
|
||||
stale_timeout_seconds: 1800
|
||||
""")
|
||||
monkeypatch.setenv("HERMES_API_CALL_STALE_TIMEOUT", "999")
|
||||
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent(
|
||||
model="gpt-5.4",
|
||||
provider="openai-codex",
|
||||
api_key="sk-dummy",
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
platform="cli",
|
||||
)
|
||||
assert agent._resolved_api_call_stale_timeout_base() == (1800.0, False)
|
||||
|
||||
agent.model = "gpt-5.5"
|
||||
assert agent._resolved_api_call_stale_timeout_base() == (600.0, False)
|
||||
|
||||
_write_config(tmp_path, "")
|
||||
import importlib
|
||||
from hermes_cli import config as cfg_mod
|
||||
importlib.reload(cfg_mod)
|
||||
from hermes_cli import timeouts as to_mod
|
||||
importlib.reload(to_mod)
|
||||
import run_agent as ra_mod
|
||||
importlib.reload(ra_mod)
|
||||
|
||||
agent2 = ra_mod.AIAgent(
|
||||
model="gpt-5.4",
|
||||
provider="openai-codex",
|
||||
api_key="sk-dummy",
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
platform="cli",
|
||||
)
|
||||
assert agent2._resolved_api_call_stale_timeout_base() == (999.0, False)
|
||||
|
||||
monkeypatch.delenv("HERMES_API_CALL_STALE_TIMEOUT", raising=False)
|
||||
assert agent2._resolved_api_call_stale_timeout_base() == (300.0, True)
|
||||
|
||||
|
||||
def test_default_non_stream_stale_timeout_auto_disables_for_local_endpoints(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / ".env").write_text("", encoding="utf-8")
|
||||
monkeypatch.delenv("HERMES_API_CALL_STALE_TIMEOUT", raising=False)
|
||||
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent(
|
||||
model="qwen3:32b",
|
||||
provider="ollama-local",
|
||||
api_key="sk-dummy",
|
||||
base_url="http://127.0.0.1:11434/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
platform="cli",
|
||||
)
|
||||
|
||||
assert agent._compute_non_stream_stale_timeout([]) == float("inf")
|
||||
|
||||
|
||||
def test_explicit_non_stream_stale_timeout_is_honored_for_local_endpoints(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / ".env").write_text("", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_API_CALL_STALE_TIMEOUT", "300")
|
||||
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent(
|
||||
model="qwen3:32b",
|
||||
provider="ollama-local",
|
||||
api_key="sk-dummy",
|
||||
base_url="http://127.0.0.1:11434/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
platform="cli",
|
||||
)
|
||||
|
||||
assert agent._compute_non_stream_stale_timeout([]) == 300.0
|
||||
@@ -8,6 +8,7 @@ from hermes_cli.tools_config import (
|
||||
_platform_toolset_summary,
|
||||
_save_platform_tools,
|
||||
_toolset_has_keys,
|
||||
CONFIGURABLE_TOOLSETS,
|
||||
TOOL_CATEGORIES,
|
||||
_visible_providers,
|
||||
tools_command,
|
||||
@@ -22,6 +23,15 @@ def test_get_platform_tools_uses_default_when_platform_not_configured():
|
||||
assert enabled
|
||||
|
||||
|
||||
def test_configurable_toolsets_include_messaging():
|
||||
assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS)
|
||||
|
||||
def test_get_platform_tools_default_telegram_includes_messaging():
|
||||
enabled = _get_platform_tools({}, "telegram")
|
||||
|
||||
assert "messaging" in enabled
|
||||
|
||||
|
||||
def test_get_platform_tools_preserves_explicit_empty_selection():
|
||||
config = {"platform_toolsets": {"cli": []}}
|
||||
|
||||
@@ -30,6 +40,19 @@ def test_get_platform_tools_preserves_explicit_empty_selection():
|
||||
assert enabled == set()
|
||||
|
||||
|
||||
def test_get_platform_tools_handles_null_platform_toolsets():
|
||||
"""YAML `platform_toolsets:` with no value parses as None — the old
|
||||
``config.get("platform_toolsets", {})`` pattern would then crash with
|
||||
``NoneType has no attribute 'get'`` on the next line. Guard against that.
|
||||
"""
|
||||
config = {"platform_toolsets": None}
|
||||
|
||||
enabled = _get_platform_tools(config, "cli")
|
||||
|
||||
# Falls through to defaults instead of raising
|
||||
assert enabled
|
||||
|
||||
|
||||
def test_platform_toolset_summary_uses_explicit_platform_list():
|
||||
config = {}
|
||||
|
||||
@@ -286,7 +309,7 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present()
|
||||
|
||||
|
||||
def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True)
|
||||
config = {"model": {"provider": "nous"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -300,7 +323,7 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch)
|
||||
|
||||
|
||||
def test_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
|
||||
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: False)
|
||||
config = {"model": {"provider": "nous"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -328,7 +351,8 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch):
|
||||
|
||||
|
||||
def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
|
||||
config = {
|
||||
"model": {"provider": "nous"},
|
||||
"platform_toolsets": {"cli": []},
|
||||
@@ -455,3 +479,90 @@ def test_numeric_mcp_server_name_does_not_crash_sorted():
|
||||
|
||||
# sorted() must not raise TypeError
|
||||
sorted(enabled)
|
||||
|
||||
|
||||
# ─── Imagegen Backend Picker Wiring ────────────────────────────────────────
|
||||
|
||||
class TestImagegenBackendRegistry:
|
||||
"""IMAGEGEN_BACKENDS tags drive the model picker flow in tools_config."""
|
||||
|
||||
def test_fal_backend_registered(self):
|
||||
from hermes_cli.tools_config import IMAGEGEN_BACKENDS
|
||||
assert "fal" in IMAGEGEN_BACKENDS
|
||||
|
||||
def test_fal_catalog_loads_lazily(self):
|
||||
"""catalog_fn should defer import to avoid import cycles."""
|
||||
from hermes_cli.tools_config import IMAGEGEN_BACKENDS
|
||||
catalog, default = IMAGEGEN_BACKENDS["fal"]["catalog_fn"]()
|
||||
assert default == "fal-ai/flux-2/klein/9b"
|
||||
assert "fal-ai/flux-2/klein/9b" in catalog
|
||||
assert "fal-ai/flux-2-pro" in catalog
|
||||
|
||||
def test_image_gen_providers_tagged_with_fal_backend(self):
|
||||
"""Both Nous Subscription and FAL.ai providers must carry the
|
||||
imagegen_backend tag so _configure_provider fires the picker."""
|
||||
from hermes_cli.tools_config import TOOL_CATEGORIES
|
||||
providers = TOOL_CATEGORIES["image_gen"]["providers"]
|
||||
for p in providers:
|
||||
assert p.get("imagegen_backend") == "fal", (
|
||||
f"{p['name']} missing imagegen_backend tag"
|
||||
)
|
||||
|
||||
|
||||
class TestImagegenModelPicker:
|
||||
"""_configure_imagegen_model writes selection to config and respects
|
||||
curses fallback semantics (returns default when stdin isn't a TTY)."""
|
||||
|
||||
def test_picker_writes_chosen_model_to_config(self):
|
||||
from hermes_cli.tools_config import _configure_imagegen_model
|
||||
config = {}
|
||||
# Force _prompt_choice to pick index 1 (second-in-ordered-list).
|
||||
with patch("hermes_cli.tools_config._prompt_choice", return_value=1):
|
||||
_configure_imagegen_model("fal", config)
|
||||
# ordered[0] == current (default klein), ordered[1] == first non-default
|
||||
assert config["image_gen"]["model"] != "fal-ai/flux-2/klein/9b"
|
||||
assert config["image_gen"]["model"].startswith("fal-ai/")
|
||||
|
||||
def test_picker_with_gpt_image_does_not_prompt_quality(self):
|
||||
"""GPT-Image quality is pinned to medium in the tool's defaults —
|
||||
no follow-up prompt, no config write for quality_setting."""
|
||||
from hermes_cli.tools_config import (
|
||||
_configure_imagegen_model,
|
||||
IMAGEGEN_BACKENDS,
|
||||
)
|
||||
catalog, default_model = IMAGEGEN_BACKENDS["fal"]["catalog_fn"]()
|
||||
model_ids = list(catalog.keys())
|
||||
ordered = [default_model] + [m for m in model_ids if m != default_model]
|
||||
gpt_idx = ordered.index("fal-ai/gpt-image-1.5")
|
||||
|
||||
# Only ONE picker call is expected (for model) — not two (model + quality).
|
||||
call_count = {"n": 0}
|
||||
def fake_prompt(*a, **kw):
|
||||
call_count["n"] += 1
|
||||
return gpt_idx
|
||||
|
||||
config = {}
|
||||
with patch("hermes_cli.tools_config._prompt_choice", side_effect=fake_prompt):
|
||||
_configure_imagegen_model("fal", config)
|
||||
|
||||
assert call_count["n"] == 1, (
|
||||
f"Expected 1 picker call (model only), got {call_count['n']}"
|
||||
)
|
||||
assert config["image_gen"]["model"] == "fal-ai/gpt-image-1.5"
|
||||
assert "quality_setting" not in config["image_gen"]
|
||||
|
||||
def test_picker_no_op_for_unknown_backend(self):
|
||||
from hermes_cli.tools_config import _configure_imagegen_model
|
||||
config = {}
|
||||
_configure_imagegen_model("nonexistent-backend", config)
|
||||
assert config == {} # untouched
|
||||
|
||||
def test_picker_repairs_corrupt_config_section(self):
|
||||
"""When image_gen is a non-dict (user-edit YAML), the picker should
|
||||
replace it with a fresh dict rather than crash."""
|
||||
from hermes_cli.tools_config import _configure_imagegen_model
|
||||
config = {"image_gen": "some-garbage-string"}
|
||||
with patch("hermes_cli.tools_config._prompt_choice", return_value=0):
|
||||
_configure_imagegen_model("fal", config)
|
||||
assert isinstance(config["image_gen"], dict)
|
||||
assert config["image_gen"]["model"] == "fal-ai/flux-2/klein/9b"
|
||||
|
||||
53
tests/hermes_cli/test_tui_npm_install.py
Normal file
53
tests/hermes_cli/test_tui_npm_install.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""_tui_need_npm_install: auto npm when lockfile ahead of node_modules."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod():
|
||||
import hermes_cli.main as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
def _touch_ink(root: Path) -> None:
|
||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
ink.parent.mkdir(parents=True, exist_ok=True)
|
||||
ink.write_text("{}")
|
||||
|
||||
|
||||
def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None:
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
|
||||
|
||||
def test_need_install_when_lock_newer_than_marker(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
(tmp_path / "node_modules" / ".package-lock.json").write_text("{}")
|
||||
os.utime(tmp_path / "package-lock.json", (200, 200))
|
||||
os.utime(tmp_path / "node_modules" / ".package-lock.json", (100, 100))
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
|
||||
|
||||
def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
(tmp_path / "node_modules" / ".package-lock.json").write_text("{}")
|
||||
os.utime(tmp_path / "package-lock.json", (100, 100))
|
||||
os.utime(tmp_path / "node_modules" / ".package-lock.json", (200, 200))
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
|
||||
|
||||
def test_need_install_when_marker_missing(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
|
||||
|
||||
def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
121
tests/hermes_cli/test_tui_resume_flow.py
Normal file
121
tests/hermes_cli/test_tui_resume_flow.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from argparse import Namespace
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _args(**overrides):
|
||||
base = {
|
||||
"continue_last": None,
|
||||
"resume": None,
|
||||
"tui": True,
|
||||
}
|
||||
base.update(overrides)
|
||||
return Namespace(**base)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod(monkeypatch):
|
||||
import hermes_cli.main as mod
|
||||
|
||||
monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
|
||||
return mod
|
||||
|
||||
|
||||
def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod):
|
||||
calls = []
|
||||
captured = {}
|
||||
|
||||
def fake_resolve_last(source="cli"):
|
||||
calls.append(source)
|
||||
return "20260408_235959_a1b2c3" if source == "tui" else None
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last)
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val)
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(continue_last=True))
|
||||
|
||||
assert calls == ["tui"]
|
||||
assert captured["resume"] == "20260408_235959_a1b2c3"
|
||||
|
||||
|
||||
def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, main_mod):
|
||||
calls = []
|
||||
captured = {}
|
||||
|
||||
def fake_resolve_last(source="cli"):
|
||||
calls.append(source)
|
||||
if source == "tui":
|
||||
return None
|
||||
if source == "cli":
|
||||
return "20260408_235959_d4e5f6"
|
||||
return None
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last)
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val)
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(continue_last=True))
|
||||
|
||||
assert calls == ["tui", "cli"]
|
||||
assert captured["resume"] == "20260408_235959_d4e5f6"
|
||||
|
||||
|
||||
def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: "20260409_000000_aa11bb")
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(resume="my t0p session"))
|
||||
|
||||
assert captured["resume"] == "20260409_000000_aa11bb"
|
||||
|
||||
|
||||
def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
class _FakeDB:
|
||||
def get_session(self, session_id):
|
||||
assert session_id == "20260409_000001_abc123"
|
||||
return {
|
||||
"message_count": 2,
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 6,
|
||||
"cache_read_tokens": 2,
|
||||
"cache_write_tokens": 2,
|
||||
"reasoning_tokens": 1,
|
||||
}
|
||||
|
||||
def get_session_title(self, _session_id):
|
||||
return "demo title"
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB()))
|
||||
|
||||
main_mod._print_tui_exit_summary("20260409_000001_abc123")
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Resume this session with:" in out
|
||||
assert "hermes --tui --resume 20260409_000001_abc123" in out
|
||||
assert 'hermes --tui -c "demo title"' in out
|
||||
assert "Tokens: 21 (in 10, out 6, cache 4, reasoning 1)" in out
|
||||
@@ -113,23 +113,6 @@ def test_prefetch_non_blocking():
|
||||
assert banner._update_result == 5
|
||||
|
||||
|
||||
def test_get_update_result_timeout():
|
||||
"""get_update_result() returns None when check hasn't completed within timeout."""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
# Reset module state — don't set the event
|
||||
banner._update_result = None
|
||||
banner._update_check_done = threading.Event()
|
||||
|
||||
start = time.monotonic()
|
||||
result = banner.get_update_result(timeout=0.1)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
# Should have waited ~0.1s and returned None
|
||||
assert result is None
|
||||
assert elapsed < 0.5
|
||||
|
||||
|
||||
def test_invalidate_update_cache_clears_all_profiles(tmp_path):
|
||||
"""_invalidate_update_cache() should delete .update_check from ALL profiles."""
|
||||
from hermes_cli.main import _invalidate_update_cache
|
||||
|
||||
84
tests/hermes_cli/test_update_config_clears_custom_fields.py
Normal file
84
tests/hermes_cli/test_update_config_clears_custom_fields.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Tests for hermes_cli.auth._update_config_for_provider clearing stale fields.
|
||||
|
||||
When the user switches from a custom provider (e.g. MiniMax with
|
||||
``api_mode: anthropic_messages``, ``api_key: mxp-...``) to a built-in
|
||||
provider (e.g. OpenRouter), the stale ``api_key`` and ``api_mode`` would
|
||||
otherwise override the new provider's credentials and transport choice.
|
||||
|
||||
Built-in providers that legitimately need a specific ``api_mode`` (copilot,
|
||||
xai) compute it at request-resolution time in
|
||||
``_copilot_runtime_api_mode`` / ``_detect_api_mode_for_url``, so removing
|
||||
the persisted value here is safe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
from hermes_cli.auth import _update_config_for_provider
|
||||
from hermes_cli.config import get_config_path
|
||||
|
||||
|
||||
def _read_model_cfg() -> dict:
|
||||
path = get_config_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
data = yaml.safe_load(path.read_text()) or {}
|
||||
model = data.get("model", {})
|
||||
return model if isinstance(model, dict) else {}
|
||||
|
||||
|
||||
def _seed_custom_provider_config(api_mode: str = "anthropic_messages") -> None:
|
||||
"""Write a config.yaml mimicking a user on a MiniMax-style custom provider."""
|
||||
path = get_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"base_url": "https://api.minimax.io/anthropic",
|
||||
"api_key": "mxp-stale-key",
|
||||
"api_mode": api_mode,
|
||||
"default": "claude-sonnet-4-6",
|
||||
}
|
||||
},
|
||||
sort_keys=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateConfigForProviderClearsStaleCustomFields:
|
||||
def test_switching_to_openrouter_clears_api_key_and_api_mode(self):
|
||||
_seed_custom_provider_config()
|
||||
|
||||
_update_config_for_provider(
|
||||
"openrouter",
|
||||
"https://openrouter.ai/api/v1",
|
||||
default_model="anthropic/claude-sonnet-4.6",
|
||||
)
|
||||
|
||||
model_cfg = _read_model_cfg()
|
||||
assert model_cfg.get("provider") == "openrouter"
|
||||
assert model_cfg.get("base_url") == "https://openrouter.ai/api/v1"
|
||||
assert "api_key" not in model_cfg, (
|
||||
"Stale custom api_key would leak into OpenRouter requests — must be cleared"
|
||||
)
|
||||
assert "api_mode" not in model_cfg, (
|
||||
"Stale api_mode=anthropic_messages from MiniMax would mis-route "
|
||||
"OpenRouter requests to the Anthropic SDK — must be cleared"
|
||||
)
|
||||
|
||||
def test_switching_to_nous_clears_stale_api_mode(self):
|
||||
_seed_custom_provider_config()
|
||||
_update_config_for_provider("nous", "https://inference-api.nousresearch.com/v1")
|
||||
model_cfg = _read_model_cfg()
|
||||
assert model_cfg.get("provider") == "nous"
|
||||
assert "api_mode" not in model_cfg
|
||||
assert "api_key" not in model_cfg
|
||||
|
||||
def test_switching_clears_codex_responses_api_mode(self):
|
||||
"""Also covers codex_responses, not just anthropic_messages."""
|
||||
_seed_custom_provider_config(api_mode="codex_responses")
|
||||
_update_config_for_provider("openrouter", "https://openrouter.ai/api/v1")
|
||||
assert "api_mode" not in _read_model_cfg()
|
||||
@@ -13,9 +13,29 @@ from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
import hermes_cli.gateway as gateway_cli
|
||||
import hermes_cli.main as cli_main
|
||||
from hermes_cli.main import cmd_update
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip the real-time sleeps inside cmd_update's restart-verification path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_restart_verify_sleep(monkeypatch):
|
||||
"""hermes_cli/main.py uses time.sleep(3) after systemctl restart to
|
||||
verify the service survived. Tests mock subprocess.run — nothing
|
||||
actually restarts — so the 3s wait is dead time.
|
||||
|
||||
main.py does ``import time as _time`` at both module level (line 167)
|
||||
and inside functions (lines 3281, 4384, 4401). Patching the global
|
||||
``time.sleep`` affects only the duration of this test.
|
||||
"""
|
||||
import time as _real_time
|
||||
monkeypatch.setattr(_real_time, "sleep", lambda *_a, **_k: None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -915,3 +935,183 @@ class TestGatewayModeWritesExitCodeEarly:
|
||||
assert exit_code_existed_at_restart, "systemctl restart was never called"
|
||||
assert exit_code_existed_at_restart[0] is True, \
|
||||
".update_exit_code must exist BEFORE systemctl restart (cgroup kill race)"
|
||||
|
||||
|
||||
class TestCmdUpdateLegacyGatewayWarning:
|
||||
"""Tests for the legacy hermes.service warning printed by `hermes update`.
|
||||
|
||||
Users who installed Hermes before the service rename often have a
|
||||
dormant ``hermes.service`` that starts flap-fighting the current
|
||||
``hermes-gateway.service`` after PR #5646. Every ``hermes update``
|
||||
should remind them to run ``hermes gateway migrate-legacy`` until
|
||||
they do.
|
||||
"""
|
||||
|
||||
_OUR_UNIT_TEXT = (
|
||||
"[Unit]\nDescription=Hermes Gateway\n[Service]\n"
|
||||
"ExecStart=/usr/bin/python -m hermes_cli.main gateway run --replace\n"
|
||||
)
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_update_prints_legacy_warning_when_detected(
|
||||
self, mock_run, _mock_which, mock_args, capsys, tmp_path, monkeypatch,
|
||||
):
|
||||
"""Legacy units present → warning in update output with migrate command."""
|
||||
user_dir = tmp_path / "user"
|
||||
system_dir = tmp_path / "system"
|
||||
user_dir.mkdir()
|
||||
system_dir.mkdir()
|
||||
legacy_path = user_dir / "hermes.service"
|
||||
legacy_path.write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_legacy_unit_search_paths",
|
||||
lambda: [(False, user_dir), (True, system_dir)],
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(commit_count="3")
|
||||
|
||||
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
|
||||
cmd_update(mock_args)
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert "Legacy Hermes gateway unit(s) detected" in captured
|
||||
assert "hermes.service" in captured
|
||||
assert "hermes gateway migrate-legacy" in captured
|
||||
assert "(user scope)" in captured
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_update_silent_when_no_legacy_units(
|
||||
self, mock_run, _mock_which, mock_args, capsys, tmp_path, monkeypatch,
|
||||
):
|
||||
"""No legacy units → no warning printed."""
|
||||
user_dir = tmp_path / "user"
|
||||
system_dir = tmp_path / "system"
|
||||
user_dir.mkdir()
|
||||
system_dir.mkdir()
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_legacy_unit_search_paths",
|
||||
lambda: [(False, user_dir), (True, system_dir)],
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(commit_count="3")
|
||||
|
||||
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
|
||||
cmd_update(mock_args)
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert "Legacy Hermes gateway" not in captured
|
||||
assert "migrate-legacy" not in captured
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_update_does_not_flag_profile_units(
|
||||
self, mock_run, _mock_which, mock_args, capsys, tmp_path, monkeypatch,
|
||||
):
|
||||
"""Profile units (hermes-gateway-coder.service) must not trigger the warning.
|
||||
|
||||
This is the core safety invariant: the legacy allowlist is
|
||||
``hermes.service`` only, no globs.
|
||||
"""
|
||||
user_dir = tmp_path / "user"
|
||||
system_dir = tmp_path / "system"
|
||||
user_dir.mkdir()
|
||||
system_dir.mkdir()
|
||||
# Drop a profile unit that an over-eager glob would match
|
||||
(user_dir / "hermes-gateway-coder.service").write_text(
|
||||
self._OUR_UNIT_TEXT, encoding="utf-8"
|
||||
)
|
||||
(user_dir / "hermes-gateway.service").write_text(
|
||||
self._OUR_UNIT_TEXT, encoding="utf-8"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_legacy_unit_search_paths",
|
||||
lambda: [(False, user_dir), (True, system_dir)],
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(commit_count="3")
|
||||
|
||||
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
|
||||
cmd_update(mock_args)
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert "Legacy Hermes gateway" not in captured
|
||||
assert "hermes-gateway-coder.service" not in captured # not flagged
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_update_skips_legacy_check_on_non_systemd_platforms(
|
||||
self, mock_run, _mock_which, mock_args, capsys, tmp_path, monkeypatch,
|
||||
):
|
||||
"""macOS / Windows / Termux — skip check entirely since the rename
|
||||
is systemd-specific."""
|
||||
user_dir = tmp_path / "user"
|
||||
user_dir.mkdir()
|
||||
# Put a file that WOULD match if the check ran
|
||||
(user_dir / "hermes.service").write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_legacy_unit_search_paths",
|
||||
lambda: [(False, user_dir), (True, tmp_path / "system")],
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
commit_count="3", launchctl_loaded=False,
|
||||
)
|
||||
|
||||
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
|
||||
cmd_update(mock_args)
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
# Must not print the warning on non-systemd platforms
|
||||
assert "Legacy Hermes gateway" not in captured
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_update_lists_system_scope_unit_with_sudo_hint(
|
||||
self, mock_run, _mock_which, mock_args, capsys, tmp_path, monkeypatch,
|
||||
):
|
||||
"""System-scope legacy units need sudo — the warning must point that out."""
|
||||
user_dir = tmp_path / "user"
|
||||
system_dir = tmp_path / "system"
|
||||
user_dir.mkdir()
|
||||
system_dir.mkdir()
|
||||
(system_dir / "hermes.service").write_text(self._OUR_UNIT_TEXT, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_legacy_unit_search_paths",
|
||||
lambda: [(False, user_dir), (True, system_dir)],
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(commit_count="3")
|
||||
|
||||
with patch.object(gateway_cli, "find_gateway_pids", return_value=[]):
|
||||
cmd_update(mock_args)
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert "Legacy Hermes gateway" in captured
|
||||
assert "(system scope)" in captured
|
||||
assert "sudo" in captured
|
||||
|
||||
325
tests/hermes_cli/test_update_hangup_protection.py
Normal file
325
tests/hermes_cli/test_update_hangup_protection.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Tests for SIGHUP protection and stdout mirroring in ``hermes update``.
|
||||
|
||||
Covers ``_UpdateOutputStream``, ``_install_hangup_protection``, and
|
||||
``_finalize_update_output`` in ``hermes_cli/main.py``. These exist so
|
||||
that ``hermes update`` survives a terminal disconnect mid-install
|
||||
(SSH drop, shell close) without leaving the venv half-installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.main import (
|
||||
_UpdateOutputStream,
|
||||
_finalize_update_output,
|
||||
_install_hangup_protection,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# _UpdateOutputStream
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateOutputStream:
|
||||
def test_write_mirrors_to_both_original_and_log(self):
|
||||
original = io.StringIO()
|
||||
log = io.StringIO()
|
||||
stream = _UpdateOutputStream(original, log)
|
||||
|
||||
stream.write("hello world\n")
|
||||
|
||||
assert original.getvalue() == "hello world\n"
|
||||
assert log.getvalue() == "hello world\n"
|
||||
|
||||
def test_write_continues_after_broken_original(self):
|
||||
"""When the terminal disconnects, original.write raises BrokenPipeError.
|
||||
|
||||
The wrapper must catch it, flip the broken flag, and keep writing to
|
||||
the log from then on.
|
||||
"""
|
||||
log = io.StringIO()
|
||||
|
||||
class _BrokenStream:
|
||||
def write(self, data):
|
||||
raise BrokenPipeError("terminal gone")
|
||||
|
||||
def flush(self):
|
||||
raise BrokenPipeError("terminal gone")
|
||||
|
||||
stream = _UpdateOutputStream(_BrokenStream(), log)
|
||||
|
||||
# First write triggers the broken-pipe path.
|
||||
stream.write("first line\n")
|
||||
# Subsequent writes take the fast broken path (no exception).
|
||||
stream.write("second line\n")
|
||||
|
||||
assert log.getvalue() == "first line\nsecond line\n"
|
||||
assert stream._original_broken is True
|
||||
|
||||
def test_write_tolerates_oserror_and_valueerror(self):
|
||||
"""OSError (EIO) and ValueError (closed file) should also be absorbed."""
|
||||
log = io.StringIO()
|
||||
|
||||
class _RaisingStream:
|
||||
def __init__(self, exc):
|
||||
self._exc = exc
|
||||
|
||||
def write(self, data):
|
||||
raise self._exc
|
||||
|
||||
def flush(self):
|
||||
raise self._exc
|
||||
|
||||
for exc in (OSError("EIO"), ValueError("closed file")):
|
||||
stream = _UpdateOutputStream(_RaisingStream(exc), log)
|
||||
stream.write("x\n")
|
||||
assert stream._original_broken is True
|
||||
|
||||
def test_log_failure_does_not_abort_write(self):
|
||||
"""Even if the log file write raises, the original write must still happen."""
|
||||
class _BrokenLog:
|
||||
def write(self, data):
|
||||
raise OSError("disk full")
|
||||
|
||||
def flush(self):
|
||||
raise OSError("disk full")
|
||||
|
||||
original = io.StringIO()
|
||||
stream = _UpdateOutputStream(original, _BrokenLog())
|
||||
|
||||
stream.write("data\n")
|
||||
|
||||
assert original.getvalue() == "data\n"
|
||||
|
||||
def test_flush_tolerates_broken_original(self):
|
||||
class _BrokenStream:
|
||||
def write(self, data):
|
||||
return len(data)
|
||||
|
||||
def flush(self):
|
||||
raise BrokenPipeError("gone")
|
||||
|
||||
log = io.StringIO()
|
||||
stream = _UpdateOutputStream(_BrokenStream(), log)
|
||||
stream.flush() # must not raise
|
||||
assert stream._original_broken is True
|
||||
|
||||
def test_isatty_delegates_to_original(self):
|
||||
class _TtyStream:
|
||||
def isatty(self):
|
||||
return True
|
||||
|
||||
def write(self, data):
|
||||
return len(data)
|
||||
|
||||
def flush(self):
|
||||
return None
|
||||
|
||||
stream = _UpdateOutputStream(_TtyStream(), io.StringIO())
|
||||
assert stream.isatty() is True
|
||||
|
||||
def test_isatty_returns_false_after_broken(self):
|
||||
class _BrokenStream:
|
||||
def isatty(self):
|
||||
return True
|
||||
|
||||
def write(self, data):
|
||||
raise BrokenPipeError()
|
||||
|
||||
def flush(self):
|
||||
return None
|
||||
|
||||
stream = _UpdateOutputStream(_BrokenStream(), io.StringIO())
|
||||
stream.write("x") # marks broken
|
||||
assert stream.isatty() is False
|
||||
|
||||
def test_getattr_delegates_unknown_attrs(self):
|
||||
class _StreamWithEncoding:
|
||||
encoding = "utf-8"
|
||||
|
||||
def write(self, data):
|
||||
return len(data)
|
||||
|
||||
def flush(self):
|
||||
return None
|
||||
|
||||
stream = _UpdateOutputStream(_StreamWithEncoding(), io.StringIO())
|
||||
assert stream.encoding == "utf-8"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# _install_hangup_protection
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInstallHangupProtection:
|
||||
def test_gateway_mode_is_noop(self):
|
||||
"""In gateway mode the process is already detached — don't touch stdio or signals."""
|
||||
prev_out, prev_err = sys.stdout, sys.stderr
|
||||
prev_sighup = signal.getsignal(signal.SIGHUP) if hasattr(signal, "SIGHUP") else None
|
||||
|
||||
state = _install_hangup_protection(gateway_mode=True)
|
||||
|
||||
try:
|
||||
assert sys.stdout is prev_out
|
||||
assert sys.stderr is prev_err
|
||||
assert state["log_file"] is None
|
||||
assert state["installed"] is False
|
||||
if hasattr(signal, "SIGHUP"):
|
||||
assert signal.getsignal(signal.SIGHUP) == prev_sighup
|
||||
finally:
|
||||
_finalize_update_output(state)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(signal, "SIGHUP"), reason="SIGHUP not available on this platform"
|
||||
)
|
||||
def test_installs_sighup_ignore(self, tmp_path, monkeypatch):
|
||||
"""SIGHUP should be set to SIG_IGN so SSH disconnect doesn't kill the update."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Clear cached get_hermes_home if present
|
||||
import hermes_cli.config as _cfg
|
||||
if hasattr(_cfg, "_HERMES_HOME_CACHE"):
|
||||
_cfg._HERMES_HOME_CACHE = None # type: ignore[attr-defined]
|
||||
|
||||
original_handler = signal.getsignal(signal.SIGHUP)
|
||||
state = _install_hangup_protection(gateway_mode=False)
|
||||
|
||||
try:
|
||||
assert signal.getsignal(signal.SIGHUP) == signal.SIG_IGN
|
||||
finally:
|
||||
_finalize_update_output(state)
|
||||
# Restore whatever was there before so we don't leak to other tests.
|
||||
signal.signal(signal.SIGHUP, original_handler)
|
||||
|
||||
def test_wraps_stdout_and_stderr_with_mirror(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Nuke any cached home path
|
||||
import hermes_cli.config as _cfg
|
||||
if hasattr(_cfg, "_HERMES_HOME_CACHE"):
|
||||
_cfg._HERMES_HOME_CACHE = None # type: ignore[attr-defined]
|
||||
|
||||
prev_out, prev_err = sys.stdout, sys.stderr
|
||||
state = _install_hangup_protection(gateway_mode=False)
|
||||
|
||||
try:
|
||||
# On Windows (no SIGHUP) we still wrap stdio and create the log.
|
||||
assert state["installed"] is True
|
||||
assert isinstance(sys.stdout, _UpdateOutputStream)
|
||||
assert isinstance(sys.stderr, _UpdateOutputStream)
|
||||
assert state["log_file"] is not None
|
||||
|
||||
sys.stdout.write("checking mirror\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
log_path = tmp_path / "logs" / "update.log"
|
||||
assert log_path.exists()
|
||||
contents = log_path.read_text(encoding="utf-8")
|
||||
assert "checking mirror" in contents
|
||||
assert "hermes update started" in contents
|
||||
finally:
|
||||
_finalize_update_output(state)
|
||||
# Sanity-check restoration
|
||||
assert sys.stdout is prev_out
|
||||
assert sys.stderr is prev_err
|
||||
|
||||
def test_logs_dir_created_if_missing(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import hermes_cli.config as _cfg
|
||||
if hasattr(_cfg, "_HERMES_HOME_CACHE"):
|
||||
_cfg._HERMES_HOME_CACHE = None # type: ignore[attr-defined]
|
||||
|
||||
# No logs/ dir yet.
|
||||
assert not (tmp_path / "logs").exists()
|
||||
|
||||
state = _install_hangup_protection(gateway_mode=False)
|
||||
try:
|
||||
assert (tmp_path / "logs").is_dir()
|
||||
assert (tmp_path / "logs" / "update.log").exists()
|
||||
finally:
|
||||
_finalize_update_output(state)
|
||||
|
||||
def test_non_fatal_if_log_setup_fails(self, monkeypatch):
|
||||
"""If get_hermes_home() raises, stdio must be left untouched but SIGHUP still handled."""
|
||||
prev_out, prev_err = sys.stdout, sys.stderr
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("no home for you")
|
||||
|
||||
# Patch the import inside _install_hangup_protection.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.get_hermes_home", _boom, raising=True
|
||||
)
|
||||
|
||||
original_handler = (
|
||||
signal.getsignal(signal.SIGHUP) if hasattr(signal, "SIGHUP") else None
|
||||
)
|
||||
|
||||
state = _install_hangup_protection(gateway_mode=False)
|
||||
|
||||
try:
|
||||
assert sys.stdout is prev_out
|
||||
assert sys.stderr is prev_err
|
||||
assert state["installed"] is False
|
||||
# SIGHUP must still be installed even when log setup fails.
|
||||
if hasattr(signal, "SIGHUP"):
|
||||
assert signal.getsignal(signal.SIGHUP) == signal.SIG_IGN
|
||||
finally:
|
||||
_finalize_update_output(state)
|
||||
if hasattr(signal, "SIGHUP") and original_handler is not None:
|
||||
signal.signal(signal.SIGHUP, original_handler)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# _finalize_update_output
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFinalizeUpdateOutput:
|
||||
def test_none_state_is_noop(self):
|
||||
_finalize_update_output(None) # must not raise
|
||||
|
||||
def test_restores_streams_and_closes_log(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import hermes_cli.config as _cfg
|
||||
if hasattr(_cfg, "_HERMES_HOME_CACHE"):
|
||||
_cfg._HERMES_HOME_CACHE = None # type: ignore[attr-defined]
|
||||
|
||||
prev_out = sys.stdout
|
||||
state = _install_hangup_protection(gateway_mode=False)
|
||||
log_file = state["log_file"]
|
||||
|
||||
assert sys.stdout is not prev_out
|
||||
assert log_file is not None
|
||||
|
||||
_finalize_update_output(state)
|
||||
|
||||
assert sys.stdout is prev_out
|
||||
# The log file handle should be closed.
|
||||
assert log_file.closed is True
|
||||
|
||||
def test_skipped_install_leaves_stdio_alone(self):
|
||||
"""When install failed (state['installed']=False) finalize should not
|
||||
touch sys.stdout / sys.stderr (they were never wrapped)."""
|
||||
# Build a synthetic state that mimics a failed install.
|
||||
sentinel_out = object()
|
||||
state = {
|
||||
"prev_stdout": sentinel_out,
|
||||
"prev_stderr": sentinel_out,
|
||||
"log_file": None,
|
||||
"installed": False,
|
||||
}
|
||||
before_out, before_err = sys.stdout, sys.stderr
|
||||
|
||||
_finalize_update_output(state)
|
||||
|
||||
assert sys.stdout is before_out
|
||||
assert sys.stderr is before_err
|
||||
@@ -86,6 +86,117 @@ def test_list_authenticated_providers_dedupes_models_when_default_in_list(monkey
|
||||
assert user_prov["models"].count("model-a") == 1, "model-a should not be duplicated"
|
||||
|
||||
|
||||
def test_list_authenticated_providers_enumerates_dict_format_models(monkeypatch):
|
||||
"""providers: dict entries with ``models:`` as a dict keyed by model id
|
||||
(canonical Hermes write format) should surface every key in the picker.
|
||||
|
||||
Regression: the ``providers:`` dict path previously only accepted
|
||||
list-format ``models:`` and silently dropped dict-format entries,
|
||||
even though Hermes's own writer and downstream readers use dict format.
|
||||
"""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
||||
|
||||
user_providers = {
|
||||
"local-ollama": {
|
||||
"name": "Local Ollama",
|
||||
"api": "http://localhost:11434/v1",
|
||||
"default_model": "minimax-m2.7:cloud",
|
||||
"models": {
|
||||
"minimax-m2.7:cloud": {"context_length": 196608},
|
||||
"kimi-k2.5:cloud": {"context_length": 200000},
|
||||
"glm-5.1:cloud": {"context_length": 202752},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="local-ollama",
|
||||
user_providers=user_providers,
|
||||
custom_providers=[],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
user_prov = next(
|
||||
(p for p in providers if p.get("is_user_defined") and p["slug"] == "local-ollama"),
|
||||
None,
|
||||
)
|
||||
|
||||
assert user_prov is not None
|
||||
assert user_prov["total_models"] == 3
|
||||
assert user_prov["models"] == [
|
||||
"minimax-m2.7:cloud",
|
||||
"kimi-k2.5:cloud",
|
||||
"glm-5.1:cloud",
|
||||
]
|
||||
|
||||
|
||||
def test_list_authenticated_providers_dict_models_without_default_model(monkeypatch):
|
||||
"""Dict-format ``models:`` without a ``default_model`` must still expose
|
||||
every dict key, not collapse to an empty list."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
||||
|
||||
user_providers = {
|
||||
"multimodel": {
|
||||
"api": "http://example.com/v1",
|
||||
"models": {
|
||||
"alpha": {"context_length": 8192},
|
||||
"beta": {"context_length": 16384},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="",
|
||||
user_providers=user_providers,
|
||||
custom_providers=[],
|
||||
)
|
||||
|
||||
user_prov = next(
|
||||
(p for p in providers if p.get("is_user_defined") and p["slug"] == "multimodel"),
|
||||
None,
|
||||
)
|
||||
|
||||
assert user_prov is not None
|
||||
assert user_prov["total_models"] == 2
|
||||
assert set(user_prov["models"]) == {"alpha", "beta"}
|
||||
|
||||
|
||||
def test_list_authenticated_providers_dict_models_dedupe_with_default(monkeypatch):
|
||||
"""When ``default_model`` is also a key in the ``models:`` dict, it must
|
||||
appear exactly once (list already had this for list-format models)."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
||||
|
||||
user_providers = {
|
||||
"my-provider": {
|
||||
"api": "http://example.com/v1",
|
||||
"default_model": "model-a",
|
||||
"models": {
|
||||
"model-a": {"context_length": 8192},
|
||||
"model-b": {"context_length": 16384},
|
||||
"model-c": {"context_length": 32768},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="my-provider",
|
||||
user_providers=user_providers,
|
||||
custom_providers=[],
|
||||
)
|
||||
|
||||
user_prov = next(
|
||||
(p for p in providers if p.get("is_user_defined")),
|
||||
None,
|
||||
)
|
||||
|
||||
assert user_prov is not None
|
||||
assert user_prov["total_models"] == 3
|
||||
assert user_prov["models"].count("model-a") == 1
|
||||
|
||||
|
||||
def test_list_authenticated_providers_fallback_to_default_only(monkeypatch):
|
||||
"""When no models array is provided, should fall back to default_model."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
@@ -116,6 +227,131 @@ def test_list_authenticated_providers_fallback_to_default_only(monkeypatch):
|
||||
assert user_prov["models"] == ["single-model"]
|
||||
|
||||
|
||||
def test_list_authenticated_providers_accepts_base_url_and_singular_model(monkeypatch):
|
||||
"""providers: dict entries written in canonical Hermes shape
|
||||
(``base_url`` + singular ``model``) should resolve the same as the
|
||||
legacy ``api`` + ``default_model`` shape.
|
||||
|
||||
Regression: section 3 previously only read ``api``/``url`` and
|
||||
``default_model``, so new-shape entries written by Hermes's own writer
|
||||
surfaced with empty ``api_url`` and no default.
|
||||
"""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
||||
|
||||
user_providers = {
|
||||
"custom": {
|
||||
"base_url": "http://example.com/v1",
|
||||
"model": "gpt-5.4",
|
||||
"models": {
|
||||
"gpt-5.4": {},
|
||||
"grok-4.20-beta": {},
|
||||
"minimax-m2.7": {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="custom",
|
||||
user_providers=user_providers,
|
||||
custom_providers=[],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
custom = next((p for p in providers if p["slug"] == "custom"), None)
|
||||
assert custom is not None
|
||||
assert custom["api_url"] == "http://example.com/v1"
|
||||
assert custom["models"] == ["gpt-5.4", "grok-4.20-beta", "minimax-m2.7"]
|
||||
assert custom["total_models"] == 3
|
||||
|
||||
|
||||
def test_list_authenticated_providers_dedupes_when_user_and_custom_overlap(monkeypatch):
|
||||
"""When the same slug appears in both ``providers:`` dict and
|
||||
``custom_providers:`` list, emit exactly one row (providers: dict wins
|
||||
since it is processed first).
|
||||
|
||||
Regression: section 3 previously had no ``seen_slugs`` check, so
|
||||
overlapping entries produced two picker rows for the same provider.
|
||||
"""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="custom",
|
||||
user_providers={
|
||||
"custom": {
|
||||
"base_url": "http://example.com/v1",
|
||||
"model": "gpt-5.4",
|
||||
"models": {
|
||||
"gpt-5.4": {},
|
||||
"grok-4.20-beta": {},
|
||||
},
|
||||
}
|
||||
},
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "custom",
|
||||
"base_url": "http://example.com/v1",
|
||||
"model": "legacy-only-model",
|
||||
}
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
matches = [p for p in providers if p["slug"] == "custom"]
|
||||
assert len(matches) == 1
|
||||
# providers: dict wins — legacy-only-model is suppressed.
|
||||
assert matches[0]["models"] == ["gpt-5.4", "grok-4.20-beta"]
|
||||
|
||||
|
||||
def test_list_authenticated_providers_no_duplicate_labels_across_schemas(monkeypatch):
|
||||
"""Regression: same endpoint in both ``providers:`` dict AND ``custom_providers:``
|
||||
list (e.g. via ``get_compatible_custom_providers()``) must not emit two picker
|
||||
rows with identical display names.
|
||||
|
||||
Before the fix, section 3 emitted bare-slug rows ("openrouter") and section 4
|
||||
emitted ``custom:openrouter`` rows for the same endpoint — both labelled
|
||||
identically, bypassing ``seen_slugs`` dedup because the slug shapes differ.
|
||||
"""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
||||
|
||||
shared_entries = [
|
||||
("endpoint-a", "http://a.local/v1"),
|
||||
("endpoint-b", "http://b.local/v1"),
|
||||
("endpoint-c", "http://c.local/v1"),
|
||||
]
|
||||
|
||||
user_providers = {
|
||||
name: {"name": name, "base_url": url, "model": "m1"}
|
||||
for name, url in shared_entries
|
||||
}
|
||||
custom_providers = [
|
||||
{"name": name, "base_url": url, "model": "m1"}
|
||||
for name, url in shared_entries
|
||||
]
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="none",
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
user_rows = [p for p in providers if p.get("source") == "user-config"]
|
||||
# Expect one row per shared entry — not two.
|
||||
assert len(user_rows) == len(shared_entries), (
|
||||
f"Expected {len(shared_entries)} rows, got {len(user_rows)}: "
|
||||
f"{[(p['slug'], p['name']) for p in user_rows]}"
|
||||
)
|
||||
|
||||
# And zero duplicate display labels.
|
||||
labels = [p["name"].lower() for p in user_rows]
|
||||
assert len(labels) == len(set(labels)), (
|
||||
f"Duplicate labels across picker rows: {labels}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for _get_named_custom_provider with providers: dict
|
||||
# =============================================================================
|
||||
|
||||
@@ -1199,6 +1199,7 @@ class TestStatusRemoteGateway:
|
||||
assert data["gateway_running"] is True
|
||||
assert data["gateway_pid"] == 999
|
||||
assert data["gateway_state"] == "running"
|
||||
assert data["gateway_health_url"] == "http://gw:8642"
|
||||
|
||||
def test_status_remote_probe_not_attempted_when_local_pid_found(self, monkeypatch):
|
||||
"""When local PID check succeeds, the remote probe is never called."""
|
||||
@@ -1235,6 +1236,7 @@ class TestStatusRemoteGateway:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["gateway_running"] is False
|
||||
assert data["gateway_health_url"] is None
|
||||
|
||||
def test_status_remote_running_null_pid(self, monkeypatch):
|
||||
"""Remote gateway running but PID not in response — pid should be None."""
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
"""Tests for Xiaomi MiMo provider support."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure dotenv doesn't interfere
|
||||
if "dotenv" not in sys.modules:
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
sys.modules["dotenv"] = fake_dotenv
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
resolve_provider,
|
||||
|
||||
Reference in New Issue
Block a user