test_tools_config.py
1 """Tests for hermes_cli.tools_config platform tool persistence.""" 2 3 from unittest.mock import patch 4 5 import pytest 6 7 from hermes_cli.tools_config import ( 8 _DEFAULT_OFF_TOOLSETS, 9 _apply_toolset_change, 10 _configure_provider, 11 _reconfigure_provider, 12 _get_platform_tools, 13 _platform_toolset_summary, 14 _reconfigure_tool, 15 _save_platform_tools, 16 _toolset_has_keys, 17 CONFIGURABLE_TOOLSETS, 18 TOOL_CATEGORIES, 19 _visible_providers, 20 tools_command, 21 ) 22 23 24 def test_agent_disabled_toolsets_suppresses_across_platforms(): 25 """agent.disabled_toolsets in config.yaml should remove those toolsets 26 from the enabled set, regardless of platform defaults or explicit config. 27 """ 28 config = { 29 "agent": {"disabled_toolsets": ["memory"]}, 30 } 31 32 cli_enabled = _get_platform_tools(config, "cli") 33 discord_enabled = _get_platform_tools(config, "discord") 34 35 assert "memory" not in cli_enabled 36 assert "memory" not in discord_enabled 37 38 39 def test_agent_disabled_toolsets_with_explicit_platform_config(): 40 """agent.disabled_toolsets should still suppress even when the platform 41 has an explicit toolset list that includes the disabled toolset. 42 """ 43 config = { 44 "agent": {"disabled_toolsets": ["memory"]}, 45 "platform_toolsets": {"cli": ["web", "terminal", "memory"]}, 46 } 47 48 enabled = _get_platform_tools(config, "cli") 49 50 assert "memory" not in enabled 51 assert "web" in enabled 52 assert "terminal" in enabled 53 54 55 def test_agent_disabled_toolsets_empty_list_is_noop(): 56 """Empty or missing disabled_toolsets should not change behavior.""" 57 config_empty = {"agent": {"disabled_toolsets": []}} 58 config_none = {"agent": {}} 59 config_missing = {} 60 61 default = _get_platform_tools({}, "cli") 62 63 assert _get_platform_tools(config_empty, "cli") == default 64 assert _get_platform_tools(config_none, "cli") == default 65 assert _get_platform_tools(config_missing, "cli") == default 66 67 68 def test_get_platform_tools_uses_default_when_platform_not_configured(): 69 config = {} 70 71 enabled = _get_platform_tools(config, "cli") 72 73 assert enabled 74 assert enabled.isdisjoint(_DEFAULT_OFF_TOOLSETS) 75 76 77 def test_configurable_toolsets_include_messaging(): 78 assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS) 79 80 def test_get_platform_tools_default_telegram_includes_messaging(): 81 enabled = _get_platform_tools({}, "telegram") 82 83 assert "messaging" in enabled 84 85 86 def test_get_platform_tools_homeassistant_platform_keeps_homeassistant_toolset(): 87 enabled = _get_platform_tools({}, "homeassistant") 88 89 assert "homeassistant" in enabled 90 91 92 def test_get_platform_tools_homeassistant_toolset_enabled_for_cron_when_hass_token_set(monkeypatch): 93 """HA toolset is runtime-gated by check_fn (requires HASS_TOKEN). 94 95 When HASS_TOKEN is set, the user has explicitly opted in — _DEFAULT_OFF_TOOLSETS 96 shouldn't also strip HA from platforms (like cron) that run through 97 _get_platform_tools without an explicit saved toolset list. 98 99 Regression guard for Norbert's HA cron breakage after #14798 made cron 100 honor per-platform tool config. 101 """ 102 monkeypatch.setenv("HASS_TOKEN", "fake-test-token") 103 104 cron_enabled = _get_platform_tools({}, "cron") 105 assert "homeassistant" in cron_enabled 106 # moa must stay off — the original goal of #14798 107 assert "moa" not in cron_enabled 108 109 cli_enabled = _get_platform_tools({}, "cli") 110 assert "homeassistant" in cli_enabled 111 112 113 def test_get_platform_tools_homeassistant_toolset_off_for_cron_when_hass_token_missing(monkeypatch): 114 """Without HASS_TOKEN, HA stays off by default — preserves #14798's behavior 115 for users who never configured HA.""" 116 monkeypatch.delenv("HASS_TOKEN", raising=False) 117 118 cron_enabled = _get_platform_tools({}, "cron") 119 assert "homeassistant" not in cron_enabled 120 121 122 def test_get_platform_tools_preserves_explicit_empty_selection(): 123 config = {"platform_toolsets": {"cli": []}} 124 125 enabled = _get_platform_tools(config, "cli") 126 127 # An explicit empty list disables every CONFIGURABLE toolset (web, 128 # terminal, memory, …). Non-configurable platform toolsets that ride 129 # along on the platform's default composite (e.g. `kanban`, whose tools 130 # live in _HERMES_CORE_TOOLS but aren't user-toggleable) are still 131 # auto-recovered by _get_platform_tools so saving via `hermes tools` 132 # doesn't silently drop them. The contract this test guards is the 133 # configurable side: nothing the user could have checked in the TUI 134 # checklist should reappear here. 135 configurable = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} 136 assert enabled.isdisjoint(configurable) 137 138 139 def test_apply_toolset_change_from_default_does_not_enable_default_off_toolsets(): 140 """Disabling one default toolset on a fresh config must not persist 141 default-off toolsets as explicitly enabled. 142 """ 143 config = {} 144 145 with patch("hermes_cli.tools_config.save_config"): 146 _apply_toolset_change(config, "cli", ["memory"], "disable") 147 148 saved = set(config["platform_toolsets"]["cli"]) 149 assert "memory" not in saved 150 assert "terminal" in saved 151 assert saved.isdisjoint(_DEFAULT_OFF_TOOLSETS) 152 153 154 def test_apply_toolset_change_can_enable_default_off_toolset_from_default(): 155 config = {} 156 157 with patch("hermes_cli.tools_config.save_config"): 158 _apply_toolset_change(config, "cli", ["homeassistant"], "enable") 159 160 saved = set(config["platform_toolsets"]["cli"]) 161 assert "homeassistant" in saved 162 assert "terminal" in saved 163 164 165 def test_get_platform_tools_handles_null_platform_toolsets(): 166 """YAML `platform_toolsets:` with no value parses as None — the old 167 ``config.get("platform_toolsets", {})`` pattern would then crash with 168 ``NoneType has no attribute 'get'`` on the next line. Guard against that. 169 """ 170 config = {"platform_toolsets": None} 171 172 enabled = _get_platform_tools(config, "cli") 173 174 # Falls through to defaults instead of raising 175 assert enabled 176 177 178 def test_platform_toolset_summary_uses_explicit_platform_list(): 179 config = {} 180 181 summary = _platform_toolset_summary(config, platforms=["cli"]) 182 183 assert set(summary.keys()) == {"cli"} 184 assert summary["cli"] == _get_platform_tools(config, "cli") 185 186 187 def test_get_platform_tools_includes_enabled_mcp_servers_by_default(): 188 config = { 189 "mcp_servers": { 190 "exa": {"url": "https://mcp.exa.ai/mcp"}, 191 "web-search-prime": {"url": "https://api.z.ai/api/mcp/web_search_prime/mcp"}, 192 "disabled-server": {"url": "https://example.com/mcp", "enabled": False}, 193 } 194 } 195 196 enabled = _get_platform_tools(config, "cli") 197 198 assert "exa" in enabled 199 assert "web-search-prime" in enabled 200 assert "disabled-server" not in enabled 201 202 203 def test_get_platform_tools_keeps_enabled_mcp_servers_with_explicit_builtin_selection(): 204 config = { 205 "platform_toolsets": {"cli": ["web", "memory"]}, 206 "mcp_servers": { 207 "exa": {"url": "https://mcp.exa.ai/mcp"}, 208 "web-search-prime": {"url": "https://api.z.ai/api/mcp/web_search_prime/mcp"}, 209 }, 210 } 211 212 enabled = _get_platform_tools(config, "cli") 213 214 assert "web" in enabled 215 assert "memory" in enabled 216 assert "exa" in enabled 217 assert "web-search-prime" in enabled 218 219 220 def test_get_platform_tools_no_mcp_sentinel_excludes_all_mcp_servers(): 221 """The 'no_mcp' sentinel in platform_toolsets excludes all MCP servers.""" 222 config = { 223 "platform_toolsets": {"cli": ["web", "terminal", "no_mcp"]}, 224 "mcp_servers": { 225 "exa": {"url": "https://mcp.exa.ai/mcp"}, 226 "web-search-prime": {"url": "https://api.z.ai/api/mcp/web_search_prime/mcp"}, 227 }, 228 } 229 230 enabled = _get_platform_tools(config, "cli") 231 232 assert "web" in enabled 233 assert "terminal" in enabled 234 assert "exa" not in enabled 235 assert "web-search-prime" not in enabled 236 assert "no_mcp" not in enabled 237 238 239 def test_get_platform_tools_no_mcp_sentinel_does_not_affect_other_platforms(): 240 """The 'no_mcp' sentinel only affects the platform it's configured on.""" 241 config = { 242 "platform_toolsets": { 243 "api_server": ["web", "terminal", "no_mcp"], 244 }, 245 "mcp_servers": { 246 "exa": {"url": "https://mcp.exa.ai/mcp"}, 247 }, 248 } 249 250 # api_server should exclude MCP 251 api_enabled = _get_platform_tools(config, "api_server") 252 assert "exa" not in api_enabled 253 254 # cli (not configured with no_mcp) should include MCP 255 cli_enabled = _get_platform_tools(config, "cli") 256 assert "exa" in cli_enabled 257 258 259 def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch): 260 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 261 (tmp_path / "auth.json").write_text( 262 '{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token": "codex-...oken","refresh_token": "codex-...oken"}}}}' 263 ) 264 monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) 265 monkeypatch.delenv("OPENAI_BASE_URL", raising=False) 266 monkeypatch.delenv("OPENAI_API_KEY", raising=False) 267 268 monkeypatch.setattr( 269 "agent.auxiliary_client.resolve_vision_provider_client", 270 lambda: ("openai-codex", object(), "gpt-4.1"), 271 ) 272 273 assert _toolset_has_keys("vision") is True 274 275 276 def test_save_platform_tools_preserves_mcp_server_names(): 277 """Ensure MCP server names are preserved when saving platform tools. 278 279 Regression test for https://github.com/NousResearch/hermes-agent/issues/1247 280 """ 281 config = { 282 "platform_toolsets": { 283 "cli": ["web", "terminal", "time", "github", "custom-mcp-server"] 284 } 285 } 286 287 new_selection = {"web", "browser"} 288 289 with patch("hermes_cli.tools_config.save_config"): 290 _save_platform_tools(config, "cli", new_selection) 291 292 saved_toolsets = config["platform_toolsets"]["cli"] 293 294 assert "time" in saved_toolsets 295 assert "github" in saved_toolsets 296 assert "custom-mcp-server" in saved_toolsets 297 assert "web" in saved_toolsets 298 assert "browser" in saved_toolsets 299 assert "terminal" not in saved_toolsets 300 301 302 def test_save_platform_tools_handles_empty_existing_config(): 303 """Saving platform tools works when no existing config exists.""" 304 config = {} 305 306 with patch("hermes_cli.tools_config.save_config"): 307 _save_platform_tools(config, "telegram", {"web", "terminal"}) 308 309 saved_toolsets = config["platform_toolsets"]["telegram"] 310 assert "web" in saved_toolsets 311 assert "terminal" in saved_toolsets 312 313 314 def test_save_platform_tools_handles_invalid_existing_config(): 315 """Saving platform tools works when existing config is not a list.""" 316 config = { 317 "platform_toolsets": { 318 "cli": "invalid-string-value" 319 } 320 } 321 322 with patch("hermes_cli.tools_config.save_config"): 323 _save_platform_tools(config, "cli", {"web"}) 324 325 saved_toolsets = config["platform_toolsets"]["cli"] 326 assert "web" in saved_toolsets 327 328 329 def test_save_platform_tools_does_not_preserve_platform_default_toolsets(): 330 """Platform default toolsets (hermes-cli, hermes-telegram, etc.) must NOT 331 be preserved across saves. 332 333 These "super" toolsets resolve to ALL tools, so if they survive in the 334 config, they silently override any tools the user unchecked. Previously, 335 the preserve filter only excluded configurable toolset keys (web, browser, 336 terminal, etc.) and treated platform defaults as unknown custom entries 337 (like MCP server names), causing them to be kept unconditionally. 338 339 Regression test: user unchecks image_gen and homeassistant via 340 ``hermes tools``, but hermes-cli stays in the config and re-enables 341 everything on the next read. 342 """ 343 config = { 344 "platform_toolsets": { 345 "cli": [ 346 "browser", "clarify", "code_execution", "cronjob", 347 "delegation", "file", "hermes-cli", # <-- the culprit 348 "memory", "session_search", "skills", "terminal", 349 "todo", "tts", "vision", "web", 350 ] 351 } 352 } 353 354 # User unchecks image_gen, homeassistant, moa — keeps the rest 355 new_selection = { 356 "browser", "clarify", "code_execution", "cronjob", 357 "delegation", "file", "memory", "session_search", 358 "skills", "terminal", "todo", "tts", "vision", "web", 359 } 360 361 with patch("hermes_cli.tools_config.save_config"): 362 _save_platform_tools(config, "cli", new_selection) 363 364 saved = config["platform_toolsets"]["cli"] 365 366 # hermes-cli must NOT survive — it's a platform default, not an MCP server 367 assert "hermes-cli" not in saved 368 369 # The individual toolset keys the user selected must be present 370 assert "web" in saved 371 assert "terminal" in saved 372 assert "browser" in saved 373 374 # Tools the user unchecked must NOT be present 375 assert "image_gen" not in saved 376 assert "homeassistant" not in saved 377 assert "moa" not in saved 378 379 380 def test_save_platform_tools_does_not_preserve_hermes_telegram(): 381 """Same bug for Telegram — hermes-telegram must not be preserved.""" 382 config = { 383 "platform_toolsets": { 384 "telegram": [ 385 "browser", "file", "hermes-telegram", "terminal", "web", 386 ] 387 } 388 } 389 390 new_selection = {"browser", "file", "terminal", "web"} 391 392 with patch("hermes_cli.tools_config.save_config"): 393 _save_platform_tools(config, "telegram", new_selection) 394 395 saved = config["platform_toolsets"]["telegram"] 396 assert "hermes-telegram" not in saved 397 assert "web" in saved 398 399 400 def test_save_platform_tools_still_preserves_mcp_with_platform_default_present(): 401 """MCP server names must still be preserved even when platform defaults 402 are being stripped out.""" 403 config = { 404 "platform_toolsets": { 405 "cli": [ 406 "web", "terminal", "hermes-cli", "my-mcp-server", "github-tools", 407 ] 408 } 409 } 410 411 new_selection = {"web", "browser"} 412 413 with patch("hermes_cli.tools_config.save_config"): 414 _save_platform_tools(config, "cli", new_selection) 415 416 saved = config["platform_toolsets"]["cli"] 417 418 # MCP servers preserved 419 assert "my-mcp-server" in saved 420 assert "github-tools" in saved 421 422 # Platform default stripped 423 assert "hermes-cli" not in saved 424 425 # User selections present 426 assert "web" in saved 427 assert "browser" in saved 428 429 # Deselected configurable toolset removed 430 assert "terminal" not in saved 431 432 433 def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch): 434 monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True) 435 config = {"model": {"provider": "nous"}} 436 437 monkeypatch.setattr( 438 "hermes_cli.nous_subscription.get_nous_auth_status", 439 lambda: {"logged_in": True}, 440 ) 441 442 providers = _visible_providers(TOOL_CATEGORIES["browser"], config) 443 444 assert providers[0]["name"].startswith("Nous Subscription") 445 446 447 def test_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monkeypatch): 448 monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: False) 449 config = {"model": {"provider": "nous"}} 450 451 monkeypatch.setattr( 452 "hermes_cli.nous_subscription.get_nous_auth_status", 453 lambda: {"logged_in": True}, 454 ) 455 456 providers = _visible_providers(TOOL_CATEGORIES["browser"], config) 457 458 assert all(not provider["name"].startswith("Nous Subscription") for provider in providers) 459 460 461 def test_local_browser_provider_is_saved_explicitly(monkeypatch): 462 config = {} 463 local_provider = next( 464 provider 465 for provider in TOOL_CATEGORIES["browser"]["providers"] 466 if provider.get("browser_provider") == "local" 467 ) 468 monkeypatch.setattr("hermes_cli.tools_config._run_post_setup", lambda key: None) 469 470 _configure_provider(local_provider, config) 471 472 assert config["browser"]["cloud_provider"] == "local" 473 474 475 def test_reconfigure_lists_enabled_web_without_existing_provider_config(monkeypatch): 476 config = {"platform_toolsets": {"cli": ["web"]}} 477 seen = {} 478 configured = [] 479 480 monkeypatch.setattr( 481 "hermes_cli.tools_config._toolset_has_keys", 482 lambda ts_key, config=None: False, 483 ) 484 485 def fake_prompt_choice(question, choices, default=0): 486 seen["choices"] = choices 487 return 0 488 489 monkeypatch.setattr("hermes_cli.tools_config._prompt_choice", fake_prompt_choice) 490 monkeypatch.setattr( 491 "hermes_cli.tools_config._configure_tool_category_for_reconfig", 492 lambda ts_key, cat, config: configured.append(ts_key), 493 ) 494 monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None) 495 496 _reconfigure_tool(config) 497 498 assert any("Web Search" in choice for choice in seen["choices"]) 499 assert configured == ["web"] 500 501 502 def test_first_install_nous_auto_configures_managed_defaults(monkeypatch): 503 monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True) 504 monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True) 505 config = { 506 "model": {"provider": "nous"}, 507 "platform_toolsets": {"cli": []}, 508 } 509 for env_var in ( 510 "VOICE_TOOLS_OPENAI_KEY", 511 "OPENAI_API_KEY", 512 "ELEVENLABS_API_KEY", 513 "FIRECRAWL_API_KEY", 514 "FIRECRAWL_API_URL", 515 "TAVILY_API_KEY", 516 "PARALLEL_API_KEY", 517 "BROWSERBASE_API_KEY", 518 "BROWSERBASE_PROJECT_ID", 519 "BROWSER_USE_API_KEY", 520 "FAL_KEY", 521 ): 522 monkeypatch.delenv(env_var, raising=False) 523 524 monkeypatch.setattr( 525 "hermes_cli.tools_config._prompt_toolset_checklist", 526 lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"}, 527 ) 528 monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None) 529 # Prevent leaked platform tokens (e.g. DISCORD_BOT_TOKEN from gateway.run 530 # import) from adding extra platforms. The loop in tools_command runs 531 # apply_nous_managed_defaults per platform; a second iteration sees values 532 # set by the first as "explicit" and skips them. 533 monkeypatch.setattr( 534 "hermes_cli.tools_config._get_enabled_platforms", 535 lambda: ["cli"], 536 ) 537 monkeypatch.setattr( 538 "hermes_cli.nous_subscription.get_nous_auth_status", 539 lambda: {"logged_in": True}, 540 ) 541 542 configured = [] 543 monkeypatch.setattr( 544 "hermes_cli.tools_config._configure_toolset", 545 lambda ts_key, config: configured.append(ts_key), 546 ) 547 548 tools_command(first_install=True, config=config) 549 550 assert config["web"]["backend"] == "firecrawl" 551 assert config["tts"]["provider"] == "openai" 552 assert config["browser"]["cloud_provider"] == "browser-use" 553 assert configured == [] 554 555 # ── Platform / toolset consistency ──────────────────────────────────────────── 556 557 558 class TestPlatformToolsetConsistency: 559 """Every platform in tools_config.PLATFORMS must have a matching toolset.""" 560 561 def test_all_platforms_have_toolset_definitions(self): 562 """Each platform's default_toolset must exist in TOOLSETS.""" 563 from hermes_cli.tools_config import PLATFORMS 564 from toolsets import TOOLSETS 565 566 for platform, meta in PLATFORMS.items(): 567 ts_name = meta["default_toolset"] 568 assert ts_name in TOOLSETS, ( 569 f"Platform {platform!r} references toolset {ts_name!r} " 570 f"which is not defined in toolsets.py" 571 ) 572 573 def test_gateway_toolset_includes_all_messaging_platforms(self): 574 """hermes-gateway includes list should cover all messaging platforms.""" 575 from hermes_cli.tools_config import PLATFORMS 576 from toolsets import TOOLSETS 577 578 gateway_includes = set(TOOLSETS["hermes-gateway"]["includes"]) 579 # Exclude non-messaging platforms from the check 580 non_messaging = {"cli", "api_server", "cron"} 581 for platform, meta in PLATFORMS.items(): 582 if platform in non_messaging: 583 continue 584 ts_name = meta["default_toolset"] 585 assert ts_name in gateway_includes, ( 586 f"Platform {platform!r} toolset {ts_name!r} missing from " 587 f"hermes-gateway includes" 588 ) 589 590 def test_skills_config_covers_tools_config_platforms(self): 591 """skills_config.PLATFORMS should have entries for all gateway platforms.""" 592 from hermes_cli.tools_config import PLATFORMS as TOOLS_PLATFORMS 593 from hermes_cli.skills_config import PLATFORMS as SKILLS_PLATFORMS 594 595 non_messaging = {"api_server"} 596 for platform in TOOLS_PLATFORMS: 597 if platform in non_messaging: 598 continue 599 assert platform in SKILLS_PLATFORMS, ( 600 f"Platform {platform!r} in tools_config but missing from " 601 f"skills_config PLATFORMS" 602 ) 603 604 605 def test_numeric_mcp_server_name_does_not_crash_sorted(): 606 """YAML parses bare numeric keys (e.g. ``12306:``) as int. 607 608 _get_platform_tools must normalise them to str so that sorted() 609 on the returned set never raises TypeError on mixed int/str. 610 611 Regression test for https://github.com/NousResearch/hermes-agent/issues/6901 612 """ 613 config = { 614 "platform_toolsets": {"cli": ["web", 12306]}, 615 "mcp_servers": { 616 12306: {"url": "https://example.com/mcp"}, 617 "normal-server": {"url": "https://example.com/mcp2"}, 618 }, 619 } 620 621 enabled = _get_platform_tools(config, "cli") 622 623 # All names must be str — no int leaking through 624 assert all(isinstance(name, str) for name in enabled), ( 625 f"Non-string toolset names found: {enabled}" 626 ) 627 assert "12306" in enabled 628 629 # sorted() must not raise TypeError 630 sorted(enabled) 631 632 633 # ─── Imagegen Backend Picker Wiring ──────────────────────────────────────── 634 635 class TestImagegenBackendRegistry: 636 """IMAGEGEN_BACKENDS tags drive the model picker flow in tools_config.""" 637 638 def test_fal_backend_registered(self): 639 from hermes_cli.tools_config import IMAGEGEN_BACKENDS 640 assert "fal" in IMAGEGEN_BACKENDS 641 642 def test_fal_catalog_loads_lazily(self): 643 """catalog_fn should defer import to avoid import cycles.""" 644 from hermes_cli.tools_config import IMAGEGEN_BACKENDS 645 catalog, default = IMAGEGEN_BACKENDS["fal"]["catalog_fn"]() 646 assert default == "fal-ai/flux-2/klein/9b" 647 assert "fal-ai/flux-2/klein/9b" in catalog 648 assert "fal-ai/flux-2-pro" in catalog 649 650 def test_image_gen_providers_tagged_with_fal_backend(self): 651 """Both Nous Subscription and FAL.ai providers must carry the 652 imagegen_backend tag so _configure_provider fires the picker.""" 653 from hermes_cli.tools_config import TOOL_CATEGORIES 654 providers = TOOL_CATEGORIES["image_gen"]["providers"] 655 for p in providers: 656 assert p.get("imagegen_backend") == "fal", ( 657 f"{p['name']} missing imagegen_backend tag" 658 ) 659 660 661 class TestImagegenModelPicker: 662 """_configure_imagegen_model writes selection to config and respects 663 curses fallback semantics (returns default when stdin isn't a TTY).""" 664 665 def test_picker_writes_chosen_model_to_config(self): 666 from hermes_cli.tools_config import _configure_imagegen_model 667 config = {} 668 # Force _prompt_choice to pick index 1 (second-in-ordered-list). 669 with patch("hermes_cli.tools_config._prompt_choice", return_value=1): 670 _configure_imagegen_model("fal", config) 671 # ordered[0] == current (default klein), ordered[1] == first non-default 672 assert config["image_gen"]["model"] != "fal-ai/flux-2/klein/9b" 673 assert config["image_gen"]["model"].startswith("fal-ai/") 674 675 def test_picker_with_gpt_image_does_not_prompt_quality(self): 676 """GPT-Image quality is pinned to medium in the tool's defaults — 677 no follow-up prompt, no config write for quality_setting.""" 678 from hermes_cli.tools_config import ( 679 _configure_imagegen_model, 680 IMAGEGEN_BACKENDS, 681 ) 682 catalog, default_model = IMAGEGEN_BACKENDS["fal"]["catalog_fn"]() 683 model_ids = list(catalog.keys()) 684 ordered = [default_model] + [m for m in model_ids if m != default_model] 685 gpt_idx = ordered.index("fal-ai/gpt-image-1.5") 686 687 # Only ONE picker call is expected (for model) — not two (model + quality). 688 call_count = {"n": 0} 689 def fake_prompt(*a, **kw): 690 call_count["n"] += 1 691 return gpt_idx 692 693 config = {} 694 with patch("hermes_cli.tools_config._prompt_choice", side_effect=fake_prompt): 695 _configure_imagegen_model("fal", config) 696 697 assert call_count["n"] == 1, ( 698 f"Expected 1 picker call (model only), got {call_count['n']}" 699 ) 700 assert config["image_gen"]["model"] == "fal-ai/gpt-image-1.5" 701 assert "quality_setting" not in config["image_gen"] 702 703 def test_picker_no_op_for_unknown_backend(self): 704 from hermes_cli.tools_config import _configure_imagegen_model 705 config = {} 706 _configure_imagegen_model("nonexistent-backend", config) 707 assert config == {} # untouched 708 709 def test_picker_repairs_corrupt_config_section(self): 710 """When image_gen is a non-dict (user-edit YAML), the picker should 711 replace it with a fresh dict rather than crash.""" 712 from hermes_cli.tools_config import _configure_imagegen_model 713 config = {"image_gen": "some-garbage-string"} 714 with patch("hermes_cli.tools_config._prompt_choice", return_value=0): 715 _configure_imagegen_model("fal", config) 716 assert isinstance(config["image_gen"], dict) 717 assert config["image_gen"]["model"] == "fal-ai/flux-2/klein/9b" 718 719 720 def test_save_platform_tools_normalizes_numeric_entries(): 721 """YAML may parse bare numeric toolset names as int. They should be 722 normalized to str so they survive the save round-trip. 723 """ 724 config = { 725 "platform_toolsets": { 726 "cli": ["web", "terminal", 12306, "custom-mcp"] 727 } 728 } 729 730 with patch("hermes_cli.tools_config.save_config"): 731 _save_platform_tools(config, "cli", {"web", "browser"}) 732 733 saved = config["platform_toolsets"]["cli"] 734 assert "12306" in saved 735 assert 12306 not in saved 736 737 738 def test_save_platform_tools_clears_no_mcp_sentinel(): 739 """`hermes tools` has no UI for no_mcp, so saving from the picker clears 740 the sentinel unconditionally — otherwise a user who once set no_mcp by 741 hand could never re-enable MCP servers through the UI. 742 """ 743 config = { 744 "platform_toolsets": { 745 "cli": ["web", "terminal", "no_mcp"] 746 } 747 } 748 749 with patch("hermes_cli.tools_config.save_config"): 750 _save_platform_tools(config, "cli", {"web", "browser"}) 751 752 saved = config["platform_toolsets"]["cli"] 753 assert "no_mcp" not in saved 754 755 756 def test_save_platform_tools_preserves_mcp_server_names(): 757 """Non-sentinel passthrough entries (MCP server names) must still survive 758 the save — we only clear `no_mcp`, not every non-configurable entry. 759 """ 760 config = { 761 "platform_toolsets": { 762 "cli": ["web", "terminal", "custom-mcp", "another-mcp"] 763 } 764 } 765 766 with patch("hermes_cli.tools_config.save_config"): 767 _save_platform_tools(config, "cli", {"web", "browser"}) 768 769 saved = config["platform_toolsets"]["cli"] 770 assert "custom-mcp" in saved 771 assert "another-mcp" in saved 772 773 774 def test_get_platform_tools_recovers_non_configurable_toolsets_from_composite(): 775 """Non-configurable toolsets whose tools are in the composite but not in 776 CONFIGURABLE_TOOLSETS should still appear in the result. 777 """ 778 from toolsets import TOOLSETS 779 from hermes_cli.tools_config import PLATFORMS 780 from unittest.mock import patch as mock_patch 781 782 fake_toolsets = dict(TOOLSETS) 783 fake_toolsets["_test_platform_tool"] = { 784 "description": "test", 785 "tools": ["_test_special_tool"], 786 "includes": [], 787 } 788 fake_toolsets["hermes-_test_platform"] = { 789 "description": "test composite", 790 "tools": ["web_search", "web_extract", "terminal", "process", "_test_special_tool"], 791 "includes": [], 792 } 793 794 test_platforms = { 795 "_test_platform": {"label": "Test", "default_toolset": "hermes-_test_platform"}, 796 } 797 798 with mock_patch("hermes_cli.tools_config.PLATFORMS", {**PLATFORMS, **test_platforms}): 799 with mock_patch("toolsets.TOOLSETS", fake_toolsets): 800 enabled = _get_platform_tools({}, "_test_platform") 801 802 assert "_test_platform_tool" in enabled 803 assert "web" in enabled 804 assert "terminal" in enabled 805 806 807 def test_get_platform_tools_second_pass_skips_fully_claimed_toolsets(): 808 """Toolsets whose tools are fully covered by configurable keys should NOT 809 be added by the second pass (prevents 'search', 'hermes-acp' noise). 810 """ 811 enabled = _get_platform_tools({}, "cli") 812 813 assert "search" not in enabled 814 815 816 def test_get_platform_tools_discord_both_off_by_default(): 817 """Both `discord` and `discord_admin` are opt-in via `hermes tools`, 818 even on the Discord platform itself. Users shouldn't auto-inherit 19 819 extra tools just because DISCORD_BOT_TOKEN is set.""" 820 enabled = _get_platform_tools({}, "discord") 821 assert "discord" not in enabled 822 assert "discord_admin" not in enabled 823 824 825 def test_discord_toolsets_in_configurable_toolsets(): 826 keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} 827 assert "discord" in keys 828 assert "discord_admin" in keys 829 830 831 def test_discord_toolsets_in_default_off(): 832 assert "discord" in _DEFAULT_OFF_TOOLSETS 833 assert "discord_admin" in _DEFAULT_OFF_TOOLSETS 834 835 836 def test_discord_toolsets_not_available_on_other_platforms(): 837 """Platform-scoping: discord / discord_admin should not appear on CLI, 838 Telegram, etc. — not even as an opt-in.""" 839 from hermes_cli.tools_config import _toolset_allowed_for_platform 840 for plat in ["cli", "telegram", "slack", "whatsapp", "signal"]: 841 assert not _toolset_allowed_for_platform("discord", plat), ( 842 f"`discord` toolset leaked onto {plat}" 843 ) 844 assert not _toolset_allowed_for_platform("discord_admin", plat), ( 845 f"`discord_admin` toolset leaked onto {plat}" 846 ) 847 assert _toolset_allowed_for_platform("discord", "discord") 848 assert _toolset_allowed_for_platform("discord_admin", "discord") 849 850 851 def test_discord_toolsets_user_enabled_are_honored(): 852 """When the user opts in via `hermes tools`, the toolset appears.""" 853 config = {"platform_toolsets": {"discord": ["web", "terminal", "discord"]}} 854 enabled = _get_platform_tools(config, "discord") 855 assert "discord" in enabled 856 assert "discord_admin" not in enabled 857 858 859 def test_save_platform_tools_strips_restricted_toolsets(): 860 """Hand-edited or all-platforms checklist with `discord` selected for 861 Telegram must be stripped at save time.""" 862 from hermes_cli.tools_config import _save_platform_tools 863 config = {} 864 _save_platform_tools(config, "telegram", {"web", "terminal", "discord", "discord_admin"}) 865 saved = config["platform_toolsets"]["telegram"] 866 assert "discord" not in saved 867 assert "discord_admin" not in saved 868 assert "web" in saved 869 assert "terminal" in saved 870 871 872 def test_get_platform_tools_feishu_includes_doc_and_drive(): 873 enabled = _get_platform_tools({}, "feishu") 874 assert "feishu_doc" in enabled 875 assert "feishu_drive" in enabled 876 877 878 def test_get_platform_tools_feishu_tools_not_on_other_platforms(): 879 for plat in ["cli", "telegram", "discord"]: 880 enabled = _get_platform_tools({}, plat) 881 assert "feishu_doc" not in enabled, f"feishu_doc leaked onto {plat}" 882 assert "feishu_drive" not in enabled, f"feishu_drive leaked onto {plat}" 883 884 885 def test_get_effective_configurable_toolsets_dedupes_bundled_plugins(): 886 """Bundled plugins (plugins/spotify) share their toolset key with the 887 built-in CONFIGURABLE_TOOLSETS entry. The effective list must not list 888 them twice — otherwise `hermes tools` → "reconfigure existing" shows 889 the same toolset two rows in a row. 890 """ 891 from hermes_cli.tools_config import _get_effective_configurable_toolsets 892 893 all_ts = _get_effective_configurable_toolsets() 894 keys = [ts_key for ts_key, _, _ in all_ts] 895 assert len(keys) == len(set(keys)), ( 896 f"duplicate toolset keys in effective list: " 897 f"{[k for k in keys if keys.count(k) > 1]}" 898 ) 899 # Spotify specifically — the bug that motivated the dedupe. 900 spotify_rows = [t for t in all_ts if t[0] == "spotify"] 901 assert len(spotify_rows) == 1, spotify_rows 902 # Built-in label wins over the plugin label. 903 assert spotify_rows[0][1] == "🎵 Spotify" 904 905 906 @pytest.mark.parametrize("provider,config_key,expected", [ 907 # managed provider → use_gateway True 908 ({"name": "T", "tts_provider": "elevenlabs", "managed_nous_feature": "tts", "env_vars": []}, "tts", True), 909 ({"name": "B", "browser_provider": "browserbase", "managed_nous_feature": "browser", "env_vars": []}, "browser", True), 910 ({"name": "W", "web_backend": "tavily", "managed_nous_feature": "web", "env_vars": []}, "web", True), 911 # self-hosted provider → use_gateway False 912 ({"name": "T", "tts_provider": "elevenlabs", "env_vars": []}, "tts", False), 913 ({"name": "B", "browser_provider": "browserbase", "env_vars": []}, "browser", False), 914 ({"name": "W", "web_backend": "tavily", "env_vars": []}, "web", False), 915 ]) 916 def test_reconfigure_provider_syncs_use_gateway(provider, config_key, expected): 917 config = {} 918 _reconfigure_provider(provider, config) 919 assert config[config_key]["use_gateway"] is expected 920 921 922 def test_reconfigure_browser_provider_overwrites_stale_use_gateway(): 923 # Switching from managed (use_gateway=True) to self-hosted must clear the stale flag. 924 config = {"browser": {"cloud_provider": "managed-browser", "use_gateway": True}} 925 provider = {"name": "Browserbase", "browser_provider": "browserbase", "env_vars": []} 926 _reconfigure_provider(provider, config) 927 assert config["browser"]["use_gateway"] is False