import sys import importlib from types import ModuleType from pathlib import Path import pytest import reachy_mini_conversation_rmscript.config as config_mod def _reload_core_tools() -> ModuleType: """Reload core_tools after config object has been patched.""" for module_name in list(sys.modules): if module_name.startswith("reachy_mini_conversation_rmscript.tools."): sys.modules.pop(module_name, None) # External file-loaded modules are registered by bare tool name. sys.modules.pop("ext_ping", None) sys.modules.pop("sweep_look", None) sys.modules.pop("ext_dup_a", None) sys.modules.pop("ext_dup_b", None) sys.modules.pop("reachy_mini_conversation_rmscript.tools.core_tools", None) core_tools_mod = importlib.import_module("reachy_mini_conversation_rmscript.tools.core_tools") core_tools_mod.initialize_tools() return core_tools_mod def test_external_profile_can_use_builtin_tools(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """External profile tools.txt can reference built-in src tools.""" profile_name = "ext_profile_test" external_profiles_root = tmp_path / "external_profiles" profile_dir = external_profiles_root / profile_name profile_dir.mkdir(parents=True) (profile_dir / "instructions.txt").write_text("hello\n", encoding="utf-8") (profile_dir / "tools.txt").write_text("dance\n", encoding="utf-8") monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", profile_name) 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() assert "dance" in core_tools_mod.ALL_TOOLS assert "dance" not in sys.modules def test_external_tools_can_be_loaded_without_external_profile( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """External tools can be loaded with built-in profile via autoload mode.""" external_tools_root = tmp_path / "external_tools" external_tools_root.mkdir(parents=True) (external_tools_root / "ext_ping.py").write_text( "\n".join( [ "from typing import Any, Dict", "from reachy_mini_conversation_rmscript.tools.core_tools import Tool, ToolDependencies", "", "class ExtPingTool(Tool):", ' name = "ext_ping"', ' description = "External ping tool"', ' parameters_schema = {"type": "object", "properties": {}, "required": []}', "", " async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:", ' return {"status": "ok"}', "", ] ), encoding="utf-8", ) monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", "default") monkeypatch.setattr(config_mod.config, "PROFILES_DIRECTORY", config_mod.DEFAULT_PROFILES_DIRECTORY) monkeypatch.setattr(config_mod.config, "TOOLS_DIRECTORY", external_tools_root) monkeypatch.setattr(config_mod.config, "AUTOLOAD_EXTERNAL_TOOLS", True) core_tools_mod = _reload_core_tools() assert "ext_ping" in core_tools_mod.ALL_TOOLS def test_external_tools_fail_on_duplicate_tool_names(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Loading must fail if multiple tools declare the same Tool.name.""" external_tools_root = tmp_path / "external_tools" external_tools_root.mkdir(parents=True) duplicate_tool_source = "\n".join( [ "from typing import Any, Dict", "from reachy_mini_conversation_rmscript.tools.core_tools import Tool, ToolDependencies", "", "class DupTool(Tool):", ' name = "dup_tool"', ' description = "Duplicate tool name"', ' parameters_schema = {"type": "object", "properties": {}, "required": []}', "", " async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:", ' return {"status": "ok"}', "", ] ) (external_tools_root / "ext_dup_a.py").write_text(duplicate_tool_source, encoding="utf-8") (external_tools_root / "ext_dup_b.py").write_text(duplicate_tool_source, encoding="utf-8") monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", "default") monkeypatch.setattr(config_mod.config, "PROFILES_DIRECTORY", config_mod.DEFAULT_PROFILES_DIRECTORY) monkeypatch.setattr(config_mod.config, "TOOLS_DIRECTORY", external_tools_root) monkeypatch.setattr(config_mod.config, "AUTOLOAD_EXTERNAL_TOOLS", True) with pytest.raises(RuntimeError, match="Duplicate Tool.name values detected"): _reload_core_tools() def test_builtin_profile_can_load_profile_local_tools( monkeypatch: pytest.MonkeyPatch, ) -> None: """Built-in profile-local tools should load from the packaged profiles root.""" monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", "example") monkeypatch.setattr(config_mod.config, "PROFILES_DIRECTORY", config_mod.DEFAULT_PROFILES_DIRECTORY) monkeypatch.setattr(config_mod.config, "TOOLS_DIRECTORY", None) monkeypatch.setattr(config_mod.config, "AUTOLOAD_EXTERNAL_TOOLS", False) core_tools_mod = _reload_core_tools() assert "sweep_look" in core_tools_mod.ALL_TOOLS def test_tool_registry_reloads_when_profile_changes(monkeypatch: pytest.MonkeyPatch) -> None: """Runtime profile changes should refresh enabled tools without restarting Python.""" monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", "example") monkeypatch.setattr(config_mod.config, "PROFILES_DIRECTORY", config_mod.DEFAULT_PROFILES_DIRECTORY) monkeypatch.setattr(config_mod.config, "TOOLS_DIRECTORY", None) monkeypatch.setattr(config_mod.config, "AUTOLOAD_EXTERNAL_TOOLS", False) core_tools_mod = _reload_core_tools() initial_tool_names = {spec["name"] for spec in core_tools_mod.get_tool_specs()} assert "sweep_look" in initial_tool_names assert "camera" not in initial_tool_names monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", "default") reloaded_tool_names = {spec["name"] for spec in core_tools_mod.get_tool_specs()} assert "camera" in reloaded_tool_names assert "move_head" in reloaded_tool_names assert "sweep_look" not in reloaded_tool_names assert "camera" in core_tools_mod.ALL_TOOLS assert "sweep_look" not in core_tools_mod.ALL_TOOLS def test_forced_tool_registry_reload_does_not_duplicate_profile_local_tool( monkeypatch: pytest.MonkeyPatch, ) -> None: """Reloading a profile-local tool should ignore stale class objects from the previous load.""" monkeypatch.setattr(config_mod.config, "REACHY_MINI_CUSTOM_PROFILE", "example") monkeypatch.setattr(config_mod.config, "PROFILES_DIRECTORY", config_mod.DEFAULT_PROFILES_DIRECTORY) 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(force=True) assert "sweep_look" in core_tools_mod.ALL_TOOLS