Python · 20765 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_build_file_mutation_preview_accepts_python_literal_hunks() -> None:
292 tool_args = _replacement_block_patch_tool_args()
293 tool_args["hunks"] = repr(tool_args["hunks"])
294
295 preview = build_file_mutation_preview("patch", tool_args=tool_args)
296
297 assert preview is not None
298 assert preview.structured_patch[0].old_start == 42
299 assert preview.structured_patch[0].new_lines == 5
300
301
302 def test_cli_print_tool_call_renders_bash_panel_without_truncating(monkeypatch: pytest.MonkeyPatch) -> None:
303 console = Console(record=True, width=120)
304 monkeypatch.setattr(cli_main_module, "console", console)
305
306 command = "python -m http.server 8000 --directory /tmp/preview-pages"
307 cli_main_module._print_tool_call("bash", {"command": command})
308
309 rendered = console.export_text(styles=False)
310 assert "Bash" in rendered
311 assert "Command" in rendered
312 assert command in rendered
313 assert "command=" not in rendered
314
315
316 def test_tool_call_widget_renders_patch_preview_instead_of_raw_tool_call() -> None:
317 widget = ToolCallWidget("patch", _patch_tool_args())
318
319 header = widget._header_renderable().plain
320 rendered = _render_text(widget._build_initial_summary(), width=120)
321
322 assert "Patch" in header
323 assert 'file_path="~/Loader/animals/index.html"' in header
324 assert "Preview" in rendered
325 assert "Patch(index.html)" in rendered
326 assert "<a href=\"cat.html\">Learn about Big Cats</a>" in rendered
327 assert "hunks=1 hunk" not in rendered
328
329
330 def test_cli_print_tool_call_renders_patch_preview(monkeypatch: pytest.MonkeyPatch) -> None:
331 console = Console(record=True, width=120)
332 monkeypatch.setattr(cli_main_module, "console", console)
333
334 cli_main_module._print_tool_call("patch", _patch_tool_args())
335
336 rendered = console.export_text(styles=False)
337 assert "Patch" in rendered
338 assert "Preview" in rendered
339 assert "Patch(index.html)" in rendered
340 assert "<a href=\"cat.html\">Learn about Big Cats</a>" in rendered
341 assert "hunks=1 hunk" not in rendered
342
343
344 def test_cli_print_tool_result_renders_edit_diff_from_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
345 console = Console(record=True, width=120)
346 monkeypatch.setattr(cli_main_module, "console", console)
347
348 cli_main_module._print_tool_result(
349 "edit",
350 "Successfully edited index.html",
351 metadata={
352 "file_path": "/tmp/index.html",
353 "original_file": "<p>Old</p>\n",
354 "new_string": "<p>New</p>\n",
355 "structured_patch": [
356 {
357 "old_start": 1,
358 "old_lines": 1,
359 "new_start": 1,
360 "new_lines": 1,
361 "lines": [
362 "-<p>Old</p>",
363 "+<p>New</p>",
364 ],
365 }
366 ],
367 },
368 )
369
370 rendered = console.export_text(styles=False)
371 assert "Edit" in rendered
372 assert "Diff" in rendered
373 assert "+ <p>New</p>" in rendered
374 assert "- <p>Old</p>" in rendered
375
376
377 @pytest.mark.asyncio
378 async def test_approval_bar_renders_file_mutation_preview() -> None:
379 app = _ApprovalHost()
380 preview = build_file_mutation_preview_dict("patch", tool_args=_patch_tool_args())
381 assert preview is not None
382
383 async with app.run_test() as pilot:
384 bar = app.query_one(ApprovalBar)
385 bar.show_approval(
386 "patch",
387 "Patch file: ~/Loader/animals/index.html",
388 "apply structured patch hunks",
389 preview=preview,
390 )
391 await pilot.pause()
392
393 content = bar.query_one("#approval-content", Static)
394 rendered = _render_text(content.content, width=120)
395
396 assert "Approve Patch" in rendered
397 assert "Preview" in rendered
398 assert "Patch(index.html)" in rendered
399
400
401 @pytest.mark.asyncio
402 async def test_approval_bar_fallback_handles_raw_patch_details() -> None:
403 app = _ApprovalHost()
404 raw_details = (
405 "patch(file_path=\"animals/index.html\", hunks="
406 f"{_raw_patch_tool_args()['hunks']})"
407 )
408
409 async with app.run_test() as pilot:
410 bar = app.query_one(ApprovalBar)
411 bar.show_approval(
412 "patch",
413 "Patch file: animals/index.html",
414 raw_details,
415 preview=None,
416 )
417 await pilot.pause()
418
419 content = bar.query_one("#approval-content", Static)
420 rendered = _render_text(content.content, width=120)
421
422 assert "Approve Patch" in rendered
423 assert "Details" in rendered
424 assert "wolf.html" in rendered
425
426
427 @pytest.mark.asyncio
428 async def test_loader_app_replaces_patch_tool_widget_with_diff_widget() -> None:
429 tool_args = _patch_tool_args()
430 preview = build_file_mutation_preview_dict("patch", tool_args=tool_args)
431 assert preview is not None
432
433 app = LoaderApp(shell_owner=_FakeShellOwner())
434 async with app.run_test() as pilot:
435 app.post_message(
436 ToolCallStarted(
437 tool_name="patch",
438 tool_args=tool_args,
439 tool_call_id="patch-call-1",
440 phase="assistant",
441 )
442 )
443 await pilot.pause()
444 assert len(list(app.query(ToolCallWidget))) == 1
445
446 app.post_message(
447 ToolCallCompleted(
448 tool_name="patch",
449 content="Successfully patched ~/Loader/animals/index.html",
450 is_error=False,
451 phase="assistant",
452 tool_call_id="patch-call-1",
453 metadata={
454 "file_path": "~/Loader/animals/index.html",
455 "structured_patch": tool_args["hunks"],
456 },
457 mutation_preview=preview,
458 )
459 )
460 await pilot.pause()
461
462 assert len(list(app.query(DiffWidget))) == 1
463 assert len(list(app.query(ToolCallWidget))) == 0
464
465
466 @pytest.mark.asyncio
467 async def test_loader_app_mounts_raw_patch_preview_without_markup_crash() -> None:
468 app = LoaderApp(shell_owner=_FakeShellOwner())
469
470 async with app.run_test() as pilot:
471 app.post_message(
472 ToolCallStarted(
473 tool_name="patch",
474 tool_args=_raw_patch_tool_args(),
475 tool_call_id="patch-call-raw",
476 phase="assistant",
477 )
478 )
479 await pilot.pause()
480
481 widget = next(iter(app.query(ToolCallWidget)))
482 summary = widget.query_one("#tool-summary", Static)
483 rendered = _render_text(summary.content, width=120)
484
485 assert "Preview" in rendered
486 assert "<h2><a href=\"wolf.html\">Wolf</a></h2>" in rendered
487 assert "wolf.html" in rendered
488 assert "penguin.html" in rendered
489 assert "< /body>" not in rendered
490
491
492 @pytest.mark.asyncio
493 async def test_loader_app_mounts_replacement_block_patch_preview_without_crash() -> None:
494 app = LoaderApp(shell_owner=_FakeShellOwner())
495
496 async with app.run_test() as pilot:
497 app.post_message(
498 ToolCallStarted(
499 tool_name="patch",
500 tool_args=_replacement_block_patch_tool_args(),
501 tool_call_id="patch-call-replacement",
502 phase="assistant",
503 )
504 )
505 await pilot.pause()
506
507 widget = next(iter(app.query(ToolCallWidget)))
508 summary = widget.query_one("#tool-summary", Static)
509 rendered = _render_text(summary.content, width=120)
510
511 assert "Preview" in rendered
512 assert "<svg width=\"200\" height=\"100\"" in rendered
513 assert "Patch(index.html)" in rendered
514
515
516 @pytest.mark.asyncio
517 async def test_loader_app_matches_repeated_tool_results_by_tool_call_id() -> None:
518 app = LoaderApp(shell_owner=_FakeShellOwner())
519 async with app.run_test() as pilot:
520 app.post_message(
521 ToolCallStarted(
522 tool_name="read",
523 tool_args={"file_path": "/tmp/cats.html"},
524 tool_call_id="read-call-1",
525 phase="assistant",
526 )
527 )
528 app.post_message(
529 ToolCallStarted(
530 tool_name="read",
531 tool_args={"file_path": "/tmp/penguins.html"},
532 tool_call_id="read-call-2",
533 phase="assistant",
534 )
535 )
536 await pilot.pause()
537
538 app.post_message(
539 ToolCallCompleted(
540 tool_name="read",
541 tool_call_id="read-call-2",
542 content="<h1>Penguins</h1>",
543 is_error=False,
544 phase="assistant",
545 )
546 )
547 await pilot.pause()
548
549 widgets = list(app.query(ToolCallWidget))
550 first = next(widget for widget in widgets if widget.tool_call_id == "read-call-1")
551 second = next(widget for widget in widgets if widget.tool_call_id == "read-call-2")
552
553 assert first.state == "running"
554 assert second.state == "success"
555 assert "/tmp/cats.html" in first._header_renderable().plain
556 assert "/tmp/penguins.html" in second._header_renderable().plain
557
558
559 @pytest.mark.asyncio
560 async def test_loader_app_renders_plain_response_without_markup_parsing() -> None:
561 app = LoaderApp(shell_owner=_FakeShellOwner())
562
563 async with app.run_test() as pilot:
564 app.post_message(
565 ResponseComplete(
566 content=(
567 "patch(file_path=\"animals/index.html\", hunks="
568 f"{_raw_patch_tool_args()['hunks']})"
569 )
570 )
571 )
572 await pilot.pause()
573
574 message_area = app.query_one("#message-area")
575 last_widget = list(message_area.children)[-1]
576 rendered = _render_text(last_widget.render(), width=120)
577 assert "patch(file_path=" in rendered
578 assert "hunks=" in rendered
579
580
581 def test_cli_parse_local_bash_commands_supports_slash_aliases() -> None:
582 assert cli_main_module._parse_local_bash_command("/jobs 5") == ("bash_jobs", {"limit": 5})
583 assert cli_main_module._parse_local_bash_command("/wait bash-7 2.5") == (
584 "bash_wait",
585 {"job_id": "bash-7", "timeout": 2.5},
586 )
587 assert cli_main_module._parse_local_bash_command("kill bash-2 50") == (
588 "bash_kill",
589 {"job_id": "bash-2", "force_after_ms": 50},
590 )
591
592
593 @pytest.mark.asyncio
594 async def test_cli_interrupt_active_foreground_bash_prints_interrupted_result(
595 monkeypatch: pytest.MonkeyPatch,
596 ) -> None:
597 console = Console(record=True, width=120)
598 monkeypatch.setattr(cli_main_module, "console", console)
599
600 bash_tool = BashTool(timeout=10.0)
601 job = await bash_tool.manager.start(
602 command='python -c "import time; time.sleep(30)"',
603 cwd=None,
604 timeout=10.0,
605 background=False,
606 mutability="workspace-write",
607 )
608 owner = SimpleNamespace(
609 registry=SimpleNamespace(get=lambda name: bash_tool if name == "bash" else None)
610 )
611
612 try:
613 interrupted = await cli_main_module._interrupt_active_foreground_bash(owner)
614 assert interrupted is True
615 assert bash_tool.manager.active_foreground_job_id is None
616 rendered = console.export_text(styles=False)
617 assert "Status: interrupted" in rendered
618 finally:
619 if job.is_running:
620 await bash_tool.manager.kill_job(job.job_id, interrupted=True)