"""Tests for the tool framework and core tools (Phase 4).""" from pathlib import Path from unittest.mock import patch import pytest from app.models.config import AppConfig, PermissionsConfig, load_config from app.models.tool_call import ToolResultStatus from app.services.permissions import PermissionsService from app.tools.base import BaseTool from app.tools.filesystem import ListDirTool, ReadFileTool from app.tools.registry import ToolRegistry, create_default_registry from app.tools.search import FindFilesTool, GrepFilesTool @pytest.fixture def config() -> AppConfig: return load_config() @pytest.fixture def workspace(config: AppConfig) -> Path: return config.agent.workspace_root # --- BaseTool --- class TestBaseTool: def test_run_with_invalid_args_returns_error(self, workspace: Path, config: AppConfig) -> None: tool = ReadFileTool(workspace, config) # missing required 'file_path' result = tool.run("tc-1", {}) assert result.status == ToolResultStatus.ERROR assert "Invalid arguments" in (result.error or "") def test_get_openai_schema_structure(self, workspace: Path, config: AppConfig) -> None: tool = ReadFileTool(workspace, config) schema = tool.get_openai_schema() assert schema["type"] == "function" assert schema["function"]["name"] == "read_file" assert "parameters" in schema["function"] assert schema["function"]["parameters"]["type"] == "object" # --- PermissionsService --- class TestPermissionsService: def test_deny_list_blocks(self) -> None: svc = PermissionsService(PermissionsConfig(deny=["dangerous_tool"])) assert svc.check("dangerous_tool") is False def test_auto_approve_allows(self) -> None: svc = PermissionsService(PermissionsConfig(auto_approve=["read_file"])) assert svc.check("read_file") is True @patch("app.services.permissions.Confirm.ask", return_value=True) def test_prompt_user_approved(self, mock_ask: object) -> None: svc = PermissionsService(PermissionsConfig(prompt_user=["write_file"])) assert svc.check("write_file") is True @patch("app.services.permissions.Confirm.ask", return_value=False) def test_prompt_user_denied(self, mock_ask: object) -> None: svc = PermissionsService(PermissionsConfig(prompt_user=["write_file"])) assert svc.check("write_file") is False @patch("app.services.permissions.Confirm.ask", return_value=False) def test_unlisted_tool_prompts(self, mock_ask: object) -> None: svc = PermissionsService(PermissionsConfig()) assert svc.check("unknown_tool") is False # --- ToolRegistry --- class TestToolRegistry: def test_register_and_get(self, workspace: Path, config: AppConfig) -> None: registry = ToolRegistry() tool = ReadFileTool(workspace, config) registry.register(tool) assert registry.get("read_file") is tool def test_duplicate_raises(self, workspace: Path, config: AppConfig) -> None: registry = ToolRegistry() tool = ReadFileTool(workspace, config) registry.register(tool) with pytest.raises(ValueError, match="Duplicate"): registry.register(tool) def test_get_missing_returns_none(self) -> None: registry = ToolRegistry() assert registry.get("nonexistent") is None def test_create_default_registry(self, workspace: Path, config: AppConfig) -> None: registry = create_default_registry(workspace, config) names = set(registry.get_all().keys()) assert names == { "read_file", "list_dir", "grep_files", "find_files", "write_file", "make_dir", "delete_file", "str_replace", "patch_apply", "run_command", "finish", } def test_schema_export(self, workspace: Path, config: AppConfig) -> None: registry = create_default_registry(workspace, config) schemas = registry.get_openai_tools_schema() assert len(schemas) == 11 assert all(s["type"] == "function" for s in schemas) # --- ReadFileTool --- class TestReadFileTool: def test_read_existing_file(self, workspace: Path, config: AppConfig) -> None: tool = ReadFileTool(workspace, config) result = tool.run("tc-1", {"file_path": "config/config.yaml"}) assert result.status == ToolResultStatus.SUCCESS assert "llm:" in result.output def test_read_nonexistent_file(self, workspace: Path, config: AppConfig) -> None: tool = ReadFileTool(workspace, config) result = tool.run("tc-2", {"file_path": "nonexistent.txt"}) assert result.status == ToolResultStatus.ERROR assert "not found" in (result.error or "").lower() def test_path_traversal_blocked(self, workspace: Path, config: AppConfig) -> None: tool = ReadFileTool(workspace, config) result = tool.run("tc-3", {"file_path": "../../etc/passwd"}) assert result.status == ToolResultStatus.ERROR assert "outside" in (result.error or "").lower() # --- ListDirTool --- class TestListDirTool: def test_list_root(self, workspace: Path, config: AppConfig) -> None: tool = ListDirTool(workspace, config) result = tool.run("tc-1", {}) assert result.status == ToolResultStatus.SUCCESS assert "app/" in result.output def test_list_subdir(self, workspace: Path, config: AppConfig) -> None: tool = ListDirTool(workspace, config) result = tool.run("tc-2", {"directory_path": "app/tools"}) assert result.status == ToolResultStatus.SUCCESS assert "base.py" in result.output def test_list_nonexistent_dir(self, workspace: Path, config: AppConfig) -> None: tool = ListDirTool(workspace, config) result = tool.run("tc-3", {"directory_path": "nonexistent_dir"}) assert result.status == ToolResultStatus.ERROR def test_list_recursive(self, workspace: Path, config: AppConfig) -> None: tool = ListDirTool(workspace, config) result = tool.run("tc-4", {"directory_path": "config", "recursive": True}) assert result.status == ToolResultStatus.SUCCESS assert "config.yaml" in result.output # --- GrepFilesTool --- class TestGrepFilesTool: def test_grep_finds_matches(self, workspace: Path, config: AppConfig) -> None: tool = GrepFilesTool(workspace, config) result = tool.run("tc-1", {"pattern": "BaseTool", "path": "app/tools", "file_pattern": "*.py"}) assert result.status == ToolResultStatus.SUCCESS assert "BaseTool" in result.output def test_grep_no_matches(self, workspace: Path, config: AppConfig) -> None: tool = GrepFilesTool(workspace, config) # Search only in config/ to avoid matching the test file itself result = tool.run("tc-2", {"pattern": "zzz_will_never_match_zzz", "path": "config"}) assert result.status == ToolResultStatus.SUCCESS assert "No matches" in result.output def test_grep_path_traversal_blocked(self, workspace: Path, config: AppConfig) -> None: tool = GrepFilesTool(workspace, config) result = tool.run("tc-3", {"pattern": "root", "path": "../../etc"}) assert result.status == ToolResultStatus.ERROR # --- FindFilesTool --- class TestFindFilesTool: def test_find_files(self, workspace: Path, config: AppConfig) -> None: tool = FindFilesTool(workspace, config) result = tool.run("tc-1", {"pattern": "*.py", "path": "app/tools"}) assert result.status == ToolResultStatus.SUCCESS assert "base.py" in result.output def test_find_no_results(self, workspace: Path, config: AppConfig) -> None: tool = FindFilesTool(workspace, config) result = tool.run("tc-2", {"pattern": "*.nonexistent_ext"}) assert result.status == ToolResultStatus.SUCCESS assert "No files found" in result.output def test_find_relative_paths(self, workspace: Path, config: AppConfig) -> None: tool = FindFilesTool(workspace, config) result = tool.run("tc-3", {"pattern": "config.yaml"}) assert result.status == ToolResultStatus.SUCCESS # Should be relative, not absolute assert not result.output.startswith("/")