From e5ae75e3c511f78376c1d98c744c8accb9c331ea Mon Sep 17 00:00:00 2001 From: Farrel Mahaztra <15523645+farrelmahaztra@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:41:48 +0700 Subject: [PATCH 1/2] Make bash timeout configurable --- hud/tools/coding/bash.py | 30 ++++++++++++------ hud/tools/coding/tests/test_bash.py | 33 ++++++++------------ hud/tools/coding/tests/test_bash_extended.py | 30 ++++++++++++++++-- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/hud/tools/coding/bash.py b/hud/tools/coding/bash.py index de446f1bc..c02da333d 100644 --- a/hud/tools/coding/bash.py +++ b/hud/tools/coding/bash.py @@ -26,7 +26,7 @@ class ClaudeBashSession: """A persistent bash shell session for Claude's bash tool. Uses readuntil-based output capture, which is simpler than ShellTool's - polling approach but doesn't support dynamic timeouts. + polling approach. """ _started: bool @@ -34,12 +34,14 @@ class ClaudeBashSession: _timed_out: bool command: str = "/bin/bash" - _timeout: float = 120.0 # seconds _sentinel: str = "<>" - def __init__(self) -> None: + DEFAULT_TIMEOUT: float = 120.0 # seconds + + def __init__(self, timeout: float = DEFAULT_TIMEOUT) -> None: self._started = False self._timed_out = False + self._timeout = timeout async def start(self) -> None: """Start the bash session.""" @@ -77,7 +79,9 @@ async def run(self, command: str) -> ContentResult: ) if self._timed_out: raise ToolError( - f"timed out: bash did not return in {self._timeout} seconds and must be restarted", + f"Bash session timed out waiting for output after {self._timeout}s. " + "Background processes may still be running. " + "Use restart=true to get a new session.", ) from None if self._process.stdin is None: @@ -113,7 +117,9 @@ async def run(self, command: str) -> ContentResult: except (TimeoutError, asyncio.LimitOverrunError): self._timed_out = True raise ToolError( - f"timed out: bash did not return in {self._timeout} seconds and must be restarted", + f"Bash session timed out waiting for output after {self._timeout}s. " + "Background processes may still be running. " + "Use restart=true to get a new session.", ) from None # Attempt non-blocking stderr fetch (may return empty) @@ -158,12 +164,18 @@ class BashTool(BaseTool): ), } - def __init__(self, session: ClaudeBashSession | None = None) -> None: + def __init__( + self, + session: ClaudeBashSession | None = None, + timeout: float = ClaudeBashSession.DEFAULT_TIMEOUT, + ) -> None: """Initialize BashTool with an optional session. Args: session: Optional pre-configured bash session. If not provided, a new session will be created on first use. + timeout: Timeout in seconds for command execution. Defaults to 120s. + Ignored if a pre-configured session is provided. """ super().__init__( env=session, @@ -171,6 +183,7 @@ def __init__(self, session: ClaudeBashSession | None = None) -> None: title="Bash Shell", description="Execute bash commands in a persistent shell session", ) + self._timeout = timeout @property def session(self) -> ClaudeBashSession | None: @@ -195,15 +208,14 @@ async def __call__( List of MCP ContentBlocks with the result """ if restart: - session_cls = type(self.session) if self.session else ClaudeBashSession if self.session: self.session.stop() - self.session = session_cls() + self.session = ClaudeBashSession(timeout=self._timeout) await self.session.start() return ContentResult(output="Bash session restarted.").to_content_blocks() if self.session is None: - self.session = ClaudeBashSession() + self.session = ClaudeBashSession(timeout=self._timeout) if not self.session._started: await self.session.start() diff --git a/hud/tools/coding/tests/test_bash.py b/hud/tools/coding/tests/test_bash.py index befd29db5..25306acb6 100644 --- a/hud/tools/coding/tests/test_bash.py +++ b/hud/tools/coding/tests/test_bash.py @@ -211,30 +211,23 @@ async def test_call_restart_with_existing_session(self): """Test restarting the tool when there's an existing session calls stop().""" tool = BashTool() - # Track calls across instances of our fake session class - stop_called = [] - start_called = [] - - class FakeSession: - """Fake session that tracks stop/start calls.""" - - async def start(self) -> None: - start_called.append(True) - - def stop(self) -> None: - stop_called.append(True) - - # Set up existing session - old_session = FakeSession() + # Set up existing session with a mock + old_session = MagicMock() + old_session.stop = MagicMock() tool.session = old_session # type: ignore[assignment] - result = await tool(restart=True) + # Mock the new session that will be created + new_session = MagicMock() + new_session.start = AsyncMock() + + with patch("hud.tools.coding.bash.ClaudeBashSession", return_value=new_session): + result = await tool(restart=True) # Verify old session was stopped - assert len(stop_called) == 1, "stop() should be called on old session" + old_session.stop.assert_called_once() # Verify new session was started - assert len(start_called) == 1, "start() should be called on new session" + new_session.start.assert_called_once() # Verify result assert isinstance(result, list) @@ -242,9 +235,9 @@ def stop(self) -> None: assert isinstance(result[0], TextContent) assert result[0].text == "Bash session restarted." - # Verify new session is a FakeSession (type preserved) - assert isinstance(tool.session, FakeSession) + # Verify new session replaced the old one assert tool.session is not old_session + assert tool.session is new_session @pytest.mark.asyncio async def test_call_no_command_error(self): diff --git a/hud/tools/coding/tests/test_bash_extended.py b/hud/tools/coding/tests/test_bash_extended.py index 59bb5b3a6..e781446f5 100644 --- a/hud/tools/coding/tests/test_bash_extended.py +++ b/hud/tools/coding/tests/test_bash_extended.py @@ -136,8 +136,34 @@ async def test_session_run_with_asyncio_timeout(self): with pytest.raises(ToolError) as exc_info: await session.run("slow command") - assert "timed out" in str(exc_info.value) - assert "120.0 seconds" in str(exc_info.value) + assert "timed out waiting for output" in str(exc_info.value) + assert "120.0s" in str(exc_info.value) + assert "Background processes may still be running" in str(exc_info.value) + assert "restart=true" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_session_run_with_custom_timeout(self): + """Test that a custom timeout value is used and reported in the error.""" + session = _BashSession(timeout=1.0) + assert session._timeout == 1.0 + + session._started = True + + mock_process = MagicMock() + mock_process.returncode = None + mock_process.stdin = MagicMock() + mock_process.stdin.write = MagicMock() + mock_process.stdin.drain = AsyncMock() + mock_process.stdout = MagicMock() + mock_process.stdout.readuntil = AsyncMock(side_effect=TimeoutError()) + + session._process = mock_process + + with pytest.raises(ToolError) as exc_info: + await session.run("sleep 5") + + assert "1.0s" in str(exc_info.value) + assert "120" not in str(exc_info.value) @pytest.mark.asyncio async def test_session_run_with_stdout_exception(self): From 73875968233d618fc747524b9b4703b06cbf24ba Mon Sep 17 00:00:00 2001 From: Farrel Mahaztra <15523645+farrelmahaztra@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:45:51 +0700 Subject: [PATCH 2/2] Derive timeout from existing session --- hud/tools/coding/bash.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hud/tools/coding/bash.py b/hud/tools/coding/bash.py index c02da333d..2ca6a7334 100644 --- a/hud/tools/coding/bash.py +++ b/hud/tools/coding/bash.py @@ -175,7 +175,8 @@ def __init__( session: Optional pre-configured bash session. If not provided, a new session will be created on first use. timeout: Timeout in seconds for command execution. Defaults to 120s. - Ignored if a pre-configured session is provided. + If a pre-configured session is provided, the timeout is + derived from that session instead. """ super().__init__( env=session, @@ -183,7 +184,7 @@ def __init__( title="Bash Shell", description="Execute bash commands in a persistent shell session", ) - self._timeout = timeout + self._timeout = session._timeout if session is not None else timeout @property def session(self) -> ClaudeBashSession | None: