/ tests / hermes_cli / test_config.py
test_config.py
  1  """Tests for hermes_cli configuration management."""
  2  
  3  import os
  4  from pathlib import Path
  5  from unittest.mock import patch, MagicMock
  6  
  7  import yaml
  8  
  9  from hermes_cli.config import (
 10      DEFAULT_CONFIG,
 11      get_hermes_home,
 12      ensure_hermes_home,
 13      get_compatible_custom_providers,
 14      load_config,
 15      load_env,
 16      migrate_config,
 17      remove_env_value,
 18      save_config,
 19      save_env_value,
 20      save_env_value_secure,
 21      sanitize_env_file,
 22      _sanitize_env_lines,
 23  )
 24  
 25  
 26  class TestGetHermesHome:
 27      def test_default_path(self):
 28          with patch.dict(os.environ, {}, clear=False):
 29              os.environ.pop("HERMES_HOME", None)
 30              home = get_hermes_home()
 31              assert home == Path.home() / ".hermes"
 32  
 33      def test_env_override(self):
 34          with patch.dict(os.environ, {"HERMES_HOME": "/custom/path"}):
 35              home = get_hermes_home()
 36              assert home == Path("/custom/path")
 37  
 38  
 39  class TestEnsureHermesHome:
 40      def test_creates_subdirs(self, tmp_path):
 41          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
 42              ensure_hermes_home()
 43              assert (tmp_path / "cron").is_dir()
 44              assert (tmp_path / "sessions").is_dir()
 45              assert (tmp_path / "logs").is_dir()
 46              assert (tmp_path / "memories").is_dir()
 47  
 48      def test_creates_default_soul_md_if_missing(self, tmp_path):
 49          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
 50              ensure_hermes_home()
 51              soul_path = tmp_path / "SOUL.md"
 52              assert soul_path.exists()
 53              assert soul_path.read_text(encoding="utf-8").strip() != ""
 54  
 55      def test_does_not_overwrite_existing_soul_md(self, tmp_path):
 56          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
 57              soul_path = tmp_path / "SOUL.md"
 58              soul_path.write_text("custom soul", encoding="utf-8")
 59              ensure_hermes_home()
 60              assert soul_path.read_text(encoding="utf-8") == "custom soul"
 61  
 62  
 63  class TestLoadConfigDefaults:
 64      def test_returns_defaults_when_no_file(self, tmp_path):
 65          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
 66              config = load_config()
 67              assert config["model"] == DEFAULT_CONFIG["model"]
 68              assert config["agent"]["max_turns"] == DEFAULT_CONFIG["agent"]["max_turns"]
 69              assert "max_turns" not in config
 70              assert "terminal" in config
 71              assert config["terminal"]["backend"] == "local"
 72              assert config["display"]["interim_assistant_messages"] is True
 73  
 74      def test_legacy_root_level_max_turns_migrates_to_agent_config(self, tmp_path):
 75          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
 76              config_path = tmp_path / "config.yaml"
 77              config_path.write_text("max_turns: 42\n")
 78  
 79              config = load_config()
 80              assert config["agent"]["max_turns"] == 42
 81              assert "max_turns" not in config
 82  
 83  
 84  class TestSaveAndLoadRoundtrip:
 85      def test_roundtrip(self, tmp_path):
 86          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
 87              config = load_config()
 88              config["model"] = "test/custom-model"
 89              config["agent"]["max_turns"] = 42
 90              save_config(config)
 91  
 92              reloaded = load_config()
 93              assert reloaded["model"] == "test/custom-model"
 94              assert reloaded["agent"]["max_turns"] == 42
 95  
 96              saved = yaml.safe_load((tmp_path / "config.yaml").read_text())
 97              assert saved["agent"]["max_turns"] == 42
 98              assert "max_turns" not in saved
 99  
100      def test_save_config_normalizes_legacy_root_level_max_turns(self, tmp_path):
101          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
102              save_config({"model": "test/custom-model", "max_turns": 37})
103  
104              saved = yaml.safe_load((tmp_path / "config.yaml").read_text())
105              assert saved["agent"]["max_turns"] == 37
106              assert "max_turns" not in saved
107  
108      def test_nested_values_preserved(self, tmp_path):
109          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
110              config = load_config()
111              config["terminal"]["timeout"] = 999
112              save_config(config)
113  
114              reloaded = load_config()
115              assert reloaded["terminal"]["timeout"] == 999
116  
117  
118  class TestSaveEnvValueSecure:
119      def test_save_env_value_writes_without_stdout(self, tmp_path, capsys):
120          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
121              save_env_value("TENOR_API_KEY", "sk-test-secret")
122              captured = capsys.readouterr()
123              assert captured.out == ""
124              assert captured.err == ""
125  
126              env_values = load_env()
127              assert env_values["TENOR_API_KEY"] == "sk-test-secret"
128  
129      def test_secure_save_returns_metadata_only(self, tmp_path):
130          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
131              result = save_env_value_secure("GITHUB_TOKEN", "ghp_test_secret")
132              assert result == {
133                  "success": True,
134                  "stored_as": "GITHUB_TOKEN",
135                  "validated": False,
136              }
137              assert "secret" not in str(result).lower()
138  
139      def test_save_env_value_updates_process_environment(self, tmp_path):
140          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
141              os.environ.pop("TENOR_API_KEY", None)
142              save_env_value("TENOR_API_KEY", "sk-test-secret")
143              assert os.environ["TENOR_API_KEY"] == "sk-test-secret"
144  
145      def test_save_env_value_hardens_file_permissions_on_posix(self, tmp_path):
146          if os.name == "nt":
147              return
148  
149          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
150              save_env_value("TENOR_API_KEY", "sk-test-secret")
151              env_mode = (tmp_path / ".env").stat().st_mode & 0o777
152              assert env_mode == 0o600
153  
154  
155  class TestRemoveEnvValue:
156      def test_removes_key_from_env_file(self, tmp_path):
157          env_path = tmp_path / ".env"
158          env_path.write_text("KEY_A=value_a\nKEY_B=value_b\nKEY_C=value_c\n")
159          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "KEY_B": "value_b"}):
160              result = remove_env_value("KEY_B")
161              assert result is True
162              content = env_path.read_text()
163              assert "KEY_B" not in content
164              assert "KEY_A=value_a" in content
165              assert "KEY_C=value_c" in content
166  
167      def test_clears_os_environ(self, tmp_path):
168          env_path = tmp_path / ".env"
169          env_path.write_text("MY_KEY=my_value\n")
170          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "MY_KEY": "my_value"}):
171              remove_env_value("MY_KEY")
172              assert "MY_KEY" not in os.environ
173  
174      def test_returns_false_when_key_not_found(self, tmp_path):
175          env_path = tmp_path / ".env"
176          env_path.write_text("OTHER_KEY=value\n")
177          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
178              result = remove_env_value("MISSING_KEY")
179              assert result is False
180              # File should be untouched
181              assert env_path.read_text() == "OTHER_KEY=value\n"
182  
183      def test_handles_missing_env_file(self, tmp_path):
184          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "GHOST_KEY": "ghost"}):
185              result = remove_env_value("GHOST_KEY")
186              assert result is False
187              # os.environ should still be cleared
188              assert "GHOST_KEY" not in os.environ
189  
190      def test_clears_os_environ_even_when_not_in_file(self, tmp_path):
191          env_path = tmp_path / ".env"
192          env_path.write_text("OTHER=stuff\n")
193          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "ORPHAN_KEY": "orphan"}):
194              remove_env_value("ORPHAN_KEY")
195              assert "ORPHAN_KEY" not in os.environ
196  
197  
198  class TestSaveConfigAtomicity:
199      """Verify save_config uses atomic writes (tempfile + os.replace)."""
200  
201      def test_no_partial_write_on_crash(self, tmp_path):
202          """If save_config crashes mid-write, the previous file stays intact."""
203          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
204              # Write an initial config
205              config = load_config()
206              config["model"] = "original-model"
207              save_config(config)
208  
209              config_path = tmp_path / "config.yaml"
210              assert config_path.exists()
211  
212              # Simulate a crash during yaml.dump by making atomic_yaml_write's
213              # yaml.dump raise after the temp file is created but before replace.
214              with patch("utils.yaml.dump", side_effect=OSError("disk full")):
215                  try:
216                      config["model"] = "should-not-persist"
217                      save_config(config)
218                  except OSError:
219                      pass
220  
221              # Original file must still be intact
222              reloaded = load_config()
223              assert reloaded["model"] == "original-model"
224  
225      def test_no_leftover_temp_files(self, tmp_path):
226          """Failed writes must clean up their temp files."""
227          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
228              config = load_config()
229              save_config(config)
230  
231              with patch("utils.yaml.dump", side_effect=OSError("disk full")):
232                  try:
233                      save_config(config)
234                  except OSError:
235                      pass
236  
237              # No .tmp files should remain
238              tmp_files = list(tmp_path.glob(".*config*.tmp"))
239              assert tmp_files == []
240  
241      def test_atomic_write_creates_valid_yaml(self, tmp_path):
242          """The written file must be valid YAML matching the input."""
243          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
244              config = load_config()
245              config["model"] = "test/atomic-model"
246              config["agent"]["max_turns"] = 77
247              save_config(config)
248  
249              # Read raw YAML to verify it's valid and correct
250              config_path = tmp_path / "config.yaml"
251              with open(config_path) as f:
252                  raw = yaml.safe_load(f)
253              assert raw["model"] == "test/atomic-model"
254              assert raw["agent"]["max_turns"] == 77
255  
256  
257  class TestSanitizeEnvLines:
258      """Tests for .env file corruption repair."""
259  
260      def test_splits_concatenated_keys(self):
261          """Two KEY=VALUE pairs jammed on one line get split."""
262          lines = ["ANTHROPIC_API_KEY=sk-ant-xxxOPENAI_BASE_URL=https://api.openai.com/v1\n"]
263          result = _sanitize_env_lines(lines)
264          assert result == [
265              "ANTHROPIC_API_KEY=sk-ant-xxx\n",
266              "OPENAI_BASE_URL=https://api.openai.com/v1\n",
267          ]
268  
269      def test_preserves_clean_file(self):
270          """A well-formed .env file passes through unchanged (modulo trailing newlines)."""
271          lines = [
272              "OPENROUTER_API_KEY=sk-or-xxx\n",
273              "FIRECRAWL_API_KEY=fc-xxx\n",
274              "# a comment\n",
275              "\n",
276          ]
277          result = _sanitize_env_lines(lines)
278          assert result == lines
279  
280      def test_preserves_comments_and_blanks(self):
281          lines = ["# comment\n", "\n", "KEY=val\n"]
282          result = _sanitize_env_lines(lines)
283          assert result == lines
284  
285      def test_adds_missing_trailing_newline(self):
286          """Lines missing trailing newline get one added."""
287          lines = ["FOO_BAR=baz"]
288          result = _sanitize_env_lines(lines)
289          assert result == ["FOO_BAR=baz\n"]
290  
291      def test_three_concatenated_keys(self):
292          """Three known keys on one line all get separated."""
293          lines = ["FAL_KEY=111FIRECRAWL_API_KEY=222GITHUB_TOKEN=333\n"]
294          result = _sanitize_env_lines(lines)
295          assert result == [
296              "FAL_KEY=111\n",
297              "FIRECRAWL_API_KEY=222\n",
298              "GITHUB_TOKEN=333\n",
299          ]
300  
301      def test_value_with_equals_sign_not_split(self):
302          """A value containing '=' shouldn't be falsely split (lowercase in value)."""
303          lines = ["OPENAI_BASE_URL=https://api.example.com/v1?key=abc123\n"]
304          result = _sanitize_env_lines(lines)
305          assert result == lines
306  
307      def test_unknown_keys_not_split(self):
308          """Unknown key names on one line are NOT split (avoids false positives)."""
309          lines = ["CUSTOM_VAR=value123OTHER_THING=value456\n"]
310          result = _sanitize_env_lines(lines)
311          # Unknown keys stay on one line — no false split
312          assert len(result) == 1
313  
314      def test_value_ending_with_digits_still_splits(self):
315          """Concatenation is detected even when value ends with digits."""
316          lines = ["OPENROUTER_API_KEY=sk-or-v1-abc123OPENAI_BASE_URL=https://api.openai.com/v1\n"]
317          result = _sanitize_env_lines(lines)
318          assert len(result) == 2
319          assert result[0].startswith("OPENROUTER_API_KEY=")
320          assert result[1].startswith("OPENAI_BASE_URL=")
321  
322      def test_glm_suffix_collision_not_split(self):
323          """GLM_API_KEY / GLM_BASE_URL must not be mangled by LM_API_KEY / LM_BASE_URL suffixes (#17138)."""
324          lines = [
325              "GLM_API_KEY=glm-secret\n",
326              "GLM_BASE_URL=https://api.z.ai/api/paas/v4\n",
327          ]
328          result = _sanitize_env_lines(lines)
329          assert result == lines, f"GLM_* lines were corrupted by suffix collision: {result}"
330  
331      def test_suffix_collision_does_not_break_real_concatenation(self):
332          """A genuine concatenation that happens to start with a suffix-superset key still splits."""
333          lines = ["GLM_API_KEY=glmLM_API_KEY=lm-key\n"]
334          result = _sanitize_env_lines(lines)
335          assert len(result) == 2
336          assert result[0].startswith("GLM_API_KEY=")
337          assert result[1].startswith("LM_API_KEY=")
338  
339      def test_save_env_value_fixes_corruption_on_write(self, tmp_path):
340          """save_env_value sanitizes corrupted lines when writing a new key."""
341          env_file = tmp_path / ".env"
342          env_file.write_text(
343              "ANTHROPIC_API_KEY=sk-antOPENAI_BASE_URL=https://api.openai.com/v1\n"
344              "FAL_KEY=existing\n"
345          )
346          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
347              save_env_value("MESSAGING_CWD", "/tmp")
348  
349              content = env_file.read_text()
350              lines = content.strip().split("\n")
351  
352              # Corrupted line should be split, new key added
353              assert "ANTHROPIC_API_KEY=sk-ant" in lines
354              assert "OPENAI_BASE_URL=https://api.openai.com/v1" in lines
355              assert "MESSAGING_CWD=/tmp" in lines
356  
357      def test_sanitize_env_file_returns_fix_count(self, tmp_path):
358          """sanitize_env_file reports how many entries were fixed."""
359          env_file = tmp_path / ".env"
360          env_file.write_text(
361              "FAL_KEY=good\n"
362              "OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n"
363          )
364          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
365              fixes = sanitize_env_file()
366              assert fixes > 0
367  
368              # Verify file is now clean
369              content = env_file.read_text()
370              assert "OPENROUTER_API_KEY=val\n" in content
371              assert "FIRECRAWL_API_KEY=val2\n" in content
372  
373      def test_sanitize_env_file_noop_on_clean_file(self, tmp_path):
374          """No changes when file is already clean."""
375          env_file = tmp_path / ".env"
376          env_file.write_text("GOOD_KEY=good\nOTHER_KEY=other\n")
377          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
378              fixes = sanitize_env_file()
379              assert fixes == 0
380  
381  
382  class TestOptionalEnvVarsRegistry:
383      """Verify that key env vars are registered in OPTIONAL_ENV_VARS."""
384  
385      def test_tavily_api_key_registered(self):
386          """TAVILY_API_KEY is listed in OPTIONAL_ENV_VARS."""
387          from hermes_cli.config import OPTIONAL_ENV_VARS
388          assert "TAVILY_API_KEY" in OPTIONAL_ENV_VARS
389  
390      def test_tavily_api_key_is_tool_category(self):
391          """TAVILY_API_KEY is in the 'tool' category."""
392          from hermes_cli.config import OPTIONAL_ENV_VARS
393          assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["category"] == "tool"
394  
395      def test_tavily_api_key_is_password(self):
396          """TAVILY_API_KEY is marked as password."""
397          from hermes_cli.config import OPTIONAL_ENV_VARS
398          assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["password"] is True
399  
400      def test_tavily_api_key_has_url(self):
401          """TAVILY_API_KEY has a URL."""
402          from hermes_cli.config import OPTIONAL_ENV_VARS
403          assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["url"] == "https://app.tavily.com/home"
404  
405      def test_tavily_in_env_vars_by_version(self):
406          """TAVILY_API_KEY is listed in ENV_VARS_BY_VERSION."""
407          from hermes_cli.config import ENV_VARS_BY_VERSION
408          all_vars = []
409          for vars_list in ENV_VARS_BY_VERSION.values():
410              all_vars.extend(vars_list)
411          assert "TAVILY_API_KEY" in all_vars
412  
413  
414  class TestAnthropicTokenMigration:
415      """Test that config version 8→9 clears ANTHROPIC_TOKEN."""
416  
417      def _write_config_version(self, tmp_path, version):
418          config_path = tmp_path / "config.yaml"
419          import yaml
420          config_path.write_text(yaml.safe_dump({"_config_version": version}))
421  
422      def test_clears_token_on_upgrade_to_v9(self, tmp_path):
423          """ANTHROPIC_TOKEN is cleared unconditionally when upgrading to v9."""
424          self._write_config_version(tmp_path, 8)
425          (tmp_path / ".env").write_text("ANTHROPIC_TOKEN=old-token\n")
426          with patch.dict(os.environ, {
427              "HERMES_HOME": str(tmp_path),
428              "ANTHROPIC_TOKEN": "old-token",
429          }):
430              migrate_config(interactive=False, quiet=True)
431              assert load_env().get("ANTHROPIC_TOKEN") == ""
432  
433      def test_skips_on_version_9_or_later(self, tmp_path):
434          """Already at v9 — ANTHROPIC_TOKEN is not touched."""
435          self._write_config_version(tmp_path, 9)
436          (tmp_path / ".env").write_text("ANTHROPIC_TOKEN=current-token\n")
437          with patch.dict(os.environ, {
438              "HERMES_HOME": str(tmp_path),
439              "ANTHROPIC_TOKEN": "current-token",
440          }):
441              migrate_config(interactive=False, quiet=True)
442              assert load_env().get("ANTHROPIC_TOKEN") == "current-token"
443  
444  
445  class TestCustomProviderCompatibility:
446      """Custom provider compatibility across legacy and v12+ config schemas."""
447  
448      def test_v11_upgrade_moves_custom_providers_into_providers(self, tmp_path):
449          config_path = tmp_path / "config.yaml"
450          config_path.write_text(
451              yaml.safe_dump(
452                  {
453                      "_config_version": 11,
454                      "model": {
455                          "default": "openai/gpt-5.4",
456                          "provider": "openrouter",
457                      },
458                      "custom_providers": [
459                          {
460                              "name": "OpenAI Direct",
461                              "base_url": "https://api.openai.com/v1",
462                              "api_key": "test-key",
463                              "api_mode": "codex_responses",
464                              "model": "gpt-5-mini",
465                          }
466                      ],
467                      "fallback_providers": [
468                          {"provider": "openai-direct", "model": "gpt-5-mini"}
469                      ],
470                  }
471              ),
472              encoding="utf-8",
473          )
474  
475          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
476              migrate_config(interactive=False, quiet=True)
477              raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
478  
479          from hermes_cli.config import DEFAULT_CONFIG
480          assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
481          assert raw["providers"]["openai-direct"] == {
482              "api": "https://api.openai.com/v1",
483              "api_key": "test-key",
484              "default_model": "gpt-5-mini",
485              "name": "OpenAI Direct",
486              "transport": "codex_responses",
487          }
488          # custom_providers removed by migration — runtime reads via compat layer
489          assert "custom_providers" not in raw
490  
491      def test_providers_dict_resolves_at_runtime(self, tmp_path):
492          """After migration deleted custom_providers, get_compatible_custom_providers
493          still finds entries from the providers dict."""
494          config_path = tmp_path / "config.yaml"
495          config_path.write_text(
496              yaml.safe_dump(
497                  {
498                      "_config_version": 17,
499                      "providers": {
500                          "openai-direct": {
501                              "api": "https://api.openai.com/v1",
502                              "api_key": "test-key",
503                              "default_model": "gpt-5-mini",
504                              "name": "OpenAI Direct",
505                              "transport": "codex_responses",
506                          }
507                      },
508                  }
509              ),
510              encoding="utf-8",
511          )
512  
513          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
514              compatible = get_compatible_custom_providers()
515  
516          assert len(compatible) == 1
517          assert compatible[0]["name"] == "OpenAI Direct"
518          assert compatible[0]["base_url"] == "https://api.openai.com/v1"
519          assert compatible[0]["provider_key"] == "openai-direct"
520          assert compatible[0]["api_mode"] == "codex_responses"
521  
522      def test_compatible_custom_providers_prefers_base_url_then_url_then_api(self, tmp_path):
523          """URL field precedence is base_url > url > api (PR #9332)."""
524          config_path = tmp_path / "config.yaml"
525          config_path.write_text(
526              yaml.safe_dump(
527                  {
528                      "_config_version": 17,
529                      "providers": {
530                          "my-provider": {
531                              "name": "My Provider",
532                              "api": "https://api.example.com/v1",
533                              "url": "https://url.example.com/v1",
534                              "base_url": "https://base.example.com/v1",
535                          }
536                      },
537                  }
538              ),
539              encoding="utf-8",
540          )
541  
542          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
543              compatible = get_compatible_custom_providers()
544  
545          assert compatible == [
546              {
547                  "name": "My Provider",
548                  "base_url": "https://base.example.com/v1",
549                  "provider_key": "my-provider",
550              }
551          ]
552  
553      def test_dedup_across_legacy_and_providers(self, tmp_path):
554          """Same name+url in both schemas should not produce duplicates."""
555          config_path = tmp_path / "config.yaml"
556          config_path.write_text(
557              yaml.safe_dump(
558                  {
559                      "_config_version": 17,
560                      "custom_providers": [
561                          {
562                              "name": "OpenAI Direct",
563                              "base_url": "https://api.openai.com/v1",
564                              "api_key": "legacy-key",
565                          }
566                      ],
567                      "providers": {
568                          "openai-direct": {
569                              "api": "https://api.openai.com/v1",
570                              "api_key": "new-key",
571                              "name": "OpenAI Direct",
572                          }
573                      },
574                  }
575              ),
576              encoding="utf-8",
577          )
578  
579          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
580              compatible = get_compatible_custom_providers()
581  
582          assert len(compatible) == 1
583          # Legacy entry wins (read first)
584          assert compatible[0]["api_key"] == "legacy-key"
585  
586      def test_dedup_preserves_entries_with_different_models(self, tmp_path):
587          """Entries with same name+URL but different models must not be collapsed."""
588          config_path = tmp_path / "config.yaml"
589          config_path.write_text(
590              yaml.safe_dump(
591                  {
592                      "_config_version": 17,
593                      "custom_providers": [
594                          {"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "qwen3-coder"},
595                          {"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "glm-5.1"},
596                          {"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "kimi-k2.5"},
597                      ],
598                  }
599              ),
600              encoding="utf-8",
601          )
602  
603          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
604              compatible = get_compatible_custom_providers()
605  
606          assert len(compatible) == 3
607          models = [e.get("model") for e in compatible]
608          assert models == ["qwen3-coder", "glm-5.1", "kimi-k2.5"]
609  
610  
611  class TestInterimAssistantMessageConfig:
612      """Test the explicit gateway interim-message config gate."""
613  
614      def test_default_config_enables_interim_assistant_messages(self):
615          assert DEFAULT_CONFIG["display"]["interim_assistant_messages"] is True
616  
617      def test_migrate_to_v15_adds_interim_assistant_message_gate(self, tmp_path):
618          config_path = tmp_path / "config.yaml"
619          config_path.write_text(
620              yaml.safe_dump({"_config_version": 14, "display": {"tool_progress": "off"}}),
621              encoding="utf-8",
622          )
623  
624          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
625              migrate_config(interactive=False, quiet=True)
626              raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
627  
628          from hermes_cli.config import DEFAULT_CONFIG
629          assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
630          assert raw["display"]["tool_progress"] == "off"
631          assert raw["display"]["interim_assistant_messages"] is True
632  
633  
634  class TestDiscordChannelPromptsConfig:
635      def test_default_config_includes_discord_channel_prompts(self):
636          assert DEFAULT_CONFIG["discord"]["channel_prompts"] == {}
637  
638      def test_migrate_adds_discord_channel_prompts_default(self, tmp_path):
639          config_path = tmp_path / "config.yaml"
640          config_path.write_text(
641              yaml.safe_dump({"_config_version": 17, "discord": {"auto_thread": True}}),
642              encoding="utf-8",
643          )
644  
645          with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
646              migrate_config(interactive=False, quiet=True)
647              raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
648  
649          from hermes_cli.config import DEFAULT_CONFIG
650          assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
651          assert raw["discord"]["auto_thread"] is True
652          assert raw["discord"]["channel_prompts"] == {}
653  
654  
655  class TestUserMessagePreviewConfig:
656      def test_default_config_preview_line_counts(self):
657          preview = DEFAULT_CONFIG["display"]["user_message_preview"]
658          assert preview["first_lines"] == 2
659          assert preview["last_lines"] == 2