Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
256 lines
9.3 KiB
Python
256 lines
9.3 KiB
Python
"""Unit tests for timmy.tools — coverage gaps.
|
|
|
|
Tests _make_smart_read_file, _safe_eval edge cases, consult_grok,
|
|
_create_stub_toolkit, get_tools_for_agent, and AiderTool edge cases.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import math
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from timmy.tools import (
|
|
_create_stub_toolkit,
|
|
_safe_eval,
|
|
consult_grok,
|
|
create_aider_tool,
|
|
get_tools_for_agent,
|
|
)
|
|
|
|
# ── _safe_eval edge cases ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSafeEval:
|
|
"""Edge cases for the AST-based safe evaluator."""
|
|
|
|
def _eval(self, expr: str):
|
|
allowed = {k: getattr(math, k) for k in dir(math) if not k.startswith("_")}
|
|
allowed["math"] = math
|
|
allowed["abs"] = abs
|
|
allowed["round"] = round
|
|
allowed["min"] = min
|
|
allowed["max"] = max
|
|
tree = ast.parse(expr, mode="eval")
|
|
return _safe_eval(tree, allowed)
|
|
|
|
def test_unsupported_constant_type(self):
|
|
"""String constants should be rejected."""
|
|
with pytest.raises(ValueError, match="Unsupported constant"):
|
|
self._eval("'hello'")
|
|
|
|
def test_unsupported_binary_op(self):
|
|
"""Bitwise ops are not in the allowlist."""
|
|
with pytest.raises(ValueError, match="Unsupported"):
|
|
self._eval("3 & 5")
|
|
|
|
def test_unsupported_unary_op(self):
|
|
"""Bitwise inversion is not supported."""
|
|
with pytest.raises(ValueError, match="Unsupported"):
|
|
self._eval("~5")
|
|
|
|
def test_unknown_name(self):
|
|
with pytest.raises(ValueError, match="Unknown name"):
|
|
self._eval("foo")
|
|
|
|
def test_attribute_on_non_math(self):
|
|
"""Attribute access on anything except the math module is blocked."""
|
|
with pytest.raises(ValueError, match="Attribute access not allowed"):
|
|
self._eval("abs.__class__")
|
|
|
|
def test_call_non_callable(self):
|
|
"""Calling a non-callable (like a number) should fail."""
|
|
with pytest.raises((ValueError, TypeError)):
|
|
self._eval("(42)()")
|
|
|
|
def test_unsupported_syntax_subscript(self):
|
|
"""Subscript syntax (a[0]) is not supported."""
|
|
with pytest.raises(ValueError, match="Unsupported syntax"):
|
|
self._eval("[1, 2][0]")
|
|
|
|
def test_kwargs_in_call(self):
|
|
"""math.log with keyword arg should work through the evaluator."""
|
|
result = self._eval("round(3.14159)")
|
|
assert result == 3
|
|
|
|
def test_math_attr_valid(self):
|
|
"""Accessing a valid math attribute should work."""
|
|
result = self._eval("math.pi")
|
|
assert result == math.pi
|
|
|
|
def test_math_attr_invalid(self):
|
|
"""Accessing a nonexistent math attribute should fail."""
|
|
with pytest.raises(ValueError, match="Attribute access not allowed"):
|
|
self._eval("math.__builtins__")
|
|
|
|
|
|
# ── _make_smart_read_file ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestMakeSmartReadFile:
|
|
"""Test the smart_read_file wrapper for directory detection."""
|
|
|
|
def test_directory_returns_listing(self, tmp_path):
|
|
"""When given a directory, should list its contents."""
|
|
(tmp_path / "alpha.txt").touch()
|
|
(tmp_path / "beta.py").touch()
|
|
(tmp_path / ".hidden").touch() # should be excluded
|
|
|
|
from timmy.tools import _make_smart_read_file
|
|
|
|
file_tools = MagicMock()
|
|
file_tools.check_escape.return_value = (True, tmp_path)
|
|
smart_read = _make_smart_read_file(file_tools)
|
|
|
|
result = smart_read(file_name=str(tmp_path))
|
|
assert "is a directory" in result
|
|
assert "alpha.txt" in result
|
|
assert "beta.py" in result
|
|
assert ".hidden" not in result
|
|
|
|
def test_empty_directory(self, tmp_path):
|
|
"""Empty directory should show placeholder."""
|
|
from timmy.tools import _make_smart_read_file
|
|
|
|
empty_dir = tmp_path / "empty"
|
|
empty_dir.mkdir()
|
|
|
|
file_tools = MagicMock()
|
|
file_tools.check_escape.return_value = (True, empty_dir)
|
|
smart_read = _make_smart_read_file(file_tools)
|
|
|
|
result = smart_read(file_name=str(empty_dir))
|
|
assert "empty directory" in result
|
|
|
|
def test_no_file_name_uses_path_kwarg(self):
|
|
"""When file_name is empty, should fall back to path= kwarg."""
|
|
from timmy.tools import _make_smart_read_file
|
|
|
|
file_tools = MagicMock()
|
|
file_tools.check_escape.return_value = (True, MagicMock(is_dir=lambda: False))
|
|
file_tools.read_file.return_value = "file content"
|
|
smart_read = _make_smart_read_file(file_tools)
|
|
|
|
smart_read(path="/some/file.txt")
|
|
file_tools.read_file.assert_called_once()
|
|
|
|
def test_no_file_name_no_path(self):
|
|
"""When neither file_name nor path is given, return error."""
|
|
from timmy.tools import _make_smart_read_file
|
|
|
|
file_tools = MagicMock()
|
|
smart_read = _make_smart_read_file(file_tools)
|
|
|
|
result = smart_read()
|
|
assert "Error" in result
|
|
|
|
def test_file_delegates_to_original(self, tmp_path):
|
|
"""Regular files should delegate to original read_file."""
|
|
from timmy.tools import _make_smart_read_file
|
|
|
|
f = tmp_path / "hello.txt"
|
|
f.write_text("hello world")
|
|
|
|
file_tools = MagicMock()
|
|
file_tools.check_escape.return_value = (True, f)
|
|
file_tools.read_file.return_value = "hello world"
|
|
smart_read = _make_smart_read_file(file_tools)
|
|
|
|
result = smart_read(file_name=str(f))
|
|
assert result == "hello world"
|
|
|
|
def test_preserves_docstring(self):
|
|
"""smart_read_file should copy the original's docstring."""
|
|
from timmy.tools import _make_smart_read_file
|
|
|
|
file_tools = MagicMock()
|
|
file_tools.read_file.__doc__ = "Original docstring."
|
|
smart_read = _make_smart_read_file(file_tools)
|
|
assert smart_read.__doc__ == "Original docstring."
|
|
|
|
|
|
# ── consult_grok ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestConsultGrok:
|
|
"""Test the Grok consultation tool."""
|
|
|
|
@patch("timmy.tools.settings")
|
|
def test_grok_unavailable(self, mock_settings):
|
|
"""When Grok is disabled, should return a helpful message."""
|
|
with patch("timmy.backends.grok_available", return_value=False):
|
|
result = consult_grok("What is 2+2?")
|
|
assert "not available" in result.lower()
|
|
|
|
|
|
# ── _create_stub_toolkit ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCreateStubToolkit:
|
|
"""Test stub toolkit creation for creative agents."""
|
|
|
|
def test_stub_has_correct_name(self):
|
|
toolkit = _create_stub_toolkit("pixel")
|
|
if toolkit is None:
|
|
pytest.skip("Agno tools not available")
|
|
assert toolkit.name == "pixel"
|
|
|
|
def test_stub_for_different_agent(self):
|
|
toolkit = _create_stub_toolkit("lyra")
|
|
if toolkit is None:
|
|
pytest.skip("Agno tools not available")
|
|
assert toolkit.name == "lyra"
|
|
|
|
|
|
# ── get_tools_for_agent ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestGetToolsForAgent:
|
|
"""Test get_tools_for_agent (not just the alias)."""
|
|
|
|
def test_known_agent_returns_toolkit(self):
|
|
result = get_tools_for_agent("echo")
|
|
assert result is not None
|
|
|
|
def test_unknown_agent_returns_none(self):
|
|
result = get_tools_for_agent("nonexistent")
|
|
assert result is None
|
|
|
|
def test_custom_base_dir(self, tmp_path):
|
|
result = get_tools_for_agent("echo", base_dir=tmp_path)
|
|
assert result is not None
|
|
|
|
|
|
# ── AiderTool edge cases ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAiderToolEdgeCases:
|
|
"""Additional edge cases for the AiderTool."""
|
|
|
|
@patch("subprocess.run")
|
|
def test_aider_success_empty_stdout(self, mock_run, tmp_path):
|
|
"""When stdout is empty, should return fallback message."""
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="")
|
|
tool = create_aider_tool(tmp_path)
|
|
result = tool.run_aider("do something")
|
|
assert "successfully" in result.lower()
|
|
|
|
@patch("subprocess.run")
|
|
def test_aider_custom_model(self, mock_run, tmp_path):
|
|
"""Custom model parameter should be passed to subprocess."""
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="done")
|
|
tool = create_aider_tool(tmp_path)
|
|
tool.run_aider("task", model="deepseek-coder:6.7b")
|
|
args = mock_run.call_args[0][0]
|
|
assert "ollama/deepseek-coder:6.7b" in args
|
|
|
|
@patch("subprocess.run")
|
|
def test_aider_os_error(self, mock_run, tmp_path):
|
|
"""OSError should be caught gracefully."""
|
|
mock_run.side_effect = OSError("Permission denied")
|
|
tool = create_aider_tool(tmp_path)
|
|
result = tool.run_aider("task")
|
|
assert "error" in result.lower()
|