| from __future__ import annotations |
| import sys |
| import json |
| import importlib |
| from types import ModuleType |
| from pathlib import Path |
| from unittest.mock import AsyncMock, MagicMock |
|
|
| import pytest |
|
|
| import reachy_mini_conversation_rmscript.config as config_mod |
| import reachy_mini_conversation_rmscript.tool_spaces as tool_spaces_mod |
| from reachy_mini_conversation_rmscript.tool_spaces import ( |
| InstalledToolSpace, |
| InstalledToolSpaceTool, |
| ResolvedInstalledToolSpace, |
| InstalledToolSpacesManifest, |
| write_installed_tool_spaces, |
| ) |
|
|
|
|
| SEARCH_SPACE_SLUG = "pollen-robotics/reachy-mini-search-tool" |
| WEATHER_SPACE_SLUG = "pollen-robotics/reachy-mini-weather-tool" |
| SEARCH_ALIAS = "pollen_robotics_reachy_mini_search_tool" |
| WEATHER_ALIAS = "pollen_robotics_reachy_mini_weather_tool" |
| SEARCH_TOOL_ID = f"{SEARCH_ALIAS}__search_web" |
| SEARCH_CLIENT_TOOL_ID = f"{SEARCH_ALIAS}__reachy_mini_search_tool_search_web" |
|
|
|
|
| def _reload_core_tools() -> ModuleType: |
| for module_name in list(sys.modules): |
| if module_name.startswith("reachy_mini_conversation_rmscript.tools."): |
| sys.modules.pop(module_name, None) |
|
|
| sys.modules.pop("reachy_mini_conversation_rmscript.tools.core_tools", None) |
| return importlib.import_module("reachy_mini_conversation_rmscript.tools.core_tools") |
|
|
|
|
| def _resolved_remote_space(client: AsyncMock) -> ResolvedInstalledToolSpace: |
| return ResolvedInstalledToolSpace( |
| slug=SEARCH_SPACE_SLUG, |
| alias=SEARCH_ALIAS, |
| mcp_url="https://pollen-robotics-reachy-mini-search-tool.hf.space/gradio_api/mcp/", |
| tags=["mcp", "reachy-mini-tool"], |
| tools=[ |
| InstalledToolSpaceTool( |
| local_name=SEARCH_TOOL_ID, |
| client_tool_name=SEARCH_CLIENT_TOOL_ID, |
| remote_name="reachy_mini_search_tool_search_web", |
| description="Search the web", |
| parameters_schema={ |
| "type": "object", |
| "properties": {"query": {"type": "string"}}, |
| "required": ["query"], |
| }, |
| ) |
| ], |
| client=client, |
| ) |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_initialize_tools_loads_enabled_installed_remote_tools_and_dispatches( |
| tmp_path: Path, |
| monkeypatch: pytest.MonkeyPatch, |
| ) -> None: |
| """Enabled public Space tools should join the registry and dispatch through the normal path.""" |
| monkeypatch.chdir(tmp_path) |
| external_profiles_root = tmp_path / "external_profiles" |
| profile_dir = external_profiles_root / "mcp_profile" |
| profile_dir.mkdir(parents=True) |
| (profile_dir / "instructions.txt").write_text("hello\n", encoding="utf-8") |
| (profile_dir / "tools.txt").write_text(f"{SEARCH_TOOL_ID}\n", encoding="utf-8") |
|
|
| monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", "mcp_profile") |
| monkeypatch.setattr(config_mod.config, "PROFILES_DIRECTORY", external_profiles_root) |
| monkeypatch.setattr(config_mod.config, "TOOLS_DIRECTORY", None) |
| monkeypatch.setattr(config_mod.config, "AUTOLOAD_EXTERNAL_TOOLS", False) |
|
|
| client = AsyncMock() |
| client.call_tool.return_value = { |
| "status": "ok", |
| "server_alias": SEARCH_ALIAS, |
| "remote_tool_name": "reachy_mini_search_tool_search_web", |
| "namespaced_tool_name": SEARCH_CLIENT_TOOL_ID, |
| "content_blocks": [], |
| "text": "hello", |
| } |
| resolver = MagicMock(side_effect=lambda _slug: _resolved_remote_space(client)) |
| monkeypatch.setattr(tool_spaces_mod, "resolve_public_tool_space_sync", resolver) |
|
|
| write_installed_tool_spaces( |
| None, |
| InstalledToolSpacesManifest( |
| spaces=[ |
| InstalledToolSpace(slug=SEARCH_SPACE_SLUG, alias=SEARCH_ALIAS), |
| InstalledToolSpace(slug=WEATHER_SPACE_SLUG, alias=WEATHER_ALIAS), |
| ] |
| ), |
| ) |
|
|
| core_tools_mod = _reload_core_tools() |
| core_tools_mod.initialize_tools() |
|
|
| resolver.assert_called_once_with(SEARCH_SPACE_SLUG) |
| assert SEARCH_TOOL_ID in core_tools_mod.ALL_TOOLS |
| tool_specs = core_tools_mod.get_tool_specs() |
| assert any(spec["name"] == SEARCH_TOOL_ID for spec in tool_specs) |
|
|
| result = await core_tools_mod.dispatch_tool_call( |
| SEARCH_TOOL_ID, |
| json.dumps({"query": "hello"}), |
| core_tools_mod.ToolDependencies( |
| reachy_mini=object(), |
| movement_manager=object(), |
| ), |
| ) |
|
|
| assert result["namespaced_tool_name"] == SEARCH_TOOL_ID |
| assert result["tool_space_slug"] == SEARCH_SPACE_SLUG |
| client.call_tool.assert_awaited_once_with(SEARCH_CLIENT_TOOL_ID, {"query": "hello"}) |
|
|
|
|
| def test_initialize_tools_warns_when_enabled_remote_tool_is_unavailable( |
| tmp_path: Path, |
| monkeypatch: pytest.MonkeyPatch, |
| caplog: pytest.LogCaptureFixture, |
| ) -> None: |
| """Startup should warn and skip tools from an unavailable Space, not crash.""" |
| monkeypatch.chdir(tmp_path) |
| external_profiles_root = tmp_path / "external_profiles" |
| profile_dir = external_profiles_root / "remote_profile" |
| profile_dir.mkdir(parents=True) |
| (profile_dir / "instructions.txt").write_text("hello\n", encoding="utf-8") |
| (profile_dir / "tools.txt").write_text(f"{SEARCH_TOOL_ID}\n", encoding="utf-8") |
|
|
| monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", "remote_profile") |
| monkeypatch.setattr(config_mod.config, "PROFILES_DIRECTORY", external_profiles_root) |
| monkeypatch.setattr(config_mod.config, "TOOLS_DIRECTORY", None) |
| monkeypatch.setattr(config_mod.config, "AUTOLOAD_EXTERNAL_TOOLS", False) |
| monkeypatch.setattr( |
| tool_spaces_mod, "resolve_public_tool_space_sync", lambda slug: (_ for _ in ()).throw(RuntimeError("boom")) |
| ) |
|
|
| write_installed_tool_spaces( |
| None, |
| InstalledToolSpacesManifest( |
| spaces=[ |
| InstalledToolSpace(slug=SEARCH_SPACE_SLUG, alias=SEARCH_ALIAS), |
| ] |
| ), |
| ) |
|
|
| core_tools_mod = _reload_core_tools() |
| with caplog.at_level("WARNING"): |
| core_tools_mod.initialize_tools() |
|
|
| assert any(SEARCH_SPACE_SLUG in record.message for record in caplog.records) |
| assert SEARCH_TOOL_ID not in core_tools_mod.ALL_TOOLS |
|
|
|
|
| def test_initialize_tools_inherits_default_tools_txt_for_profile_without_local_tool_list( |
| tmp_path: Path, |
| monkeypatch: pytest.MonkeyPatch, |
| ) -> None: |
| """Profiles without a local tools.txt should inherit the built-in default tool set.""" |
| external_profiles_root = tmp_path / "external_profiles" |
| profile_dir = external_profiles_root / "inherit_default" |
| profile_dir.mkdir(parents=True) |
| (profile_dir / "instructions.txt").write_text("hello\n", encoding="utf-8") |
|
|
| monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", "inherit_default") |
| monkeypatch.setattr(config_mod.config, "PROFILES_DIRECTORY", external_profiles_root) |
| monkeypatch.setattr(config_mod.config, "TOOLS_DIRECTORY", None) |
| monkeypatch.setattr(config_mod.config, "AUTOLOAD_EXTERNAL_TOOLS", False) |
|
|
| core_tools_mod = _reload_core_tools() |
| core_tools_mod.initialize_tools() |
|
|
| assert "dance" in core_tools_mod.ALL_TOOLS |
|
|