/ tests / hermes_cli / test_doctor.py
test_doctor.py
  1  """Tests for hermes_cli.doctor."""
  2  
  3  import os
  4  import sys
  5  import types
  6  import io
  7  import contextlib
  8  from argparse import Namespace
  9  from types import SimpleNamespace
 10  
 11  import pytest
 12  
 13  import hermes_cli.doctor as doctor
 14  import hermes_cli.gateway as gateway_cli
 15  from hermes_cli import doctor as doctor_mod
 16  from hermes_cli.doctor import _has_provider_env_config
 17  
 18  
 19  class TestDoctorPlatformHints:
 20      def test_termux_package_hint(self, monkeypatch):
 21          monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
 22          monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
 23          assert doctor._is_termux() is True
 24          assert doctor._python_install_cmd() == "python -m pip install"
 25          assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep"
 26  
 27      def test_non_termux_package_hint_defaults_to_apt(self, monkeypatch):
 28          monkeypatch.delenv("TERMUX_VERSION", raising=False)
 29          monkeypatch.setenv("PREFIX", "/usr")
 30          monkeypatch.setattr(sys, "platform", "linux")
 31          assert doctor._is_termux() is False
 32          assert doctor._python_install_cmd() == "uv pip install"
 33          assert doctor._system_package_install_cmd("ripgrep") == "sudo apt install ripgrep"
 34  
 35  
 36  class TestProviderEnvDetection:
 37      def test_detects_openai_api_key(self):
 38          content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
 39          assert _has_provider_env_config(content)
 40  
 41      def test_detects_custom_endpoint_without_openrouter_key(self):
 42          content = "OPENAI_BASE_URL=http://localhost:8080/v1\n"
 43          assert _has_provider_env_config(content)
 44  
 45      def test_detects_kimi_cn_api_key(self):
 46          content = "KIMI_CN_API_KEY=sk-test\n"
 47          assert _has_provider_env_config(content)
 48  
 49      def test_returns_false_when_no_provider_settings(self):
 50          content = "TERMINAL_ENV=local\n"
 51          assert not _has_provider_env_config(content)
 52  
 53  
 54  class TestDoctorEnvFileEncoding:
 55      """Regression for #18637 (bug 3): `hermes doctor` crashed on Windows
 56      Chinese locale (GBK) because `.env` was read with Path.read_text() which
 57      defaults to the system locale encoding, not UTF-8."""
 58  
 59      def test_doctor_reads_env_as_utf8_even_when_locale_is_not_utf8(
 60          self, monkeypatch, tmp_path
 61      ):
 62          import pathlib
 63  
 64          hermes_home = tmp_path / ".hermes"
 65          hermes_home.mkdir()
 66          # Write a UTF-8 .env containing an em dash (U+2014 = e2 80 94). The
 67          # 0x94 byte is exactly the one the issue reporter hit: it's invalid
 68          # as a GBK trailing byte in this position, so locale-default reads
 69          # raise UnicodeDecodeError on Chinese Windows.
 70          env_path = hermes_home / ".env"
 71          env_path.write_text(
 72              "OPENAI_API_KEY=sk-test  # em-dash here — should not crash\n",
 73              encoding="utf-8",
 74          )
 75  
 76          monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
 77  
 78          orig_read_text = pathlib.Path.read_text
 79  
 80          def gbk_like_read_text(self, encoding=None, errors=None, **kwargs):
 81              # Simulate a GBK locale: refuse to decode this specific UTF-8
 82              # .env unless the caller pins encoding="utf-8".
 83              if self == env_path and encoding != "utf-8":
 84                  raise UnicodeDecodeError(
 85                      "gbk", b"\x94", 0, 1, "illegal multibyte sequence"
 86                  )
 87              return orig_read_text(self, encoding=encoding, errors=errors, **kwargs)
 88  
 89          monkeypatch.setattr(pathlib.Path, "read_text", gbk_like_read_text)
 90  
 91          # Short-circuit the expensive tool-availability probe — we only
 92          # need doctor to reach the .env read without crashing.
 93          fake_model_tools = types.SimpleNamespace(
 94              check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
 95              TOOLSET_REQUIREMENTS={},
 96          )
 97          monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
 98  
 99          # Run doctor. If the .env read still uses locale encoding, this
100          # raises UnicodeDecodeError and the test fails.
101          with pytest.raises(SystemExit):
102              doctor_mod.run_doctor(Namespace(fix=False))
103  
104  
105  class TestDoctorToolAvailabilityOverrides:
106      def test_marks_honcho_available_when_configured(self, monkeypatch):
107          monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: True)
108  
109          available, unavailable = doctor._apply_doctor_tool_availability_overrides(
110              [],
111              [{"name": "honcho", "env_vars": [], "tools": ["query_user_context"]}],
112          )
113  
114          assert available == ["honcho"]
115          assert unavailable == []
116  
117      def test_leaves_honcho_unavailable_when_not_configured(self, monkeypatch):
118          monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: False)
119  
120          honcho_entry = {"name": "honcho", "env_vars": [], "tools": ["query_user_context"]}
121          available, unavailable = doctor._apply_doctor_tool_availability_overrides(
122              [],
123              [honcho_entry],
124          )
125  
126          assert available == []
127          assert unavailable == [honcho_entry]
128  
129  
130  class TestHonchoDoctorConfigDetection:
131      def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):
132          fake_config = SimpleNamespace(enabled=True, api_key="***")
133  
134          monkeypatch.setattr(
135              "plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
136              lambda: fake_config,
137          )
138  
139          assert doctor._honcho_is_configured_for_doctor()
140  
141      def test_reports_not_configured_without_api_key(self, monkeypatch):
142          fake_config = SimpleNamespace(enabled=True, api_key="")
143  
144          monkeypatch.setattr(
145              "plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
146              lambda: fake_config,
147          )
148  
149          assert not doctor._honcho_is_configured_for_doctor()
150  
151  
152  def test_run_doctor_sets_interactive_env_for_tool_checks(monkeypatch, tmp_path):
153      """Doctor should present CLI-gated tools as available in CLI context."""
154      project_root = tmp_path / "project"
155      hermes_home = tmp_path / ".hermes"
156      project_root.mkdir()
157      hermes_home.mkdir()
158  
159      monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project_root)
160      monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
161      monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
162  
163      seen = {}
164  
165      def fake_check_tool_availability(*args, **kwargs):
166          seen["interactive"] = os.getenv("HERMES_INTERACTIVE")
167          raise SystemExit(0)
168  
169      fake_model_tools = types.SimpleNamespace(
170          check_tool_availability=fake_check_tool_availability,
171          TOOLSET_REQUIREMENTS={},
172      )
173      monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
174  
175      with pytest.raises(SystemExit):
176          doctor_mod.run_doctor(Namespace(fix=False))
177  
178      assert seen["interactive"] == "1"
179  
180  
181  def test_check_gateway_service_linger_warns_when_disabled(monkeypatch, tmp_path, capsys):
182      unit_path = tmp_path / "hermes-gateway.service"
183      unit_path.write_text("[Unit]\n")
184  
185      monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
186      monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
187      monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (False, ""))
188  
189      issues = []
190      doctor._check_gateway_service_linger(issues)
191  
192      out = capsys.readouterr().out
193      assert "Gateway Service" in out
194      assert "Systemd linger disabled" in out
195      assert "loginctl enable-linger" in out
196      assert issues == [
197          "Enable linger for the gateway user service: sudo loginctl enable-linger $USER"
198      ]
199  
200  
201  def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypatch, tmp_path, capsys):
202      unit_path = tmp_path / "missing.service"
203  
204      monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
205      monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
206  
207      issues = []
208      doctor._check_gateway_service_linger(issues)
209  
210      out = capsys.readouterr().out
211      assert out == ""
212      assert issues == []
213  
214  
215  def test_doctor_reports_vercel_backend_diagnostics(monkeypatch, tmp_path):
216      monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
217      monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
218      monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "2048")
219      monkeypatch.setenv("VERCEL_TOKEN", "super-secret-value")
220      monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False)
221      monkeypatch.setenv("VERCEL_TEAM_ID", "team")
222      monkeypatch.setattr(doctor_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)
223  
224      fake_model_tools = types.SimpleNamespace(
225          check_tool_availability=lambda *a, **kw: ([], []),
226          TOOLSET_REQUIREMENTS={},
227      )
228      monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
229  
230      buf = io.StringIO()
231      with contextlib.redirect_stdout(buf):
232          doctor_mod.run_doctor(Namespace(fix=False))
233  
234      out = buf.getvalue()
235      assert "Vercel runtime" in out
236      assert "python3.13" in out
237      assert "Vercel custom disk unsupported" in out
238      assert "Vercel auth incomplete" in out
239      assert "VERCEL_PROJECT_ID" in out
240      assert "Vercel auth mode: incomplete access token" in out
241      assert "Vercel auth present env: VERCEL_TOKEN, VERCEL_TEAM_ID" in out
242      assert "Vercel auth missing env: VERCEL_PROJECT_ID" in out
243      assert "super-secret-value" not in out
244      assert "snapshot filesystem only" in out
245  
246  
247  # ── Memory provider section (doctor should only check the *active* provider) ──
248  
249  
250  class TestDoctorMemoryProviderSection:
251      """The ◆ Memory Provider section should respect memory.provider config."""
252  
253      def _make_hermes_home(self, tmp_path, provider=""):
254          """Create a minimal HERMES_HOME with config.yaml."""
255          home = tmp_path / ".hermes"
256          home.mkdir(parents=True, exist_ok=True)
257          import yaml
258          config = {"memory": {"provider": provider}} if provider else {"memory": {}}
259          (home / "config.yaml").write_text(yaml.dump(config))
260          return home
261  
262      def _run_doctor_and_capture(self, monkeypatch, tmp_path, provider=""):
263          """Run doctor and capture stdout."""
264          home = self._make_hermes_home(tmp_path, provider)
265          monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
266          monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
267          monkeypatch.setattr(doctor_mod, "_DHH", str(home))
268          (tmp_path / "project").mkdir(exist_ok=True)
269  
270          # Stub tool availability (returns empty) so doctor runs past it
271          fake_model_tools = types.SimpleNamespace(
272              check_tool_availability=lambda *a, **kw: ([], []),
273              TOOLSET_REQUIREMENTS={},
274          )
275          monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
276  
277          # Stub auth checks to avoid real API calls
278          try:
279              from hermes_cli import auth as _auth_mod
280              monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
281              monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
282          except Exception:
283              pass
284  
285          import io, contextlib
286          buf = io.StringIO()
287          with contextlib.redirect_stdout(buf):
288              doctor_mod.run_doctor(Namespace(fix=False))
289          return buf.getvalue()
290  
291      def test_no_provider_shows_builtin_ok(self, monkeypatch, tmp_path):
292          out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
293          assert "Memory Provider" in out
294          assert "Built-in memory active" in out
295          # Should NOT mention Honcho or Mem0 errors
296          assert "Honcho API key" not in out
297          assert "Mem0" not in out
298  
299      def test_honcho_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
300          # Make honcho import fail
301          monkeypatch.setitem(
302              sys.modules, "plugins.memory.honcho.client", None
303          )
304          out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="honcho")
305          assert "Memory Provider" in out
306          # Should show failure since honcho is set but not importable
307          assert "Built-in memory active" not in out
308  
309      def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
310          # Make mem0 import fail
311          monkeypatch.setitem(sys.modules, "plugins.memory.mem0", None)
312          out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0")
313          assert "Memory Provider" in out
314          assert "Built-in memory active" not in out
315  
316  
317  def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path):
318      helper = TestDoctorMemoryProviderSection()
319      monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
320      monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
321  
322      real_which = doctor_mod.shutil.which
323  
324      def fake_which(cmd):
325          if cmd in {"docker", "node", "npm"}:
326              return None
327          return real_which(cmd)
328  
329      monkeypatch.setattr(doctor_mod.shutil, "which", fake_which)
330  
331      out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
332  
333      assert "Docker backend is not available inside Termux" in out
334      assert "Node.js not found (browser tools are optional in the tested Termux path)" in out
335      assert "Install Node.js on Termux with: pkg install nodejs" in out
336      assert "Termux browser setup:" in out
337      assert "1) pkg install nodejs" in out
338      assert "2) npm install -g agent-browser" in out
339      assert "3) agent-browser install" in out
340      assert "docker not found (optional)" not in out
341  
342  
343  def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, tmp_path):
344      home = tmp_path / ".hermes"
345      home.mkdir(parents=True, exist_ok=True)
346  
347      import yaml
348  
349      (home / "config.yaml").write_text(
350          yaml.dump(
351              {
352                  "model": {
353                      "provider": "volcengine-plan",
354                      "default": "doubao-seed-2.0-code",
355                  },
356                  "providers": {
357                      "volcengine-plan": {
358                          "name": "volcengine-plan",
359                          "base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
360                          "default_model": "doubao-seed-2.0-code",
361                          "models": {"doubao-seed-2.0-code": {}},
362                      }
363                  },
364              }
365          )
366      )
367  
368      monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
369      monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
370      monkeypatch.setattr(doctor_mod, "_DHH", str(home))
371      (tmp_path / "project").mkdir(exist_ok=True)
372  
373      fake_model_tools = types.SimpleNamespace(
374          check_tool_availability=lambda *a, **kw: ([], []),
375          TOOLSET_REQUIREMENTS={},
376      )
377      monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
378  
379      try:
380          from hermes_cli import auth as _auth_mod
381          monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
382          monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
383      except Exception:
384          pass
385  
386      buf = io.StringIO()
387      with contextlib.redirect_stdout(buf):
388          doctor_mod.run_doctor(Namespace(fix=False))
389  
390      out = buf.getvalue()
391      assert "model.provider 'volcengine-plan' is not a recognised provider" not in out
392  
393  
394  def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
395      home = tmp_path / ".hermes"
396      home.mkdir(parents=True, exist_ok=True)
397      (home / "config.yaml").write_text(
398          "model:\n"
399          "  provider: custom\n"
400          "  default: local-model\n"
401          "  base_url: http://localhost:8000/v1\n",
402          encoding="utf-8",
403      )
404  
405      monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
406      monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
407      monkeypatch.setattr(doctor_mod, "_DHH", str(home))
408      (tmp_path / "project").mkdir(exist_ok=True)
409  
410      fake_model_tools = types.SimpleNamespace(
411          check_tool_availability=lambda *a, **kw: ([], []),
412          TOOLSET_REQUIREMENTS={},
413      )
414      monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
415  
416      try:
417          from hermes_cli import auth as _auth_mod
418          monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
419          monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
420      except Exception:
421          pass
422  
423      buf = io.StringIO()
424      with contextlib.redirect_stdout(buf):
425          doctor_mod.run_doctor(Namespace(fix=False))
426  
427      out = buf.getvalue()
428      assert "model.provider 'custom' is not a recognised provider" not in out
429  
430  
431  @pytest.mark.parametrize(
432      ("provider", "default_model"),
433      [
434          ("ai-gateway", "anthropic/claude-sonnet-4.6"),
435          ("opencode-zen", "anthropic/claude-sonnet-4.6"),
436          ("kilocode", "anthropic/claude-sonnet-4.6"),
437          ("kimi-coding", "kimi-k2"),
438      ],
439  )
440  def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
441      monkeypatch, tmp_path, provider, default_model
442  ):
443      home = tmp_path / ".hermes"
444      home.mkdir(parents=True, exist_ok=True)
445      (home / "config.yaml").write_text(
446          "model:\n"
447          f"  provider: {provider}\n"
448          f"  default: {default_model}\n",
449          encoding="utf-8",
450      )
451  
452      monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
453      monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
454      monkeypatch.setattr(doctor_mod, "_DHH", str(home))
455      (tmp_path / "project").mkdir(exist_ok=True)
456  
457      fake_model_tools = types.SimpleNamespace(
458          check_tool_availability=lambda *a, **kw: ([], []),
459          TOOLSET_REQUIREMENTS={},
460      )
461      monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
462  
463      try:
464          from hermes_cli import auth as _auth_mod
465          monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
466          monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
467      except Exception:
468          pass
469  
470      buf = io.StringIO()
471      with contextlib.redirect_stdout(buf):
472          doctor_mod.run_doctor(Namespace(fix=False))
473  
474      out = buf.getvalue()
475      assert f"model.provider '{provider}' is not a recognised provider" not in out
476      assert f"model.provider '{provider}' is unknown" not in out
477      if provider in {"ai-gateway", "opencode-zen", "kilocode"}:
478          assert (
479              f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
480              not in out
481          )
482  
483  
484  
485  
486  def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
487      home = tmp_path / ".hermes"
488      home.mkdir(parents=True, exist_ok=True)
489      (home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8")
490      (home / "config.yaml").write_text(
491          "model:\n"
492          "  provider: kimi-coding-cn\n"
493          "  default: kimi-k2.6\n",
494          encoding="utf-8",
495      )
496  
497      monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
498      monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
499      monkeypatch.setattr(doctor_mod, "_DHH", str(home))
500      (tmp_path / "project").mkdir(exist_ok=True)
501  
502      fake_model_tools = types.SimpleNamespace(
503          check_tool_availability=lambda *a, **kw: ([], []),
504          TOOLSET_REQUIREMENTS={},
505      )
506      monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
507  
508      try:
509          from hermes_cli import auth as _auth_mod
510          monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
511          monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
512          monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
513      except Exception:
514          pass
515  
516      buf = io.StringIO()
517      with contextlib.redirect_stdout(buf):
518          doctor_mod.run_doctor(Namespace(fix=False))
519  
520      out = buf.getvalue()
521      assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
522  
523  
524  def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
525      home = tmp_path / ".hermes"
526      home.mkdir(parents=True, exist_ok=True)
527      (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
528      project = tmp_path / "project"
529      project.mkdir(exist_ok=True)
530  
531      monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
532      monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
533      monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
534      monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
535      monkeypatch.setattr(doctor_mod, "_DHH", str(home))
536      monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None)
537  
538      fake_model_tools = types.SimpleNamespace(
539          check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]),
540          TOOLSET_REQUIREMENTS={
541              "terminal": {"name": "terminal"},
542              "browser": {"name": "browser"},
543          },
544      )
545      monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
546  
547      try:
548          from hermes_cli import auth as _auth_mod
549          monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
550          monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
551      except Exception:
552          pass
553  
554      import io, contextlib
555      buf = io.StringIO()
556      with contextlib.redirect_stdout(buf):
557          doctor_mod.run_doctor(Namespace(fix=False))
558      out = buf.getvalue()
559  
560      assert "✓ browser" not in out
561      assert "browser" in out
562      assert "system dependency not met" in out
563      assert "agent-browser is not installed (expected in the tested Termux path)" in out
564      assert "npm install -g agent-browser && agent-browser install" in out
565  
566  
567  def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, tmp_path):
568      home = tmp_path / ".hermes"
569      home.mkdir(parents=True, exist_ok=True)
570      (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
571      (home / ".env").write_text("KIMI_CN_API_KEY=sk-test\n", encoding="utf-8")
572      project = tmp_path / "project"
573      project.mkdir(exist_ok=True)
574  
575      monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
576      monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
577      monkeypatch.setattr(doctor_mod, "_DHH", str(home))
578      monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test")
579  
580      fake_model_tools = types.SimpleNamespace(
581          check_tool_availability=lambda *a, **kw: ([], []),
582          TOOLSET_REQUIREMENTS={},
583      )
584      monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
585  
586      try:
587          from hermes_cli import auth as _auth_mod
588          monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
589          monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
590      except Exception:
591          pass
592  
593      calls = []
594  
595      def fake_get(url, headers=None, timeout=None):
596          calls.append((url, headers, timeout))
597          return types.SimpleNamespace(status_code=200)
598  
599      import httpx
600      monkeypatch.setattr(httpx, "get", fake_get)
601  
602      import io, contextlib
603      buf = io.StringIO()
604      with contextlib.redirect_stdout(buf):
605          doctor_mod.run_doctor(Namespace(fix=False))
606      out = buf.getvalue()
607  
608      assert "API key or custom endpoint configured" in out
609      assert "Kimi / Moonshot (China)" in out
610      assert "str expected, not NoneType" not in out
611      assert any(url == "https://api.moonshot.cn/v1/models" for url, _, _ in calls)
612  
613  
614  @pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"])
615  def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url):
616      home = tmp_path / ".hermes"
617      home.mkdir(parents=True, exist_ok=True)
618      (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
619      (home / ".env").write_text("OPENCODE_GO_API_KEY=***\n", encoding="utf-8")
620      project = tmp_path / "project"
621      project.mkdir(exist_ok=True)
622  
623      monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
624      monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
625      monkeypatch.setattr(doctor_mod, "_DHH", str(home))
626      monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test")
627      if base_url:
628          monkeypatch.setenv("OPENCODE_GO_BASE_URL", base_url)
629      else:
630          monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
631  
632      fake_model_tools = types.SimpleNamespace(
633          check_tool_availability=lambda *a, **kw: ([], []),
634          TOOLSET_REQUIREMENTS={},
635      )
636      monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
637  
638      try:
639          from hermes_cli import auth as _auth_mod
640          monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
641          monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
642      except ImportError:
643          pass
644  
645      calls = []
646  
647      def fake_get(url, headers=None, timeout=None):
648          calls.append((url, headers, timeout))
649          return types.SimpleNamespace(status_code=200)
650  
651      import httpx
652      monkeypatch.setattr(httpx, "get", fake_get)
653  
654      import io, contextlib
655      buf = io.StringIO()
656      with contextlib.redirect_stdout(buf):
657          doctor_mod.run_doctor(Namespace(fix=False))
658      out = buf.getvalue()
659  
660      assert any(
661          "OpenCode Go" in line and "(key configured)" in line
662          for line in out.splitlines()
663      )
664      assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls)
665      assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls)