| 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) |