Python · 12318 bytes Raw Blame History
1 """Tests for the ReAct parsing module."""
2
3 import json
4
5 from loader.runtime.parsing import format_tool_result, parse_tool_calls
6
7
8 class TestParseToolCalls:
9 """Tests for parse_tool_calls function."""
10
11 def test_parse_tool_call_xml_style(self):
12 text = '''I need to read the file.
13 <tool_call>
14 {"name": "read", "arguments": {"file_path": "/tmp/test.txt"}}
15 </tool_call>
16 '''
17 result = parse_tool_calls(text)
18 assert len(result.tool_calls) == 1
19 assert result.tool_calls[0].name == "read"
20 assert result.tool_calls[0].arguments == {"file_path": "/tmp/test.txt"}
21 assert not result.is_final_answer
22
23 def test_parse_multiple_tool_calls(self):
24 text = '''<tool_call>
25 {"name": "read", "arguments": {"file_path": "a.txt"}}
26 </tool_call>
27 <tool_call>
28 {"name": "read", "arguments": {"file_path": "b.txt"}}
29 </tool_call>'''
30 result = parse_tool_calls(text)
31 assert len(result.tool_calls) == 2
32 assert result.tool_calls[0].arguments["file_path"] == "a.txt"
33 assert result.tool_calls[1].arguments["file_path"] == "b.txt"
34
35 def test_parse_final_answer(self):
36 text = '''Thought: I have all the information needed.
37 Final Answer: The file contains a hello world program.'''
38 result = parse_tool_calls(text)
39 assert result.is_final_answer
40 assert len(result.tool_calls) == 0
41 assert "hello world" in result.content.lower()
42
43 def test_parse_no_tool_calls(self):
44 text = "Just some regular text without any tool calls."
45 result = parse_tool_calls(text)
46 assert len(result.tool_calls) == 0
47 assert not result.is_final_answer
48 assert "regular text" in result.content
49
50 def test_parse_bare_json(self):
51 text = '''Let me read that file.
52 {"name": "read", "arguments": {"file_path": "/test.txt"}}'''
53 result = parse_tool_calls(text)
54 assert len(result.tool_calls) == 1
55 assert result.tool_calls[0].name == "read"
56
57 def test_parse_bare_json_todowrite_with_nested_items(self):
58 text = json.dumps(
59 {
60 "name": "TodoWrite",
61 "arguments": {
62 "todos": [
63 {
64 "content": "Run tests",
65 "active_form": "Running tests",
66 "status": "in_progress",
67 }
68 ]
69 },
70 }
71 )
72 result = parse_tool_calls(text)
73 assert len(result.tool_calls) == 1
74 assert result.tool_calls[0].name == "TodoWrite"
75 assert result.tool_calls[0].arguments["todos"][0]["content"] == "Run tests"
76
77 def test_parse_bare_json_patch_with_nested_hunks(self):
78 text = json.dumps(
79 {
80 "name": "patch",
81 "arguments": {
82 "file_path": "sample.txt",
83 "hunks": [
84 {
85 "old_start": 2,
86 "old_lines": 1,
87 "new_start": 2,
88 "new_lines": 1,
89 "lines": ["-beta", "+beta updated"],
90 }
91 ],
92 },
93 }
94 )
95 result = parse_tool_calls(text)
96 assert len(result.tool_calls) == 1
97 assert result.tool_calls[0].name == "patch"
98 assert result.tool_calls[0].arguments["hunks"][0]["lines"] == [
99 "-beta",
100 "+beta updated",
101 ]
102
103 def test_parse_bare_json_ask_user_question_with_option_objects(self):
104 text = json.dumps(
105 {
106 "name": "AskUserQuestion",
107 "arguments": {
108 "question": "Which path should we take?",
109 "options": [
110 {
111 "label": "Plan first",
112 "description": "Write the plan before changing code.",
113 },
114 {
115 "label": "Execute now",
116 "description": "Start implementing immediately.",
117 },
118 ],
119 },
120 }
121 )
122 result = parse_tool_calls(text)
123 assert len(result.tool_calls) == 1
124 assert result.tool_calls[0].name == "AskUserQuestion"
125 assert result.tool_calls[0].arguments["options"][1]["label"] == "Execute now"
126
127 def test_parse_removes_react_labels(self):
128 text = '''Thought: I need to check this.
129 Action: <tool_call>
130 {"name": "read", "arguments": {"file_path": "test.txt"}}
131 </tool_call>'''
132 result = parse_tool_calls(text)
133 assert "Thought:" not in result.content
134 assert "Action:" not in result.content
135
136 def test_parse_invalid_json_ignored(self):
137 text = '''<tool_call>
138 {invalid json here}
139 </tool_call>'''
140 result = parse_tool_calls(text)
141 assert len(result.tool_calls) == 0
142
143 def test_parse_empty_arguments(self):
144 text = '''<tool_call>
145 {"name": "pwd", "arguments": {}}
146 </tool_call>'''
147 result = parse_tool_calls(text)
148 assert len(result.tool_calls) == 1
149 assert result.tool_calls[0].arguments == {}
150
151 def test_parse_parameters_alias(self):
152 """Test that 'parameters' is accepted as alias for 'arguments'."""
153 text = '''<tool_call>
154 {"name": "read", "parameters": {"file_path": "/tmp/test.txt"}}
155 </tool_call>'''
156 result = parse_tool_calls(text)
157 assert len(result.tool_calls) == 1
158 assert result.tool_calls[0].name == "read"
159 assert result.tool_calls[0].arguments == {"file_path": "/tmp/test.txt"}
160
161 def test_parse_malformed_closing_tag(self):
162 """Test handling of malformed </tool_call> at start."""
163 text = '''</tool_call> {"name": "read", "parameters": {"file_path": "test.txt"}}
164 </tool_call>'''
165 result = parse_tool_calls(text)
166 # Should clean up the malformed tags
167 assert "</tool_call>" not in result.content
168
169 def test_parse_cleans_orphaned_tags(self):
170 """Test that orphaned tool_call tags are removed from content."""
171 text = '''Some text </tool_call> more text <tool_call> end'''
172 result = parse_tool_calls(text)
173 assert "<tool_call>" not in result.content
174 assert "</tool_call>" not in result.content
175
176 def test_parse_bracketed_calls_format(self):
177 """Test parsing [calls tool with: key=value] format."""
178 text = '''I'll create the file now.
179 [calls write tool with: file_path=/tmp/test.txt, content="hello world"]
180 Created the file.'''
181 result = parse_tool_calls(text)
182 assert len(result.tool_calls) == 1
183 assert result.tool_calls[0].name == "write"
184 assert result.tool_calls[0].arguments["file_path"] == "/tmp/test.txt"
185 assert result.tool_calls[0].arguments["content"] == "hello world"
186 # Bracketed call should be removed from content
187 assert "[calls" not in result.content
188
189 def test_parse_bracketed_use_format(self):
190 """Test parsing [USE tool: key=value] format."""
191 text = '[USE bash tool: command="ls -la"]'
192 result = parse_tool_calls(text)
193 assert len(result.tool_calls) == 1
194 assert result.tool_calls[0].name == "bash"
195 assert result.tool_calls[0].arguments["command"] == "ls -la"
196
197 def test_parse_bracketed_edit_format(self):
198 """Test parsing bracketed format with edit tool."""
199 text = '[calls edit tool with: file_path="test.py", old_string="foo", new_string="bar"]'
200 result = parse_tool_calls(text)
201 assert len(result.tool_calls) == 1
202 assert result.tool_calls[0].name == "edit"
203 assert result.tool_calls[0].arguments["file_path"] == "test.py"
204 assert result.tool_calls[0].arguments["old_string"] == "foo"
205 assert result.tool_calls[0].arguments["new_string"] == "bar"
206
207 def test_parse_bracketed_mixed_case_tool_uses_allowed_name(self):
208 text = '[calls askuserquestion tool with: question="Which path should we take?"]'
209 result = parse_tool_calls(
210 text,
211 allowed_tool_names=["AskUserQuestion", "TodoWrite", "read"],
212 )
213 assert len(result.tool_calls) == 1
214 assert result.tool_calls[0].name == "AskUserQuestion"
215 assert result.tool_calls[0].arguments == {
216 "question": "Which path should we take?"
217 }
218
219 def test_parse_bare_json_filters_unknown_tool_when_allowed_names_provided(self):
220 text = '{"name": "TotallyUnknownTool", "arguments": {"question": "ignored"}}'
221 result = parse_tool_calls(
222 text,
223 allowed_tool_names=["AskUserQuestion", "TodoWrite", "read"],
224 )
225 assert result.tool_calls == []
226 assert "TotallyUnknownTool" in result.content
227
228 def test_parse_bare_json_maps_read_file_alias_to_read(self):
229 text = '{"name": "read_file", "arguments": {"file_path": "/tmp/test.txt"}}'
230 result = parse_tool_calls(
231 text,
232 allowed_tool_names=["read", "write", "patch"],
233 )
234 assert len(result.tool_calls) == 1
235 assert result.tool_calls[0].name == "read"
236 assert result.tool_calls[0].arguments == {"file_path": "/tmp/test.txt"}
237
238 def test_parse_fenced_read_command_into_tool_call(self):
239 text = "Let me inspect the file first.\n```bash\nread /tmp/test.txt\n```"
240 result = parse_tool_calls(
241 text,
242 allowed_tool_names=["read", "glob", "bash"],
243 )
244 assert len(result.tool_calls) == 1
245 assert result.tool_calls[0].name == "read"
246 assert result.tool_calls[0].arguments == {"file_path": "/tmp/test.txt"}
247
248 def test_parse_fenced_glob_command_into_tool_call(self):
249 text = "```bash\nglob /tmp/guide/chapters/*.html\n```"
250 result = parse_tool_calls(
251 text,
252 allowed_tool_names=["read", "glob", "bash"],
253 )
254 assert len(result.tool_calls) == 1
255 assert result.tool_calls[0].name == "glob"
256 assert result.tool_calls[0].arguments == {
257 "pattern": "/tmp/guide/chapters/*.html"
258 }
259
260
261 class TestFormatToolResult:
262 """Tests for format_tool_result function."""
263
264 def test_format_success(self):
265 result = format_tool_result("read", "file contents here")
266 assert "Observation" in result
267 assert "read" in result
268 assert "Result" in result
269 assert "file contents here" in result
270
271 def test_format_error(self):
272 result = format_tool_result("write", "Permission denied", is_error=True)
273 assert "Observation" in result
274 assert "write" in result
275 assert "Error" in result
276 assert "Permission denied" in result
277
278 def test_format_todowrite_compacts_payload(self):
279 result = format_tool_result(
280 "TodoWrite",
281 json.dumps(
282 {
283 "old_todos": [
284 {
285 "content": "Create index.html",
286 "active_form": "Creating index.html",
287 "status": "completed",
288 }
289 ],
290 "new_todos": [
291 {
292 "content": "Create index.html",
293 "active_form": "Creating index.html",
294 "status": "completed",
295 },
296 {
297 "content": "Create installation chapter (02-installation.html)",
298 "active_form": "Creating installation chapter",
299 "status": "pending",
300 },
301 ],
302 "verification_nudge_needed": False,
303 "store_path": "/tmp/.loader/todos/active.json",
304 }
305 ),
306 )
307 assert "Observation [TodoWrite]: Result: updated todo list" in result
308 assert "1 completed" in result
309 assert "1 pending" in result
310 assert "next pending: Create installation chapter (02-installation.html)" in result
311 assert "old_todos" not in result
312 assert "new_todos" not in result