/ tests / hermes_cli / test_tools_config.py
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