diff --git a/nanobot/agent/tools/anthropic/bash.py b/nanobot/agent/tools/anthropic/bash.py index 1bc2cb1..df469f7 100644 --- a/nanobot/agent/tools/anthropic/bash.py +++ b/nanobot/agent/tools/anthropic/bash.py @@ -71,9 +71,10 @@ class _BashSession: assert self._process.stdout assert self._process.stderr - # Send command + sentinel + # Send command + sentinel on its own line so heredoc terminators + # aren't corrupted (EOF; echo '...' ≠ EOF) self._process.stdin.write( - command.encode() + f"; echo '{self._sentinel}'\n".encode() + command.encode() + f"\necho '{self._sentinel}'\n".encode() ) await self._process.stdin.drain() diff --git a/tests/test_bash_heredoc.py b/tests/test_bash_heredoc.py new file mode 100644 index 0000000..1d4a658 --- /dev/null +++ b/tests/test_bash_heredoc.py @@ -0,0 +1,68 @@ +"""Test that bash tool handles heredoc commands correctly. + +Reproduces the bug where `; echo '<>'` appended on the same line +as a heredoc terminator prevents bash from recognizing the terminator, +causing the session to hang forever. +""" + +import asyncio +import pytest +from nanobot.agent.tools.anthropic.bash import BashTool20250124 + + +@pytest.mark.asyncio +async def test_heredoc_command(): + """Heredoc commands must complete without hanging.""" + tool = BashTool20250124() + + # Simple command works + result = await tool(command="echo hello") + assert result.output == "hello" + + # Heredoc command — this is the exact pattern that caused the hang + result = await asyncio.wait_for( + tool(command="cat << 'EOF'\nline1\nline2\nEOF"), + timeout=5.0, + ) + assert "line1" in result.output + assert "line2" in result.output + + +@pytest.mark.asyncio +async def test_heredoc_append_to_file(): + """Heredoc append (the exact pattern the LLM uses) must work.""" + tool = BashTool20250124() + + result = await asyncio.wait_for( + tool(command="cat >> /tmp/test_heredoc_bash.txt << 'EOF'\nhello world\nEOF"), + timeout=5.0, + ) + # Should complete without error + assert result.error is None or result.error == "" + + # Verify the file was written + result2 = await tool(command="cat /tmp/test_heredoc_bash.txt") + assert "hello world" in result2.output + + # Cleanup + await tool(command="rm -f /tmp/test_heredoc_bash.txt") + + +@pytest.mark.asyncio +async def test_regular_commands_still_work(): + """Ensure regular commands still work after the fix.""" + tool = BashTool20250124() + + # Semicolons in commands + result = await tool(command="echo a; echo b") + assert "a" in result.output + assert "b" in result.output + + # Multiline script + result = await tool(command="for i in 1 2 3; do echo $i; done") + assert "1" in result.output + assert "3" in result.output + + # Command with exit code + result = await tool(command="true") + assert result.output == "(no output)" or result.output is not None diff --git a/tests/test_bash_tool.py b/tests/test_bash_tool.py index 64baeea..ebfe159 100644 --- a/tests/test_bash_tool.py +++ b/tests/test_bash_tool.py @@ -40,7 +40,7 @@ async def test_bash_tool_restart(): # Restart result = await tool(restart=True) - assert "restarted" in result.output.lower() + assert "restarted" in (result.system or result.output or "").lower() # Variable should be gone result2 = await tool(command="echo $TEST_VAR")