Python · 20369 bytes Raw Blame History
1 """Tests for bash job metadata and operator-facing surfaces."""
2
3 from __future__ import annotations
4
5 import json
6 from types import SimpleNamespace
7
8 import pytest
9 from rich.console import Console
10 from textual.app import App, ComposeResult
11 from textual.widgets import Static
12
13 import loader.cli.main as cli_main_module
14 from loader.runtime.events import AgentEvent
15 from loader.tools import BashTool
16 from loader.ui.adapter import (
17 EventAdapter,
18 ResponseComplete,
19 ToolCallCompleted,
20 ToolCallStarted,
21 )
22 from loader.ui.app import LoaderApp
23 from loader.ui.widgets import ApprovalBar, DiffWidget
24 from loader.ui.widgets.tool_widget import ToolCallWidget
25 from loader.utils.file_mutations import (
26 build_file_mutation_preview,
27 build_file_mutation_preview_dict,
28 render_file_mutation_preview,
29 )
30
31
32 class _FakeApp:
33 def __init__(self) -> None:
34 self.messages: list[object] = []
35
36 def post_message(self, message: object) -> None:
37 self.messages.append(message)
38
39
40 class _FakeShellOwner:
41 def __init__(self) -> None:
42 self.session = SimpleNamespace(runtime_owner_path="")
43 self.last_turn_summary = None
44 self.registry = SimpleNamespace(get=lambda name: None)
45 self.safeguards = SimpleNamespace(filter_stream_chunk=lambda chunk: chunk)
46
47
48 class _ApprovalHost(App[None]):
49 def compose(self) -> ComposeResult:
50 yield ApprovalBar(id="approval")
51
52
53 def _patch_tool_args() -> dict[str, object]:
54 return {
55 "file_path": "~/Loader/animals/index.html",
56 "hunks": [
57 {
58 "lines": [
59 '- <a href="cat.html">Learn about Cats</a>',
60 '+ <a href="cat.html">Learn about Big Cats</a>',
61 ' <a href="dog.html">Learn about Dogs</a>',
62 ],
63 "new_lines": 2,
64 "new_start": 18,
65 "old_lines": 2,
66 "old_start": 18,
67 }
68 ],
69 }
70
71
72 def _raw_patch_tool_args() -> dict[str, object]:
73 return {
74 "file_path": "animals/index.html",
75 "hunks": [
76 {
77 "old_start": 53,
78 "old_lines": 1,
79 "new_start": 53,
80 "new_lines": 5,
81 "lines": [
82 "</body>",
83 "</html>",
84 "",
85 "<!-- New animal entries -->",
86 '<div class="animal-card">',
87 '<h2><a href="wolf.html">Wolf</a></h2>',
88 (
89 "<p>Wolves are wild canines that live in packs and are known "
90 "for their intelligence and social behavior.</p>"
91 ),
92 "</div>",
93 "",
94 '<div class="animal-card">',
95 '<h2><a href="bear.html">Bear</a></h2>',
96 (
97 "<p>Bears are large mammals that are found in various parts "
98 "of the world, known for their strength and omnivorous diet.</p>"
99 ),
100 "</div>",
101 "",
102 '<div class="animal-card">',
103 '<h2><a href="penguin.html">Penguin</a></h2>',
104 (
105 "<p>Penguins are flightless birds that live in the Southern "
106 "Hemisphere, known for their distinctive waddle and swimming "
107 "abilities.</p>"
108 ),
109 "</div>",
110 ],
111 }
112 ],
113 }
114
115
116 def _replacement_block_patch_tool_args() -> dict[str, object]:
117 return {
118 "file_path": "~/Loader/animals/index.html",
119 "hunks": [
120 {
121 "old_start": 42,
122 "old_end": 56,
123 "new_lines": [
124 ' <svg width="200" height="100" viewBox="0 0 200 100" xmlns="http://www.w3.org/2000/svg">',
125 " <!-- Shell -->",
126 ' <ellipse cx="100" cy="50" rx="60" ry="30" fill="#228B22" stroke="#000" stroke-width="2"/>',
127 " <!-- Head -->",
128 " </svg>",
129 ],
130 }
131 ],
132 }
133
134
135 def _render_text(renderable, *, width: int = 100) -> str:
136 console = Console(record=True, width=width)
137 console.print(renderable)
138 return console.export_text(styles=False)
139
140
141 def test_event_adapter_preserves_tool_metadata_on_completion() -> None:
142 app = _FakeApp()
143 adapter = EventAdapter(app) # type: ignore[arg-type]
144 metadata = {"job_id": "bash-3", "status": "running", "background": True}
145
146 adapter.handle_event(
147 AgentEvent(
148 type="tool_call",
149 tool_name="bash",
150 tool_call_id="call-bash-3",
151 tool_args={"command": "python -m http.server 8000", "background": True},
152 phase="assistant",
153 )
154 )
155 adapter.handle_event(
156 AgentEvent(
157 type="tool_result",
158 tool_name="bash",
159 tool_call_id="call-bash-3",
160 content="Started bash job bash-3",
161 tool_metadata=metadata,
162 phase="assistant",
163 )
164 )
165
166 completed = next(message for message in app.messages if isinstance(message, ToolCallCompleted))
167 assert completed.tool_call_id == "call-bash-3"
168 assert completed.metadata == metadata
169
170
171 def test_event_adapter_adds_mutation_preview_for_patch_completion() -> None:
172 app = _FakeApp()
173 adapter = EventAdapter(app) # type: ignore[arg-type]
174 tool_args = _patch_tool_args()
175
176 adapter.handle_event(
177 AgentEvent(
178 type="tool_call",
179 tool_name="patch",
180 tool_call_id="call-patch-1",
181 tool_args=tool_args,
182 phase="assistant",
183 )
184 )
185 adapter.handle_event(
186 AgentEvent(
187 type="tool_result",
188 tool_name="patch",
189 tool_call_id="call-patch-1",
190 content="Successfully patched ~/Loader/animals/index.html",
191 tool_metadata={
192 "file_path": "~/Loader/animals/index.html",
193 "structured_patch": tool_args["hunks"],
194 },
195 phase="assistant",
196 )
197 )
198
199 completed = next(message for message in app.messages if isinstance(message, ToolCallCompleted))
200 assert completed.mutation_preview is not None
201 assert completed.mutation_preview["operation"] == "patch"
202 assert completed.mutation_preview["file_path"] == "~/Loader/animals/index.html"
203
204
205 def test_tool_call_widget_renders_full_bash_command_in_box() -> None:
206 command = "python -m http.server 8000 --directory /tmp/preview-pages"
207 widget = ToolCallWidget("bash", {"command": command})
208
209 header = widget._header_renderable().plain
210 rendered = _render_text(widget._build_initial_summary(), width=120)
211
212 assert "Bash" in header
213 assert "command=" not in header
214 assert "Command" in rendered
215 assert command in rendered
216
217
218 def test_build_file_mutation_preview_uses_structured_patch_metadata() -> None:
219 preview = build_file_mutation_preview(
220 "write",
221 metadata={
222 "kind": "update",
223 "file_path": "/tmp/animals/index.html",
224 "original_file": "<h1>Animals</h1>\n",
225 "content": "<h1>Animals</h1>\n<p>Updated</p>\n",
226 "structured_patch": [
227 {
228 "old_start": 1,
229 "old_lines": 1,
230 "new_start": 1,
231 "new_lines": 2,
232 "lines": [
233 " <h1>Animals</h1>",
234 "+<p>Updated</p>",
235 ],
236 }
237 ],
238 },
239 )
240
241 assert preview is not None
242 assert preview.operation == "update"
243 assert preview.added_lines == 1
244 assert preview.removed_lines == 0
245
246
247 def test_render_file_mutation_preview_truncates_large_diff() -> None:
248 preview = build_file_mutation_preview(
249 "write",
250 tool_args={
251 "file_path": "/tmp/generated.txt",
252 "content": "\n".join(f"line {idx}" for idx in range(120)),
253 },
254 )
255 assert preview is not None
256
257 rendered = _render_text(
258 render_file_mutation_preview(preview, max_lines=6, max_chars=1_000),
259 width=120,
260 )
261
262 assert "Create(generated.txt)" in rendered
263 assert "truncated for display" in rendered
264
265
266 def test_build_file_mutation_preview_accepts_replacement_block_hunks() -> None:
267 preview = build_file_mutation_preview(
268 "patch",
269 tool_args=_replacement_block_patch_tool_args(),
270 )
271
272 assert preview is not None
273 assert preview.file_path == "~/Loader/animals/index.html"
274 assert preview.structured_patch[0].old_start == 42
275 assert preview.structured_patch[0].old_lines == 15
276 assert preview.structured_patch[0].new_lines == 5
277 assert preview.structured_patch[0].lines[0].startswith("+ <svg")
278
279
280 def test_build_file_mutation_preview_accepts_json_encoded_hunks() -> None:
281 tool_args = _replacement_block_patch_tool_args()
282 tool_args["hunks"] = json.dumps(tool_args["hunks"])
283
284 preview = build_file_mutation_preview("patch", tool_args=tool_args)
285
286 assert preview is not None
287 assert preview.structured_patch[0].old_start == 42
288 assert preview.structured_patch[0].new_lines == 5
289
290
291 def test_cli_print_tool_call_renders_bash_panel_without_truncating(monkeypatch: pytest.MonkeyPatch) -> None:
292 console = Console(record=True, width=120)
293 monkeypatch.setattr(cli_main_module, "console", console)
294
295 command = "python -m http.server 8000 --directory /tmp/preview-pages"
296 cli_main_module._print_tool_call("bash", {"command": command})
297
298 rendered = console.export_text(styles=False)
299 assert "Bash" in rendered
300 assert "Command" in rendered
301 assert command in rendered
302 assert "command=" not in rendered
303
304
305 def test_tool_call_widget_renders_patch_preview_instead_of_raw_tool_call() -> None:
306 widget = ToolCallWidget("patch", _patch_tool_args())
307
308 header = widget._header_renderable().plain
309 rendered = _render_text(widget._build_initial_summary(), width=120)
310
311 assert "Patch" in header
312 assert 'file_path="~/Loader/animals/index.html"' in header
313 assert "Preview" in rendered
314 assert "Patch(index.html)" in rendered
315 assert "<a href=\"cat.html\">Learn about Big Cats</a>" in rendered
316 assert "hunks=1 hunk" not in rendered
317
318
319 def test_cli_print_tool_call_renders_patch_preview(monkeypatch: pytest.MonkeyPatch) -> None:
320 console = Console(record=True, width=120)
321 monkeypatch.setattr(cli_main_module, "console", console)
322
323 cli_main_module._print_tool_call("patch", _patch_tool_args())
324
325 rendered = console.export_text(styles=False)
326 assert "Patch" in rendered
327 assert "Preview" in rendered
328 assert "Patch(index.html)" in rendered
329 assert "<a href=\"cat.html\">Learn about Big Cats</a>" in rendered
330 assert "hunks=1 hunk" not in rendered
331
332
333 def test_cli_print_tool_result_renders_edit_diff_from_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
334 console = Console(record=True, width=120)
335 monkeypatch.setattr(cli_main_module, "console", console)
336
337 cli_main_module._print_tool_result(
338 "edit",
339 "Successfully edited index.html",
340 metadata={
341 "file_path": "/tmp/index.html",
342 "original_file": "<p>Old</p>\n",
343 "new_string": "<p>New</p>\n",
344 "structured_patch": [
345 {
346 "old_start": 1,
347 "old_lines": 1,
348 "new_start": 1,
349 "new_lines": 1,
350 "lines": [
351 "-<p>Old</p>",
352 "+<p>New</p>",
353 ],
354 }
355 ],
356 },
357 )
358
359 rendered = console.export_text(styles=False)
360 assert "Edit" in rendered
361 assert "Diff" in rendered
362 assert "+ <p>New</p>" in rendered
363 assert "- <p>Old</p>" in rendered
364
365
366 @pytest.mark.asyncio
367 async def test_approval_bar_renders_file_mutation_preview() -> None:
368 app = _ApprovalHost()
369 preview = build_file_mutation_preview_dict("patch", tool_args=_patch_tool_args())
370 assert preview is not None
371
372 async with app.run_test() as pilot:
373 bar = app.query_one(ApprovalBar)
374 bar.show_approval(
375 "patch",
376 "Patch file: ~/Loader/animals/index.html",
377 "apply structured patch hunks",
378 preview=preview,
379 )
380 await pilot.pause()
381
382 content = bar.query_one("#approval-content", Static)
383 rendered = _render_text(content.content, width=120)
384
385 assert "Approve Patch" in rendered
386 assert "Preview" in rendered
387 assert "Patch(index.html)" in rendered
388
389
390 @pytest.mark.asyncio
391 async def test_approval_bar_fallback_handles_raw_patch_details() -> None:
392 app = _ApprovalHost()
393 raw_details = (
394 "patch(file_path=\"animals/index.html\", hunks="
395 f"{_raw_patch_tool_args()['hunks']})"
396 )
397
398 async with app.run_test() as pilot:
399 bar = app.query_one(ApprovalBar)
400 bar.show_approval(
401 "patch",
402 "Patch file: animals/index.html",
403 raw_details,
404 preview=None,
405 )
406 await pilot.pause()
407
408 content = bar.query_one("#approval-content", Static)
409 rendered = _render_text(content.content, width=120)
410
411 assert "Approve Patch" in rendered
412 assert "Details" in rendered
413 assert "wolf.html" in rendered
414
415
416 @pytest.mark.asyncio
417 async def test_loader_app_replaces_patch_tool_widget_with_diff_widget() -> None:
418 tool_args = _patch_tool_args()
419 preview = build_file_mutation_preview_dict("patch", tool_args=tool_args)
420 assert preview is not None
421
422 app = LoaderApp(shell_owner=_FakeShellOwner())
423 async with app.run_test() as pilot:
424 app.post_message(
425 ToolCallStarted(
426 tool_name="patch",
427 tool_args=tool_args,
428 tool_call_id="patch-call-1",
429 phase="assistant",
430 )
431 )
432 await pilot.pause()
433 assert len(list(app.query(ToolCallWidget))) == 1
434
435 app.post_message(
436 ToolCallCompleted(
437 tool_name="patch",
438 content="Successfully patched ~/Loader/animals/index.html",
439 is_error=False,
440 phase="assistant",
441 tool_call_id="patch-call-1",
442 metadata={
443 "file_path": "~/Loader/animals/index.html",
444 "structured_patch": tool_args["hunks"],
445 },
446 mutation_preview=preview,
447 )
448 )
449 await pilot.pause()
450
451 assert len(list(app.query(DiffWidget))) == 1
452 assert len(list(app.query(ToolCallWidget))) == 0
453
454
455 @pytest.mark.asyncio
456 async def test_loader_app_mounts_raw_patch_preview_without_markup_crash() -> None:
457 app = LoaderApp(shell_owner=_FakeShellOwner())
458
459 async with app.run_test() as pilot:
460 app.post_message(
461 ToolCallStarted(
462 tool_name="patch",
463 tool_args=_raw_patch_tool_args(),
464 tool_call_id="patch-call-raw",
465 phase="assistant",
466 )
467 )
468 await pilot.pause()
469
470 widget = next(iter(app.query(ToolCallWidget)))
471 summary = widget.query_one("#tool-summary", Static)
472 rendered = _render_text(summary.content, width=120)
473
474 assert "Preview" in rendered
475 assert "<h2><a href=\"wolf.html\">Wolf</a></h2>" in rendered
476 assert "wolf.html" in rendered
477 assert "penguin.html" in rendered
478 assert "< /body>" not in rendered
479
480
481 @pytest.mark.asyncio
482 async def test_loader_app_mounts_replacement_block_patch_preview_without_crash() -> None:
483 app = LoaderApp(shell_owner=_FakeShellOwner())
484
485 async with app.run_test() as pilot:
486 app.post_message(
487 ToolCallStarted(
488 tool_name="patch",
489 tool_args=_replacement_block_patch_tool_args(),
490 tool_call_id="patch-call-replacement",
491 phase="assistant",
492 )
493 )
494 await pilot.pause()
495
496 widget = next(iter(app.query(ToolCallWidget)))
497 summary = widget.query_one("#tool-summary", Static)
498 rendered = _render_text(summary.content, width=120)
499
500 assert "Preview" in rendered
501 assert "<svg width=\"200\" height=\"100\"" in rendered
502 assert "Patch(index.html)" in rendered
503
504
505 @pytest.mark.asyncio
506 async def test_loader_app_matches_repeated_tool_results_by_tool_call_id() -> None:
507 app = LoaderApp(shell_owner=_FakeShellOwner())
508 async with app.run_test() as pilot:
509 app.post_message(
510 ToolCallStarted(
511 tool_name="read",
512 tool_args={"file_path": "/tmp/cats.html"},
513 tool_call_id="read-call-1",
514 phase="assistant",
515 )
516 )
517 app.post_message(
518 ToolCallStarted(
519 tool_name="read",
520 tool_args={"file_path": "/tmp/penguins.html"},
521 tool_call_id="read-call-2",
522 phase="assistant",
523 )
524 )
525 await pilot.pause()
526
527 app.post_message(
528 ToolCallCompleted(
529 tool_name="read",
530 tool_call_id="read-call-2",
531 content="<h1>Penguins</h1>",
532 is_error=False,
533 phase="assistant",
534 )
535 )
536 await pilot.pause()
537
538 widgets = list(app.query(ToolCallWidget))
539 first = next(widget for widget in widgets if widget.tool_call_id == "read-call-1")
540 second = next(widget for widget in widgets if widget.tool_call_id == "read-call-2")
541
542 assert first.state == "running"
543 assert second.state == "success"
544 assert "/tmp/cats.html" in first._header_renderable().plain
545 assert "/tmp/penguins.html" in second._header_renderable().plain
546
547
548 @pytest.mark.asyncio
549 async def test_loader_app_renders_plain_response_without_markup_parsing() -> None:
550 app = LoaderApp(shell_owner=_FakeShellOwner())
551
552 async with app.run_test() as pilot:
553 app.post_message(
554 ResponseComplete(
555 content=(
556 "patch(file_path=\"animals/index.html\", hunks="
557 f"{_raw_patch_tool_args()['hunks']})"
558 )
559 )
560 )
561 await pilot.pause()
562
563 message_area = app.query_one("#message-area")
564 last_widget = list(message_area.children)[-1]
565 rendered = _render_text(last_widget.render(), width=120)
566 assert "patch(file_path=" in rendered
567 assert "hunks=" in rendered
568
569
570 def test_cli_parse_local_bash_commands_supports_slash_aliases() -> None:
571 assert cli_main_module._parse_local_bash_command("/jobs 5") == ("bash_jobs", {"limit": 5})
572 assert cli_main_module._parse_local_bash_command("/wait bash-7 2.5") == (
573 "bash_wait",
574 {"job_id": "bash-7", "timeout": 2.5},
575 )
576 assert cli_main_module._parse_local_bash_command("kill bash-2 50") == (
577 "bash_kill",
578 {"job_id": "bash-2", "force_after_ms": 50},
579 )
580
581
582 @pytest.mark.asyncio
583 async def test_cli_interrupt_active_foreground_bash_prints_interrupted_result(
584 monkeypatch: pytest.MonkeyPatch,
585 ) -> None:
586 console = Console(record=True, width=120)
587 monkeypatch.setattr(cli_main_module, "console", console)
588
589 bash_tool = BashTool(timeout=10.0)
590 job = await bash_tool.manager.start(
591 command='python -c "import time; time.sleep(30)"',
592 cwd=None,
593 timeout=10.0,
594 background=False,
595 mutability="workspace-write",
596 )
597 owner = SimpleNamespace(
598 registry=SimpleNamespace(get=lambda name: bash_tool if name == "bash" else None)
599 )
600
601 try:
602 interrupted = await cli_main_module._interrupt_active_foreground_bash(owner)
603 assert interrupted is True
604 assert bash_tool.manager.active_foreground_job_id is None
605 rendered = console.export_text(styles=False)
606 assert "Status: interrupted" in rendered
607 finally:
608 if job.is_running:
609 await bash_tool.manager.kill_job(job.job_id, interrupted=True)