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