|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import asyncio |
| 4 | +import contextlib |
4 | 5 | import shlex |
5 | 6 | import sys |
| 7 | +from types import SimpleNamespace |
6 | 8 |
|
7 | 9 | import pytest |
8 | 10 | from republic import ToolContext |
|
11 | 13 |
|
12 | 14 | import bub.builtin.tools as builtin_tools |
13 | 15 | from bub.builtin.shell_manager import ShellManager |
14 | | -from bub.builtin.tools import bash, bash_output, kill_bash |
| 16 | +from bub.builtin.tools import bash, bash_output, kill_bash, quit_tool |
15 | 17 |
|
16 | 18 |
|
17 | | -def _tool_context(tmp_path) -> ToolContext: |
18 | | - return ToolContext(tape="test-tape", run_id="test-run", state={"_runtime_workspace": str(tmp_path)}) |
| 19 | +def _tool_context(tmp_path, **state) -> ToolContext: |
| 20 | + return ToolContext(tape="test-tape", run_id="test-run", state={"_runtime_workspace": str(tmp_path), **state}) |
19 | 21 |
|
20 | 22 |
|
21 | 23 | def _python_shell(code: str) -> str: |
@@ -51,6 +53,26 @@ async def test_foreground_bash_releases_shell_when_command_fails(tmp_path, monke |
51 | 53 | assert manager._shells == {} |
52 | 54 |
|
53 | 55 |
|
| 56 | +@pytest.mark.asyncio |
| 57 | +async def test_foreground_bash_terminates_shell_when_cancelled(tmp_path, monkeypatch) -> None: |
| 58 | + manager = ShellManager() |
| 59 | + monkeypatch.setattr(builtin_tools, "shell_manager", manager) |
| 60 | + |
| 61 | + task = asyncio.create_task( |
| 62 | + bash.run( |
| 63 | + cmd=_python_shell("import time; time.sleep(10)"), |
| 64 | + context=_tool_context(tmp_path, session_id="session:target"), |
| 65 | + ) |
| 66 | + ) |
| 67 | + await asyncio.sleep(0.1) |
| 68 | + |
| 69 | + task.cancel() |
| 70 | + with contextlib.suppress(asyncio.CancelledError): |
| 71 | + await task |
| 72 | + |
| 73 | + assert manager._shells == {} |
| 74 | + |
| 75 | + |
54 | 76 | @pytest.mark.asyncio |
55 | 77 | async def test_bash_non_zero_exit_is_returned_as_tool_error(tmp_path) -> None: |
56 | 78 | command = _python_shell("import sys; print('boom'); sys.exit(7)") |
@@ -124,3 +146,46 @@ async def test_kill_bash_returns_status_when_process_already_finished(tmp_path) |
124 | 146 | result = await kill_bash.run(shell_id=shell_id) |
125 | 147 |
|
126 | 148 | assert result == f"id: {shell_id}\nstatus: exited\nexit_code: 0" |
| 149 | + |
| 150 | + |
| 151 | +@pytest.mark.asyncio |
| 152 | +async def test_quit_tool_terminates_background_shells_for_current_session(tmp_path, monkeypatch) -> None: |
| 153 | + manager = ShellManager() |
| 154 | + monkeypatch.setattr(builtin_tools, "shell_manager", manager) |
| 155 | + |
| 156 | + target_started = await bash.run( |
| 157 | + cmd=_python_shell("import time; time.sleep(10)"), |
| 158 | + background=True, |
| 159 | + context=_tool_context(tmp_path, session_id="session:target"), |
| 160 | + ) |
| 161 | + target_shell_id = target_started.removeprefix("started: ").strip() |
| 162 | + other_started = await bash.run( |
| 163 | + cmd=_python_shell("import time; time.sleep(10)"), |
| 164 | + background=True, |
| 165 | + context=_tool_context(tmp_path, session_id="session:other"), |
| 166 | + ) |
| 167 | + other_shell_id = other_started.removeprefix("started: ").strip() |
| 168 | + |
| 169 | + class FakeFramework: |
| 170 | + def __init__(self) -> None: |
| 171 | + self.quit_sessions: list[str] = [] |
| 172 | + |
| 173 | + async def quit_via_router(self, session_id: str) -> None: |
| 174 | + self.quit_sessions.append(session_id) |
| 175 | + |
| 176 | + framework = FakeFramework() |
| 177 | + context = _tool_context( |
| 178 | + tmp_path, |
| 179 | + session_id="session:target", |
| 180 | + _runtime_agent=SimpleNamespace(framework=framework), |
| 181 | + ) |
| 182 | + |
| 183 | + result = await quit_tool.run(context=context) |
| 184 | + |
| 185 | + assert result == "Session tasks stopped." |
| 186 | + assert framework.quit_sessions == ["session:target"] |
| 187 | + with pytest.raises(KeyError, match="unknown shell id"): |
| 188 | + await bash_output.run(shell_id=target_shell_id) |
| 189 | + assert manager.get(other_shell_id).returncode is None |
| 190 | + |
| 191 | + await kill_bash.run(shell_id=other_shell_id) |
0 commit comments