Add Phase 6: write tools, shell, and edit tools with reasoning-only fix
Implement 6 new agent tools — write_file, make_dir, delete_file, str_replace, patch_apply, run_command — bringing the agent from read-only observer to active code modifier. All write/shell operations are gated through the existing permissions service. Also fix a bug where qwen3.5 thinking mode produces reasoning tokens but no content after tool results, causing the agent to silently exit. The loop now detects reasoning-only responses, retries twice, then injects a nudge message to break the model out of its thinking loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
95
tests/unit/test_shell.py
Normal file
95
tests/unit/test_shell.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for the run_command shell tool (Phase 6)."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.config import AppConfig, load_config
|
||||
from app.models.tool_call import ToolResultStatus
|
||||
from app.tools.shell import RunCommandTool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config() -> AppConfig:
|
||||
return load_config()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_workspace(tmp_path: Path, config: AppConfig) -> tuple[Path, AppConfig]:
|
||||
"""Create a temporary workspace for shell tests."""
|
||||
config.agent.workspace_root = tmp_path
|
||||
return tmp_path, config
|
||||
|
||||
|
||||
class TestRunCommandTool:
|
||||
def test_allowed_command_runs(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
|
||||
ws, cfg = tmp_workspace
|
||||
tool = RunCommandTool(ws, cfg)
|
||||
result = tool.run("tc-1", {"command": "echo hello"})
|
||||
assert result.status == ToolResultStatus.SUCCESS
|
||||
assert "hello" in result.output
|
||||
|
||||
def test_denied_command_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
|
||||
ws, cfg = tmp_workspace
|
||||
tool = RunCommandTool(ws, cfg)
|
||||
result = tool.run("tc-2", {"command": "sudo rm -rf /"})
|
||||
assert result.status == ToolResultStatus.ERROR
|
||||
assert "denied" in (result.error or "").lower()
|
||||
|
||||
def test_disallowed_command_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
|
||||
ws, cfg = tmp_workspace
|
||||
tool = RunCommandTool(ws, cfg)
|
||||
result = tool.run("tc-3", {"command": "curl http://example.com"})
|
||||
assert result.status == ToolResultStatus.ERROR
|
||||
assert "denied" in (result.error or "").lower()
|
||||
|
||||
def test_unlisted_command_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
|
||||
ws, cfg = tmp_workspace
|
||||
tool = RunCommandTool(ws, cfg)
|
||||
result = tool.run("tc-4", {"command": "nc -l 1234"})
|
||||
assert result.status == ToolResultStatus.ERROR
|
||||
assert "not in the allowed" in (result.error or "").lower()
|
||||
|
||||
def test_nonzero_exit_code_reported(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
|
||||
ws, cfg = tmp_workspace
|
||||
tool = RunCommandTool(ws, cfg)
|
||||
# ls on a nonexistent path returns non-zero
|
||||
result = tool.run("tc-5", {"command": "ls /nonexistent_path_xyz"})
|
||||
assert result.status == ToolResultStatus.SUCCESS
|
||||
assert "Exit code:" in result.output
|
||||
|
||||
def test_timeout(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
|
||||
ws, cfg = tmp_workspace
|
||||
tool = RunCommandTool(ws, cfg)
|
||||
with mock_patch(
|
||||
"app.tools.shell.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("cmd", 1),
|
||||
):
|
||||
result = tool.run("tc-6", {"command": "echo test", "timeout": 1})
|
||||
assert result.status == ToolResultStatus.ERROR
|
||||
assert "timed out" in (result.error or "").lower()
|
||||
|
||||
def test_empty_command(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
|
||||
ws, cfg = tmp_workspace
|
||||
tool = RunCommandTool(ws, cfg)
|
||||
result = tool.run("tc-7", {"command": ""})
|
||||
assert result.status == ToolResultStatus.ERROR
|
||||
assert "empty" in (result.error or "").lower()
|
||||
|
||||
def test_custom_timeout(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
|
||||
ws, cfg = tmp_workspace
|
||||
tool = RunCommandTool(ws, cfg)
|
||||
result = tool.run("tc-8", {"command": "echo fast", "timeout": 60})
|
||||
assert result.status == ToolResultStatus.SUCCESS
|
||||
assert "fast" in result.output
|
||||
|
||||
def test_runs_in_workspace_dir(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
|
||||
ws, cfg = tmp_workspace
|
||||
# Create a file in the workspace to verify cwd
|
||||
(ws / "marker.txt").write_text("found")
|
||||
tool = RunCommandTool(ws, cfg)
|
||||
result = tool.run("tc-9", {"command": "cat marker.txt"})
|
||||
assert result.status == ToolResultStatus.SUCCESS
|
||||
assert "found" in result.output
|
||||
Reference in New Issue
Block a user