mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
1225 lines
48 KiB
Python
1225 lines
48 KiB
Python
"""Tests for hermes_cli.doctor."""
|
|
|
|
import os
|
|
import sys
|
|
import types
|
|
import io
|
|
import contextlib
|
|
from argparse import Namespace
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.doctor as doctor
|
|
import hermes_cli.gateway as gateway_cli
|
|
from hermes_cli import doctor as doctor_mod
|
|
from hermes_cli.doctor import _has_provider_env_config
|
|
|
|
|
|
class TestDoctorPlatformHints:
|
|
def test_termux_package_hint(self, monkeypatch):
|
|
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
|
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
|
assert doctor._is_termux() is True
|
|
assert doctor._python_install_cmd() == "python -m pip install"
|
|
assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep"
|
|
|
|
def test_non_termux_package_hint_defaults_to_apt(self, monkeypatch):
|
|
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
|
monkeypatch.setenv("PREFIX", "/usr")
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
assert doctor._is_termux() is False
|
|
assert doctor._python_install_cmd() == "uv pip install"
|
|
assert doctor._system_package_install_cmd("ripgrep") == "sudo apt install ripgrep"
|
|
|
|
|
|
class TestProviderEnvDetection:
|
|
def test_detects_openai_api_key(self):
|
|
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
|
|
assert _has_provider_env_config(content)
|
|
|
|
def test_detects_custom_endpoint_without_openrouter_key(self):
|
|
content = "OPENAI_BASE_URL=http://localhost:8080/v1\n"
|
|
assert _has_provider_env_config(content)
|
|
|
|
def test_detects_kimi_cn_api_key(self):
|
|
content = "KIMI_CN_API_KEY=sk-test\n"
|
|
assert _has_provider_env_config(content)
|
|
|
|
def test_returns_false_when_no_provider_settings(self):
|
|
content = "TERMINAL_ENV=local\n"
|
|
assert not _has_provider_env_config(content)
|
|
|
|
|
|
class TestDoctorEnvFileEncoding:
|
|
"""Regression for #18637 (bug 3): `hermes doctor` crashed on Windows
|
|
Chinese locale (GBK) because `.env` was read with Path.read_text() which
|
|
defaults to the system locale encoding, not UTF-8."""
|
|
|
|
def test_doctor_reads_env_as_utf8_even_when_locale_is_not_utf8(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
import pathlib
|
|
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
# Write a UTF-8 .env containing an em dash (U+2014 = e2 80 94). The
|
|
# 0x94 byte is exactly the one the issue reporter hit: it's invalid
|
|
# as a GBK trailing byte in this position, so locale-default reads
|
|
# raise UnicodeDecodeError on Chinese Windows.
|
|
env_path = hermes_home / ".env"
|
|
env_path.write_text(
|
|
"OPENAI_API_KEY=sk-test # em-dash here — should not crash\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
|
|
|
|
orig_read_text = pathlib.Path.read_text
|
|
|
|
def gbk_like_read_text(self, encoding=None, errors=None, **kwargs):
|
|
# Simulate a GBK locale: refuse to decode this specific UTF-8
|
|
# .env unless the caller pins encoding="utf-8".
|
|
if self == env_path and encoding != "utf-8":
|
|
raise UnicodeDecodeError(
|
|
"gbk", b"\x94", 0, 1, "illegal multibyte sequence"
|
|
)
|
|
return orig_read_text(self, encoding=encoding, errors=errors, **kwargs)
|
|
|
|
monkeypatch.setattr(pathlib.Path, "read_text", gbk_like_read_text)
|
|
|
|
# Short-circuit the expensive tool-availability probe — we only
|
|
# need doctor to reach the .env read without crashing.
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
|
|
# Run doctor. If the .env read still uses locale encoding, this
|
|
# raises UnicodeDecodeError and the test fails.
|
|
with pytest.raises(SystemExit):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
|
|
|
|
class TestDoctorToolAvailabilityOverrides:
|
|
def test_marks_honcho_available_when_configured(self, monkeypatch):
|
|
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: True)
|
|
|
|
available, unavailable = doctor._apply_doctor_tool_availability_overrides(
|
|
[],
|
|
[{"name": "honcho", "env_vars": [], "tools": ["query_user_context"]}],
|
|
)
|
|
|
|
assert available == ["honcho"]
|
|
assert unavailable == []
|
|
|
|
def test_leaves_honcho_unavailable_when_not_configured(self, monkeypatch):
|
|
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: False)
|
|
|
|
honcho_entry = {"name": "honcho", "env_vars": [], "tools": ["query_user_context"]}
|
|
available, unavailable = doctor._apply_doctor_tool_availability_overrides(
|
|
[],
|
|
[honcho_entry],
|
|
)
|
|
|
|
assert available == []
|
|
assert unavailable == [honcho_entry]
|
|
|
|
def test_marks_kanban_available_only_when_missing_worker_env_gate(self, monkeypatch):
|
|
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: False)
|
|
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
|
|
|
available, unavailable = doctor._apply_doctor_tool_availability_overrides(
|
|
[],
|
|
[{"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}],
|
|
)
|
|
|
|
assert available == ["kanban"]
|
|
assert unavailable == []
|
|
|
|
def test_leaves_kanban_unavailable_when_worker_env_is_set(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_KANBAN_TASK", "probe")
|
|
kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}
|
|
|
|
available, unavailable = doctor._apply_doctor_tool_availability_overrides(
|
|
[],
|
|
[kanban_entry],
|
|
)
|
|
|
|
assert available == []
|
|
assert unavailable == [kanban_entry]
|
|
|
|
def test_leaves_non_worker_kanban_failure_unavailable(self, monkeypatch):
|
|
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
|
kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show", "not_a_kanban_tool"]}
|
|
|
|
available, unavailable = doctor._apply_doctor_tool_availability_overrides(
|
|
[],
|
|
[kanban_entry],
|
|
)
|
|
|
|
assert available == []
|
|
assert unavailable == [kanban_entry]
|
|
|
|
def test_kanban_doctor_detail_explains_worker_gate(self, monkeypatch):
|
|
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
|
|
|
assert doctor._doctor_tool_availability_detail("kanban") == "(runtime-gated; loaded only for dispatcher-spawned workers)"
|
|
|
|
|
|
class TestHonchoDoctorConfigDetection:
|
|
def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):
|
|
fake_config = SimpleNamespace(enabled=True, api_key="***")
|
|
|
|
monkeypatch.setattr(
|
|
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
|
lambda: fake_config,
|
|
)
|
|
|
|
assert doctor._honcho_is_configured_for_doctor()
|
|
|
|
def test_reports_not_configured_without_api_key(self, monkeypatch):
|
|
fake_config = SimpleNamespace(enabled=True, api_key="")
|
|
|
|
monkeypatch.setattr(
|
|
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
|
lambda: fake_config,
|
|
)
|
|
|
|
assert not doctor._honcho_is_configured_for_doctor()
|
|
|
|
|
|
def test_run_doctor_sets_interactive_env_for_tool_checks(monkeypatch, tmp_path):
|
|
"""Doctor should present CLI-gated tools as available in CLI context."""
|
|
project_root = tmp_path / "project"
|
|
hermes_home = tmp_path / ".hermes"
|
|
project_root.mkdir()
|
|
hermes_home.mkdir()
|
|
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project_root)
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
|
|
seen = {}
|
|
|
|
def fake_check_tool_availability(*args, **kwargs):
|
|
seen["interactive"] = os.getenv("HERMES_INTERACTIVE")
|
|
raise SystemExit(0)
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=fake_check_tool_availability,
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
|
|
with pytest.raises(SystemExit):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
|
|
assert seen["interactive"] == "1"
|
|
|
|
|
|
def test_check_gateway_service_linger_warns_when_disabled(monkeypatch, tmp_path, capsys):
|
|
unit_path = tmp_path / "hermes-gateway.service"
|
|
unit_path.write_text("[Unit]\n")
|
|
|
|
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
|
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
|
|
monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (False, ""))
|
|
|
|
issues = []
|
|
doctor._check_gateway_service_linger(issues)
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Gateway Service" in out
|
|
assert "Systemd linger disabled" in out
|
|
assert "loginctl enable-linger" in out
|
|
assert issues == [
|
|
"Enable linger for the gateway user service: sudo loginctl enable-linger $USER"
|
|
]
|
|
|
|
|
|
def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypatch, tmp_path, capsys):
|
|
unit_path = tmp_path / "missing.service"
|
|
|
|
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
|
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
|
|
|
|
issues = []
|
|
doctor._check_gateway_service_linger(issues)
|
|
|
|
out = capsys.readouterr().out
|
|
assert out == ""
|
|
assert issues == []
|
|
|
|
|
|
def test_doctor_reports_vercel_backend_diagnostics(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
|
monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
|
|
monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "2048")
|
|
monkeypatch.setenv("VERCEL_TOKEN", "super-secret-value")
|
|
monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False)
|
|
monkeypatch.setenv("VERCEL_TEAM_ID", "team")
|
|
monkeypatch.setattr(doctor_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: ([], []),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
|
|
out = buf.getvalue()
|
|
assert "Vercel runtime" in out
|
|
assert "python3.13" in out
|
|
assert "Vercel custom disk unsupported" in out
|
|
assert "Vercel auth incomplete" in out
|
|
assert "VERCEL_PROJECT_ID" in out
|
|
assert "Vercel auth mode: incomplete access token" in out
|
|
assert "Vercel auth present env: VERCEL_TOKEN, VERCEL_TEAM_ID" in out
|
|
assert "Vercel auth missing env: VERCEL_PROJECT_ID" in out
|
|
assert "super-secret-value" not in out
|
|
assert "snapshot filesystem only" in out
|
|
|
|
|
|
# ── Memory provider section (doctor should only check the *active* provider) ──
|
|
|
|
|
|
class TestDoctorMemoryProviderSection:
|
|
"""The ◆ Memory Provider section should respect memory.provider config."""
|
|
|
|
def _make_hermes_home(self, tmp_path, provider=""):
|
|
"""Create a minimal HERMES_HOME with config.yaml."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
import yaml
|
|
config = {"memory": {"provider": provider}} if provider else {"memory": {}}
|
|
(home / "config.yaml").write_text(yaml.dump(config))
|
|
return home
|
|
|
|
def _run_doctor_and_capture(self, monkeypatch, tmp_path, provider=""):
|
|
"""Run doctor and capture stdout."""
|
|
home = self._make_hermes_home(tmp_path, provider)
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
(tmp_path / "project").mkdir(exist_ok=True)
|
|
|
|
# Stub tool availability (returns empty) so doctor runs past it
|
|
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 to avoid real API calls
|
|
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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
|
|
import io, contextlib
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
return buf.getvalue()
|
|
|
|
def test_no_provider_shows_builtin_ok(self, monkeypatch, tmp_path):
|
|
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
|
|
assert "Memory Provider" in out
|
|
assert "Built-in memory active" in out
|
|
# Should NOT mention Honcho or Mem0 errors
|
|
assert "Honcho API key" not in out
|
|
assert "Mem0" not in out
|
|
|
|
def test_honcho_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
|
|
# Make honcho import fail
|
|
monkeypatch.setitem(
|
|
sys.modules, "plugins.memory.honcho.client", None
|
|
)
|
|
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="honcho")
|
|
assert "Memory Provider" in out
|
|
# Should show failure since honcho is set but not importable
|
|
assert "Built-in memory active" not in out
|
|
|
|
def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
|
|
# Make mem0 import fail
|
|
monkeypatch.setitem(sys.modules, "plugins.memory.mem0", None)
|
|
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0")
|
|
assert "Memory Provider" in out
|
|
assert "Built-in memory active" not in out
|
|
|
|
|
|
def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path):
|
|
helper = TestDoctorMemoryProviderSection()
|
|
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
|
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
|
|
|
real_which = doctor_mod.shutil.which
|
|
|
|
def fake_which(cmd):
|
|
if cmd in {"docker", "node", "npm"}:
|
|
return None
|
|
return real_which(cmd)
|
|
|
|
monkeypatch.setattr(doctor_mod.shutil, "which", fake_which)
|
|
|
|
out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
|
|
|
|
assert "Docker backend is not available inside Termux" in out
|
|
assert "Node.js not found (browser tools are optional in the tested Termux path)" in out
|
|
assert "Install Node.js on Termux with: pkg install nodejs" in out
|
|
assert "Termux browser setup:" in out
|
|
assert "1) pkg install nodejs" in out
|
|
assert "2) npm install -g agent-browser" in out
|
|
assert "3) agent-browser install" in out
|
|
assert "Termux compatibility fallbacks:" in out
|
|
assert "use .[termux-all] for broad compatibility" in out
|
|
assert "Matrix E2EE extra is excluded on Termux" in out
|
|
assert "Local faster-whisper extra is excluded on Termux" in out
|
|
assert "STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY)." in out
|
|
assert "docker not found (optional)" not in out
|
|
|
|
|
|
def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
|
|
import yaml
|
|
|
|
(home / "config.yaml").write_text(
|
|
yaml.dump(
|
|
{
|
|
"model": {
|
|
"provider": "volcengine-plan",
|
|
"default": "doubao-seed-2.0-code",
|
|
},
|
|
"providers": {
|
|
"volcengine-plan": {
|
|
"name": "volcengine-plan",
|
|
"base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
|
|
"default_model": "doubao-seed-2.0-code",
|
|
"models": {"doubao-seed-2.0-code": {}},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
(tmp_path / "project").mkdir(exist_ok=True)
|
|
|
|
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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
|
|
out = buf.getvalue()
|
|
assert "model.provider 'volcengine-plan' is not a recognised provider" not in out
|
|
|
|
|
|
def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text(
|
|
"model:\n"
|
|
" provider: custom\n"
|
|
" default: local-model\n"
|
|
" base_url: http://localhost:8000/v1\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
(tmp_path / "project").mkdir(exist_ok=True)
|
|
|
|
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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
|
|
out = buf.getvalue()
|
|
assert "model.provider 'custom' is not a recognised provider" not in out
|
|
|
|
|
|
def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text(
|
|
"model:\n"
|
|
" provider: openrouter\n"
|
|
" default: openai/gpt-4.1-mini\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
(tmp_path / "project").mkdir(exist_ok=True)
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: ([], []),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
|
|
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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
|
|
out = buf.getvalue()
|
|
assert "model.provider 'openrouter' is set but no API key is configured" in out
|
|
assert "No credentials found for provider 'openrouter'." in out
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("provider", "default_model"),
|
|
[
|
|
("ai-gateway", "anthropic/claude-sonnet-4.6"),
|
|
("opencode-zen", "anthropic/claude-sonnet-4.6"),
|
|
("kilocode", "anthropic/claude-sonnet-4.6"),
|
|
("kimi-coding", "kimi-k2"),
|
|
],
|
|
)
|
|
def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
|
|
monkeypatch, tmp_path, provider, default_model
|
|
):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text(
|
|
"model:\n"
|
|
f" provider: {provider}\n"
|
|
f" default: {default_model}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
(tmp_path / "project").mkdir(exist_ok=True)
|
|
|
|
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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
|
|
out = buf.getvalue()
|
|
assert f"model.provider '{provider}' is not a recognised provider" not in out
|
|
assert f"model.provider '{provider}' is unknown" not in out
|
|
if provider in {"ai-gateway", "opencode-zen", "kilocode"}:
|
|
assert (
|
|
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
|
|
not in out
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8")
|
|
(home / "config.yaml").write_text(
|
|
"model:\n"
|
|
" provider: kimi-coding-cn\n"
|
|
" default: kimi-k2.6\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
(tmp_path / "project").mkdir(exist_ok=True)
|
|
|
|
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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
|
|
out = buf.getvalue()
|
|
assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
|
|
|
|
|
|
def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(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)
|
|
|
|
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
|
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None)
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]),
|
|
TOOLSET_REQUIREMENTS={
|
|
"terminal": {"name": "terminal"},
|
|
"browser": {"name": "browser"},
|
|
},
|
|
)
|
|
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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
|
|
import io, contextlib
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
|
|
assert "✓ browser" not in out
|
|
assert "browser" in out
|
|
assert "system dependency not met" in out
|
|
assert "agent-browser is not installed (expected in the tested Termux path)" in out
|
|
assert "npm install -g agent-browser && agent-browser install" in out
|
|
|
|
|
|
def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, tmp_path):
|
|
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("KIMI_CN_API_KEY=sk-test\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("KIMI_CN_API_KEY", "sk-test")
|
|
|
|
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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
|
except Exception:
|
|
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 "API key or custom endpoint configured" in out
|
|
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)
|
|
|
|
|
|
def test_run_doctor_dashscope_retries_china_endpoint_after_intl_unauthorized(monkeypatch, tmp_path):
|
|
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("DASHSCOPE_API_KEY=sk-test\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("DASHSCOPE_API_KEY", "sk-test")
|
|
monkeypatch.delenv("DASHSCOPE_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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
|
except ImportError:
|
|
pass
|
|
|
|
calls = []
|
|
|
|
def fake_get(url, headers=None, timeout=None):
|
|
calls.append((url, headers, timeout))
|
|
status = 200 if "dashscope.aliyuncs.com" in url else 401
|
|
return types.SimpleNamespace(status_code=status)
|
|
|
|
import httpx
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
|
|
assert "Alibaba/DashScope" in out
|
|
assert "invalid API key" not in out
|
|
assert any(
|
|
url == "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models"
|
|
for url, _, _ in calls
|
|
)
|
|
assert any(
|
|
url == "https://dashscope.aliyuncs.com/compatible-mode/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: {})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_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)
|
|
|
|
|
|
class TestGitHubTokenCheck:
|
|
"""Tests for GitHub token / gh auth detection in doctor."""
|
|
|
|
def test_no_token_and_not_gh_authenticated_shows_warn(self, monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
|
|
|
|
from hermes_cli.doctor import run_doctor, _DHH
|
|
import io, contextlib
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
|
|
assert "No GITHUB_TOKEN" in out
|
|
assert "60 req/hr" in out
|
|
|
|
def test_token_env_present_shows_ok(self, monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_test123")
|
|
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
|
|
|
|
from hermes_cli.doctor import run_doctor
|
|
import io, contextlib
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
|
|
assert "GitHub token configured" in out
|
|
|
|
def test_gh_authenticated_without_env_token_shows_ok(self, monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
# No GITHUB_TOKEN or GH_TOKEN
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
|
|
# Mock gh to return success
|
|
import shutil
|
|
real_which = shutil.which
|
|
def mock_which(cmd):
|
|
return "/usr/local/bin/gh" if cmd == "gh" else real_which(cmd)
|
|
monkeypatch.setattr(shutil, "which", mock_which)
|
|
|
|
call_log = []
|
|
def mock_run(cmd, **kwargs):
|
|
call_log.append(cmd)
|
|
if cmd[:2] == ["gh", "auth"]:
|
|
result = types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
else:
|
|
result = types.SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
return result
|
|
|
|
import subprocess
|
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
|
|
|
from hermes_cli.doctor import run_doctor
|
|
import io, contextlib
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
|
|
assert "gh auth" in str(call_log) or any(c[0] == "gh" for c in call_log), f"gh not called: {call_log}"
|
|
assert "GitHub authenticated via gh CLI" in out or "token configured" in out
|
|
|
|
|
|
def _run_doctor_with_healthy_oauth_fallback(
|
|
monkeypatch,
|
|
tmp_path,
|
|
*,
|
|
env_key: str,
|
|
bad_key: str,
|
|
failing_host: str,
|
|
gemini_oauth_status: dict,
|
|
minimax_oauth_status: dict,
|
|
xai_oauth_status: dict | None = None,
|
|
) -> str:
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text(
|
|
"model:\n"
|
|
" provider: nous\n"
|
|
" default: moonshotai/kimi-k2.6\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(env_key, bad_key)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
|
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
|
|
monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
|
|
monkeypatch.delenv("MINIMAX_CN_API_KEY", raising=False)
|
|
monkeypatch.setenv(env_key, bad_key)
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: ([], []),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
|
|
from hermes_cli import auth as _auth_mod
|
|
|
|
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: gemini_oauth_status)
|
|
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status)
|
|
_xai_status = xai_oauth_status if xai_oauth_status is not None else {}
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: _xai_status)
|
|
|
|
def fake_get(url, headers=None, timeout=None):
|
|
status = 401 if failing_host in url else 200
|
|
return types.SimpleNamespace(status_code=status)
|
|
|
|
import httpx
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
return buf.getvalue()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("env_key", "bad_key", "failing_host", "gemini_oauth_status", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"),
|
|
[
|
|
(
|
|
"GOOGLE_API_KEY",
|
|
"bad-gemini-key",
|
|
"googleapis.com",
|
|
{"logged_in": True, "email": "user@example.com"},
|
|
{},
|
|
None,
|
|
"Check GOOGLE_API_KEY in .env",
|
|
),
|
|
(
|
|
"MINIMAX_API_KEY",
|
|
"bad-minimax-key",
|
|
"minimax.io",
|
|
{},
|
|
{"logged_in": True, "region": "global"},
|
|
None,
|
|
"Check MINIMAX_API_KEY in .env",
|
|
),
|
|
(
|
|
"XAI_API_KEY",
|
|
"bad-xai-key",
|
|
"api.x.ai",
|
|
{},
|
|
{},
|
|
{"logged_in": True, "auth_mode": "oauth_pkce"},
|
|
"Check XAI_API_KEY in .env",
|
|
),
|
|
],
|
|
)
|
|
def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
|
|
monkeypatch,
|
|
tmp_path,
|
|
env_key,
|
|
bad_key,
|
|
failing_host,
|
|
gemini_oauth_status,
|
|
minimax_oauth_status,
|
|
xai_oauth_status,
|
|
unexpected_issue,
|
|
):
|
|
out = _run_doctor_with_healthy_oauth_fallback(
|
|
monkeypatch,
|
|
tmp_path,
|
|
env_key=env_key,
|
|
bad_key=bad_key,
|
|
failing_host=failing_host,
|
|
gemini_oauth_status=gemini_oauth_status,
|
|
minimax_oauth_status=minimax_oauth_status,
|
|
xai_oauth_status=xai_oauth_status,
|
|
)
|
|
|
|
assert "invalid API key" in out
|
|
assert unexpected_issue not in out
|
|
|
|
|
|
def test_has_healthy_oauth_fallback_returns_false_for_unknown_provider():
|
|
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
|
|
assert _has_healthy_oauth_fallback_for_apikey_provider("unknown-provider") is False
|
|
|
|
|
|
class TestHasHealthyOauthFallbackForXai:
|
|
def test_returns_true_when_xai_oauth_healthy(self, monkeypatch):
|
|
from hermes_cli import auth as _auth_mod
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": True})
|
|
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
|
|
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is True
|
|
|
|
def test_returns_false_when_xai_oauth_not_logged_in(self, monkeypatch):
|
|
from hermes_cli import auth as _auth_mod
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": False})
|
|
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
|
|
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False
|
|
|
|
def test_returns_false_when_xai_oauth_returns_none(self, monkeypatch):
|
|
from hermes_cli import auth as _auth_mod
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: None)
|
|
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
|
|
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False
|
|
|
|
def test_returns_false_when_xai_import_unavailable(self, monkeypatch):
|
|
import sys
|
|
# Simulate get_xai_oauth_auth_status missing from auth module
|
|
monkeypatch.delattr("hermes_cli.auth.get_xai_oauth_auth_status", raising=False)
|
|
# Force doctor module to re-import the function
|
|
monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False)
|
|
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
|
|
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False
|
|
|
|
def test_xai_import_failure_does_not_affect_gemini(self, monkeypatch):
|
|
import sys
|
|
from hermes_cli import auth as _auth_mod
|
|
# xAI function missing, but Gemini is healthy
|
|
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
|
|
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": True})
|
|
monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False)
|
|
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
|
|
assert _has_healthy_oauth_fallback_for_apikey_provider("gemini") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ◆ Auth Providers — xAI OAuth display in run_doctor()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDoctorXaiOAuthStatus:
|
|
"""The ◆ Auth Providers section must show xAI OAuth login state.
|
|
|
|
xAI OAuth is checked in a *separate* try/except block so that an import
|
|
failure (or runtime exception) cannot silence the Nous / Codex / Gemini /
|
|
MiniMax rows that were already printed above it.
|
|
"""
|
|
|
|
def _run(self, monkeypatch, tmp_path, *, xai_auth_fn) -> str:
|
|
"""Run doctor with a controlled xAI auth callable; return stdout."""
|
|
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))
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: ([], []),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
|
|
from hermes_cli import auth as _auth_mod
|
|
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", xai_auth_fn)
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
return buf.getvalue()
|
|
|
|
def test_logged_in_shows_ok(self, monkeypatch, tmp_path):
|
|
out = self._run(
|
|
monkeypatch, tmp_path,
|
|
xai_auth_fn=lambda: {"logged_in": True},
|
|
)
|
|
assert "xAI OAuth" in out
|
|
assert "(logged in)" in out
|
|
|
|
def test_not_logged_in_shows_warn(self, monkeypatch, tmp_path):
|
|
out = self._run(
|
|
monkeypatch, tmp_path,
|
|
xai_auth_fn=lambda: {"logged_in": False},
|
|
)
|
|
assert "xAI OAuth" in out
|
|
assert "(not logged in)" in out
|
|
|
|
def test_error_shown_when_not_logged_in_and_error_present(self, monkeypatch, tmp_path):
|
|
out = self._run(
|
|
monkeypatch, tmp_path,
|
|
xai_auth_fn=lambda: {"logged_in": False, "error": "refresh token expired"},
|
|
)
|
|
assert "xAI OAuth" in out
|
|
assert "refresh token expired" in out
|
|
|
|
def test_no_error_line_when_error_key_absent(self, monkeypatch, tmp_path):
|
|
out = self._run(
|
|
monkeypatch, tmp_path,
|
|
xai_auth_fn=lambda: {"logged_in": False},
|
|
)
|
|
assert "xAI OAuth" in out
|
|
# The check_info line is only emitted when the "error" key is present.
|
|
# Pick a token that would appear in no ordinary doctor output.
|
|
assert "refresh token expired" not in out
|
|
|
|
def test_logged_in_does_not_emit_not_logged_in_on_xai_line(self, monkeypatch, tmp_path):
|
|
out = self._run(
|
|
monkeypatch, tmp_path,
|
|
xai_auth_fn=lambda: {"logged_in": True},
|
|
)
|
|
assert "xAI OAuth" in out
|
|
# The xAI OAuth line itself must say "(logged in)", not "(not logged in)".
|
|
xai_line = next(l for l in out.splitlines() if "xAI OAuth" in l)
|
|
assert "(logged in)" in xai_line
|
|
assert "(not logged in)" not in xai_line
|
|
|
|
def test_import_failure_does_not_crash_doctor(self, monkeypatch, tmp_path):
|
|
"""Doctor must not crash when get_xai_oauth_auth_status cannot be imported."""
|
|
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))
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: ([], []),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
|
|
from hermes_cli import auth as _auth_mod
|
|
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
# The ◆ Auth Providers header must still appear — other providers unaffected.
|
|
assert "Auth Providers" in out
|
|
|
|
def test_import_failure_does_not_affect_other_providers(self, monkeypatch, tmp_path):
|
|
"""Nous / Codex / Gemini / MiniMax rows must survive an xAI import failure."""
|
|
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))
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: ([], []),
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
|
|
from hermes_cli import auth as _auth_mod
|
|
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
|
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
assert "Nous Portal auth" in out
|
|
assert "logged in" in out
|
|
|
|
def test_function_raises_does_not_crash_doctor(self, monkeypatch, tmp_path):
|
|
"""A runtime exception from get_xai_oauth_auth_status must be swallowed."""
|
|
def _raise():
|
|
raise RuntimeError("simulated xAI status failure")
|
|
|
|
out = self._run(monkeypatch, tmp_path, xai_auth_fn=_raise)
|
|
assert "Auth Providers" in out
|
|
|
|
def test_function_returns_none_does_not_crash_doctor(self, monkeypatch, tmp_path):
|
|
"""None return is normalised to {} via `or {}` — must not AttributeError."""
|
|
out = self._run(monkeypatch, tmp_path, xai_auth_fn=lambda: None)
|
|
# None → {} → logged_in falsy → shows not-logged-in warn
|
|
assert "xAI OAuth" in out
|
|
assert "(not logged in)" in out
|