/ tests / test_toolsets.py
test_toolsets.py
  1  """Tests for toolsets.py — toolset resolution, validation, and composition."""
  2  
  3  from tools.registry import ToolRegistry
  4  from toolsets import (
  5      TOOLSETS,
  6      get_toolset,
  7      resolve_toolset,
  8      resolve_multiple_toolsets,
  9      get_all_toolsets,
 10      get_toolset_names,
 11      validate_toolset,
 12      create_custom_toolset,
 13      get_toolset_info,
 14  )
 15  
 16  
 17  def _dummy_handler(args, **kwargs):
 18      return "{}"
 19  
 20  
 21  def _make_schema(name: str, description: str = "test tool"):
 22      return {
 23          "name": name,
 24          "description": description,
 25          "parameters": {"type": "object", "properties": {}},
 26      }
 27  
 28  
 29  class TestGetToolset:
 30      def test_known_toolset(self):
 31          ts = get_toolset("web")
 32          assert ts is not None
 33          assert "web_search" in ts["tools"]
 34  
 35      def test_unknown_returns_none(self):
 36          assert get_toolset("nonexistent") is None
 37  
 38  
 39  class TestResolveToolset:
 40      def test_leaf_toolset(self):
 41          tools = resolve_toolset("web")
 42          assert set(tools) == {"web_search", "web_extract"}
 43  
 44      def test_composite_toolset(self):
 45          tools = resolve_toolset("debugging")
 46          assert "terminal" in tools
 47          assert "web_search" in tools
 48          assert "web_extract" in tools
 49  
 50      def test_cycle_detection(self):
 51          # Create a cycle: A includes B, B includes A
 52          TOOLSETS["_cycle_a"] = {"description": "test", "tools": ["t1"], "includes": ["_cycle_b"]}
 53          TOOLSETS["_cycle_b"] = {"description": "test", "tools": ["t2"], "includes": ["_cycle_a"]}
 54          try:
 55              tools = resolve_toolset("_cycle_a")
 56              # Should not infinite loop — cycle is detected
 57              assert "t1" in tools
 58              assert "t2" in tools
 59          finally:
 60              del TOOLSETS["_cycle_a"]
 61              del TOOLSETS["_cycle_b"]
 62  
 63      def test_unknown_toolset_returns_empty(self):
 64          assert resolve_toolset("nonexistent") == []
 65  
 66      def test_plugin_toolset_uses_registry_snapshot(self, monkeypatch):
 67          reg = ToolRegistry()
 68          reg.register(
 69              name="plugin_b",
 70              toolset="plugin_example",
 71              schema=_make_schema("plugin_b", "B"),
 72              handler=_dummy_handler,
 73          )
 74          reg.register(
 75              name="plugin_a",
 76              toolset="plugin_example",
 77              schema=_make_schema("plugin_a", "A"),
 78              handler=_dummy_handler,
 79          )
 80  
 81          monkeypatch.setattr("tools.registry.registry", reg)
 82  
 83          assert resolve_toolset("plugin_example") == ["plugin_a", "plugin_b"]
 84  
 85      def test_all_alias(self):
 86          tools = resolve_toolset("all")
 87          assert len(tools) > 10  # Should resolve all tools from all toolsets
 88  
 89      def test_star_alias(self):
 90          tools = resolve_toolset("*")
 91          assert len(tools) > 10
 92  
 93  
 94  class TestResolveMultipleToolsets:
 95      def test_combines_and_deduplicates(self):
 96          tools = resolve_multiple_toolsets(["web", "terminal"])
 97          assert "web_search" in tools
 98          assert "web_extract" in tools
 99          assert "terminal" in tools
100          # No duplicates
101          assert len(tools) == len(set(tools))
102  
103      def test_empty_list(self):
104          assert resolve_multiple_toolsets([]) == []
105  
106  
107  class TestValidateToolset:
108      def test_valid(self):
109          assert validate_toolset("web") is True
110          assert validate_toolset("terminal") is True
111  
112      def test_all_alias_valid(self):
113          assert validate_toolset("all") is True
114          assert validate_toolset("*") is True
115  
116      def test_invalid(self):
117          assert validate_toolset("nonexistent") is False
118  
119      def test_mcp_alias_uses_live_registry(self, monkeypatch):
120          reg = ToolRegistry()
121          reg.register(
122              name="mcp_dynserver_ping",
123              toolset="mcp-dynserver",
124              schema=_make_schema("mcp_dynserver_ping", "Ping"),
125              handler=_dummy_handler,
126          )
127          reg.register_toolset_alias("dynserver", "mcp-dynserver")
128  
129          monkeypatch.setattr("tools.registry.registry", reg)
130  
131          assert validate_toolset("dynserver") is True
132          assert validate_toolset("mcp-dynserver") is True
133          assert "mcp_dynserver_ping" in resolve_toolset("dynserver")
134  
135  
136  class TestGetToolsetInfo:
137      def test_leaf(self):
138          info = get_toolset_info("web")
139          assert info["name"] == "web"
140          assert info["is_composite"] is False
141          assert info["tool_count"] == 2
142  
143      def test_composite(self):
144          info = get_toolset_info("debugging")
145          assert info["is_composite"] is True
146          assert info["tool_count"] > len(info["direct_tools"])
147  
148      def test_unknown_returns_none(self):
149          assert get_toolset_info("nonexistent") is None
150  
151  
152  class TestCreateCustomToolset:
153      def test_runtime_creation(self):
154          create_custom_toolset(
155              name="_test_custom",
156              description="Test toolset",
157              tools=["web_search"],
158              includes=["terminal"],
159          )
160          try:
161              tools = resolve_toolset("_test_custom")
162              assert "web_search" in tools
163              assert "terminal" in tools
164              assert validate_toolset("_test_custom") is True
165          finally:
166              del TOOLSETS["_test_custom"]
167  
168  
169  class TestRegistryOwnedToolsets:
170      def test_registry_membership_is_live(self, monkeypatch):
171          reg = ToolRegistry()
172          reg.register(
173              name="test_live_toolset_tool",
174              toolset="test-live-toolset",
175              schema=_make_schema("test_live_toolset_tool", "Live"),
176              handler=_dummy_handler,
177          )
178  
179          monkeypatch.setattr("tools.registry.registry", reg)
180  
181          assert validate_toolset("test-live-toolset") is True
182          assert get_toolset("test-live-toolset")["tools"] == ["test_live_toolset_tool"]
183          assert resolve_toolset("test-live-toolset") == ["test_live_toolset_tool"]
184  
185  
186  class TestToolsetConsistency:
187      """Verify structural integrity of the built-in TOOLSETS dict."""
188  
189      def test_all_toolsets_have_required_keys(self):
190          for name, ts in TOOLSETS.items():
191              assert "description" in ts, f"{name} missing description"
192              assert "tools" in ts, f"{name} missing tools"
193              assert "includes" in ts, f"{name} missing includes"
194  
195      def test_all_includes_reference_existing_toolsets(self):
196          for name, ts in TOOLSETS.items():
197              for inc in ts["includes"]:
198                  assert inc in TOOLSETS, f"{name} includes unknown toolset '{inc}'"
199  
200      def test_hermes_platforms_share_core_tools(self):
201          """All hermes-* platform toolsets share the same core tools.
202  
203          Platform-specific additions (e.g. ``discord`` / ``discord_admin``
204          on hermes-discord, gated on DISCORD_BOT_TOKEN) are allowed on top —
205          the invariant is that the core set is identical across platforms.
206          """
207          platforms = ["hermes-cli", "hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"]
208          tool_sets = [set(TOOLSETS[p]["tools"]) for p in platforms]
209          # All platforms must contain the shared core; platform-specific
210          # extras are OK (subset check, not equality).
211          core = set.intersection(*tool_sets)
212          for name, ts in zip(platforms, tool_sets):
213              assert core.issubset(ts), f"{name} is missing core tools: {core - ts}"
214          # Sanity: the shared core must be non-trivial (i.e. we didn't
215          # silently let a platform diverge so far that nothing is shared).
216          assert len(core) > 20, f"Suspiciously small shared core: {len(core)} tools"
217  
218  
219  class TestPluginToolsets:
220      def test_get_all_toolsets_includes_plugin_toolset(self, monkeypatch):
221          reg = ToolRegistry()
222          reg.register(
223              name="plugin_tool",
224              toolset="plugin_bundle",
225              schema=_make_schema("plugin_tool", "Plugin tool"),
226              handler=_dummy_handler,
227          )
228  
229          monkeypatch.setattr("tools.registry.registry", reg)
230  
231          all_toolsets = get_all_toolsets()
232          assert "plugin_bundle" in all_toolsets
233          assert all_toolsets["plugin_bundle"]["tools"] == ["plugin_tool"]