| 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) |
| |
| 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 |
|
|