tenseleyflow/loader / f91124a

Browse files

test: add unit tests for tools and parsing

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f91124a61085dfb982bdab4b34bb26a4c3cd2d26
Parents
080b2e5
Tree
eb2fa25

4 changed files

StatusFile+-
A tests/__init__.py 1 0
A tests/conftest.py 37 0
A tests/test_parsing.py 121 0
A tests/test_tools.py 240 0
tests/__init__.pyadded
@@ -0,0 +1,1 @@
1
+"""Tests for loader."""
tests/conftest.pyadded
@@ -0,0 +1,37 @@
1
+"""Pytest configuration and fixtures."""
2
+
3
+import pytest
4
+import tempfile
5
+from pathlib import Path
6
+
7
+
8
+@pytest.fixture
9
+def temp_dir():
10
+    """Create a temporary directory for tests."""
11
+    with tempfile.TemporaryDirectory() as tmpdir:
12
+        yield Path(tmpdir)
13
+
14
+
15
+@pytest.fixture
16
+def sample_file(temp_dir):
17
+    """Create a sample file for testing."""
18
+    file_path = temp_dir / "sample.txt"
19
+    file_path.write_text("Line 1\nLine 2\nLine 3\n")
20
+    return file_path
21
+
22
+
23
+@pytest.fixture
24
+def sample_python_file(temp_dir):
25
+    """Create a sample Python file for testing."""
26
+    file_path = temp_dir / "sample.py"
27
+    file_path.write_text('''"""Sample module."""
28
+
29
+def hello():
30
+    """Say hello."""
31
+    return "Hello, world!"
32
+
33
+def add(a, b):
34
+    """Add two numbers."""
35
+    return a + b
36
+''')
37
+    return file_path
tests/test_parsing.pyadded
@@ -0,0 +1,121 @@
1
+"""Tests for the ReAct parsing module."""
2
+
3
+import pytest
4
+from loader.agent.parsing import parse_tool_calls, format_tool_result
5
+
6
+
7
+class TestParseToolCalls:
8
+    """Tests for parse_tool_calls function."""
9
+
10
+    def test_parse_tool_call_xml_style(self):
11
+        text = '''I need to read the file.
12
+<tool_call>
13
+{"name": "read", "arguments": {"file_path": "/tmp/test.txt"}}
14
+</tool_call>
15
+'''
16
+        result = parse_tool_calls(text)
17
+        assert len(result.tool_calls) == 1
18
+        assert result.tool_calls[0].name == "read"
19
+        assert result.tool_calls[0].arguments == {"file_path": "/tmp/test.txt"}
20
+        assert not result.is_final_answer
21
+
22
+    def test_parse_multiple_tool_calls(self):
23
+        text = '''<tool_call>
24
+{"name": "read", "arguments": {"file_path": "a.txt"}}
25
+</tool_call>
26
+<tool_call>
27
+{"name": "read", "arguments": {"file_path": "b.txt"}}
28
+</tool_call>'''
29
+        result = parse_tool_calls(text)
30
+        assert len(result.tool_calls) == 2
31
+        assert result.tool_calls[0].arguments["file_path"] == "a.txt"
32
+        assert result.tool_calls[1].arguments["file_path"] == "b.txt"
33
+
34
+    def test_parse_final_answer(self):
35
+        text = '''Thought: I have all the information needed.
36
+Final Answer: The file contains a hello world program.'''
37
+        result = parse_tool_calls(text)
38
+        assert result.is_final_answer
39
+        assert len(result.tool_calls) == 0
40
+        assert "hello world" in result.content.lower()
41
+
42
+    def test_parse_no_tool_calls(self):
43
+        text = "Just some regular text without any tool calls."
44
+        result = parse_tool_calls(text)
45
+        assert len(result.tool_calls) == 0
46
+        assert not result.is_final_answer
47
+        assert "regular text" in result.content
48
+
49
+    def test_parse_bare_json(self):
50
+        text = '''Let me read that file.
51
+{"name": "read", "arguments": {"file_path": "/test.txt"}}'''
52
+        result = parse_tool_calls(text)
53
+        assert len(result.tool_calls) == 1
54
+        assert result.tool_calls[0].name == "read"
55
+
56
+    def test_parse_removes_react_labels(self):
57
+        text = '''Thought: I need to check this.
58
+Action: <tool_call>
59
+{"name": "read", "arguments": {"file_path": "test.txt"}}
60
+</tool_call>'''
61
+        result = parse_tool_calls(text)
62
+        assert "Thought:" not in result.content
63
+        assert "Action:" not in result.content
64
+
65
+    def test_parse_invalid_json_ignored(self):
66
+        text = '''<tool_call>
67
+{invalid json here}
68
+</tool_call>'''
69
+        result = parse_tool_calls(text)
70
+        assert len(result.tool_calls) == 0
71
+
72
+    def test_parse_empty_arguments(self):
73
+        text = '''<tool_call>
74
+{"name": "pwd", "arguments": {}}
75
+</tool_call>'''
76
+        result = parse_tool_calls(text)
77
+        assert len(result.tool_calls) == 1
78
+        assert result.tool_calls[0].arguments == {}
79
+
80
+    def test_parse_parameters_alias(self):
81
+        """Test that 'parameters' is accepted as alias for 'arguments'."""
82
+        text = '''<tool_call>
83
+{"name": "read", "parameters": {"file_path": "/tmp/test.txt"}}
84
+</tool_call>'''
85
+        result = parse_tool_calls(text)
86
+        assert len(result.tool_calls) == 1
87
+        assert result.tool_calls[0].name == "read"
88
+        assert result.tool_calls[0].arguments == {"file_path": "/tmp/test.txt"}
89
+
90
+    def test_parse_malformed_closing_tag(self):
91
+        """Test handling of malformed </tool_call> at start."""
92
+        text = '''</tool_call> {"name": "read", "parameters": {"file_path": "test.txt"}}
93
+</tool_call>'''
94
+        result = parse_tool_calls(text)
95
+        # Should clean up the malformed tags
96
+        assert "</tool_call>" not in result.content
97
+
98
+    def test_parse_cleans_orphaned_tags(self):
99
+        """Test that orphaned tool_call tags are removed from content."""
100
+        text = '''Some text </tool_call> more text <tool_call> end'''
101
+        result = parse_tool_calls(text)
102
+        assert "<tool_call>" not in result.content
103
+        assert "</tool_call>" not in result.content
104
+
105
+
106
+class TestFormatToolResult:
107
+    """Tests for format_tool_result function."""
108
+
109
+    def test_format_success(self):
110
+        result = format_tool_result("read", "file contents here")
111
+        assert "Observation" in result
112
+        assert "read" in result
113
+        assert "Result" in result
114
+        assert "file contents here" in result
115
+
116
+    def test_format_error(self):
117
+        result = format_tool_result("write", "Permission denied", is_error=True)
118
+        assert "Observation" in result
119
+        assert "write" in result
120
+        assert "Error" in result
121
+        assert "Permission denied" in result
tests/test_tools.pyadded
@@ -0,0 +1,240 @@
1
+"""Tests for tool implementations."""
2
+
3
+import pytest
4
+from loader.tools import (
5
+    ReadTool, WriteTool, EditTool, GlobTool,
6
+    BashTool, GrepTool, ConfirmationRequired,
7
+)
8
+from loader.tools.base import ToolRegistry, create_default_registry
9
+
10
+
11
+class TestReadTool:
12
+    """Tests for ReadTool."""
13
+
14
+    @pytest.fixture
15
+    def tool(self):
16
+        return ReadTool()
17
+
18
+    @pytest.mark.asyncio
19
+    async def test_read_file(self, tool, sample_file):
20
+        result = await tool.execute(file_path=str(sample_file))
21
+        assert not result.is_error
22
+        assert "Line 1" in result.output
23
+        assert "Line 2" in result.output
24
+
25
+    @pytest.mark.asyncio
26
+    async def test_read_nonexistent(self, tool, temp_dir):
27
+        result = await tool.execute(file_path=str(temp_dir / "nonexistent.txt"))
28
+        assert result.is_error
29
+        assert "not found" in result.output.lower()
30
+
31
+    @pytest.mark.asyncio
32
+    async def test_read_with_offset(self, tool, sample_file):
33
+        result = await tool.execute(file_path=str(sample_file), offset=2, limit=1)
34
+        assert not result.is_error
35
+        assert "Line 2" in result.output
36
+        assert "Line 1" not in result.output
37
+
38
+    def test_is_not_destructive(self, tool):
39
+        assert not tool.is_destructive
40
+
41
+
42
+class TestWriteTool:
43
+    """Tests for WriteTool."""
44
+
45
+    @pytest.fixture
46
+    def tool(self):
47
+        return WriteTool()
48
+
49
+    @pytest.mark.asyncio
50
+    async def test_write_file(self, tool, temp_dir):
51
+        file_path = temp_dir / "new_file.txt"
52
+        result = await tool.execute(file_path=str(file_path), content="Hello, world!")
53
+        assert not result.is_error
54
+        assert file_path.exists()
55
+        assert file_path.read_text() == "Hello, world!"
56
+
57
+    @pytest.mark.asyncio
58
+    async def test_write_creates_parents(self, tool, temp_dir):
59
+        file_path = temp_dir / "subdir" / "deep" / "file.txt"
60
+        result = await tool.execute(file_path=str(file_path), content="nested")
61
+        assert not result.is_error
62
+        assert file_path.exists()
63
+
64
+    def test_is_destructive(self, tool):
65
+        assert tool.is_destructive
66
+
67
+    def test_requires_confirmation(self, tool):
68
+        with pytest.raises(ConfirmationRequired) as exc_info:
69
+            tool.check_confirmation(
70
+                skip_confirmation=False,
71
+                file_path="/tmp/test.txt",
72
+                content="test",
73
+            )
74
+        assert "Write to file" in exc_info.value.message
75
+
76
+    def test_skip_confirmation(self, tool):
77
+        # Should not raise
78
+        tool.check_confirmation(
79
+            skip_confirmation=True,
80
+            file_path="/tmp/test.txt",
81
+            content="test",
82
+        )
83
+
84
+
85
+class TestEditTool:
86
+    """Tests for EditTool."""
87
+
88
+    @pytest.fixture
89
+    def tool(self):
90
+        return EditTool()
91
+
92
+    @pytest.mark.asyncio
93
+    async def test_edit_file(self, tool, sample_file):
94
+        result = await tool.execute(
95
+            file_path=str(sample_file),
96
+            old_string="Line 2",
97
+            new_string="Modified Line 2",
98
+        )
99
+        assert not result.is_error
100
+        assert "Modified Line 2" in sample_file.read_text()
101
+
102
+    @pytest.mark.asyncio
103
+    async def test_edit_nonexistent(self, tool, temp_dir):
104
+        result = await tool.execute(
105
+            file_path=str(temp_dir / "nonexistent.txt"),
106
+            old_string="foo",
107
+            new_string="bar",
108
+        )
109
+        assert result.is_error
110
+
111
+    @pytest.mark.asyncio
112
+    async def test_edit_string_not_found(self, tool, sample_file):
113
+        result = await tool.execute(
114
+            file_path=str(sample_file),
115
+            old_string="Not in file",
116
+            new_string="replacement",
117
+        )
118
+        assert result.is_error
119
+        assert "not found" in result.output.lower()
120
+
121
+
122
+class TestGlobTool:
123
+    """Tests for GlobTool."""
124
+
125
+    @pytest.fixture
126
+    def tool(self):
127
+        return GlobTool()
128
+
129
+    @pytest.mark.asyncio
130
+    async def test_glob_finds_files(self, tool, temp_dir):
131
+        (temp_dir / "file1.py").write_text("# python")
132
+        (temp_dir / "file2.py").write_text("# python")
133
+        (temp_dir / "file3.txt").write_text("text")
134
+
135
+        result = await tool.execute(pattern="*.py", path=str(temp_dir))
136
+        assert not result.is_error
137
+        assert "file1.py" in result.output
138
+        assert "file2.py" in result.output
139
+        assert "file3.txt" not in result.output
140
+
141
+    @pytest.mark.asyncio
142
+    async def test_glob_no_matches(self, tool, temp_dir):
143
+        result = await tool.execute(pattern="*.xyz", path=str(temp_dir))
144
+        assert not result.is_error
145
+        assert "No files matching" in result.output
146
+
147
+
148
+class TestBashTool:
149
+    """Tests for BashTool."""
150
+
151
+    @pytest.fixture
152
+    def tool(self):
153
+        return BashTool()
154
+
155
+    @pytest.mark.asyncio
156
+    async def test_bash_simple_command(self, tool):
157
+        result = await tool.execute(command="echo 'hello world'")
158
+        assert not result.is_error
159
+        assert "hello world" in result.output
160
+
161
+    @pytest.mark.asyncio
162
+    async def test_bash_pwd(self, tool):
163
+        result = await tool.execute(command="pwd")
164
+        assert not result.is_error
165
+        assert "/" in result.output
166
+
167
+    @pytest.mark.asyncio
168
+    async def test_bash_failed_command(self, tool):
169
+        result = await tool.execute(command="exit 1")
170
+        assert result.is_error
171
+        assert "Exit code 1" in result.output
172
+
173
+    def test_is_destructive(self, tool):
174
+        assert tool.is_destructive
175
+
176
+    def test_safe_command_no_confirmation(self, tool):
177
+        # ls is safe
178
+        tool.check_confirmation(skip_confirmation=False, command="ls -la")
179
+        # git status is safe
180
+        tool.check_confirmation(skip_confirmation=False, command="git status")
181
+
182
+    def test_unsafe_command_requires_confirmation(self, tool):
183
+        with pytest.raises(ConfirmationRequired):
184
+            tool.check_confirmation(skip_confirmation=False, command="rm -rf /tmp/test")
185
+
186
+
187
+class TestGrepTool:
188
+    """Tests for GrepTool."""
189
+
190
+    @pytest.fixture
191
+    def tool(self):
192
+        return GrepTool()
193
+
194
+    @pytest.mark.asyncio
195
+    async def test_grep_finds_pattern(self, tool, sample_python_file):
196
+        result = await tool.execute(
197
+            pattern="def.*hello",
198
+            path=str(sample_python_file),
199
+        )
200
+        assert not result.is_error
201
+        assert "hello" in result.output
202
+
203
+    @pytest.mark.asyncio
204
+    async def test_grep_no_matches(self, tool, sample_file):
205
+        result = await tool.execute(
206
+            pattern="nonexistent_pattern",
207
+            path=str(sample_file),
208
+        )
209
+        assert not result.is_error
210
+        assert "No matches" in result.output
211
+
212
+
213
+class TestToolRegistry:
214
+    """Tests for ToolRegistry."""
215
+
216
+    def test_create_default_registry(self):
217
+        registry = create_default_registry()
218
+        assert registry.get("read") is not None
219
+        assert registry.get("write") is not None
220
+        assert registry.get("edit") is not None
221
+        assert registry.get("glob") is not None
222
+        assert registry.get("bash") is not None
223
+        assert registry.get("grep") is not None
224
+
225
+    def test_unknown_tool(self):
226
+        registry = create_default_registry()
227
+        assert registry.get("nonexistent") is None
228
+
229
+    @pytest.mark.asyncio
230
+    async def test_execute_unknown_tool(self):
231
+        registry = create_default_registry()
232
+        result = await registry.execute("nonexistent")
233
+        assert result.is_error
234
+        assert "Unknown tool" in result.output
235
+
236
+    def test_skip_confirmation_flag(self):
237
+        registry = create_default_registry()
238
+        assert not registry.skip_confirmation
239
+        registry.skip_confirmation = True
240
+        assert registry.skip_confirmation