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:
2026-03-11 09:45:48 -05:00
parent 0cf0d01657
commit f60c47a85f
12 changed files with 956 additions and 3 deletions

View File

@@ -0,0 +1,138 @@
"""Tests for write/mkdir/delete filesystem tools (Phase 6)."""
from pathlib import Path
import pytest
from app.models.config import AppConfig, load_config
from app.models.tool_call import ToolResultStatus
from app.tools.filesystem import DeleteFileTool, MakeDirTool, WriteFileTool
@pytest.fixture
def config() -> AppConfig:
return load_config()
@pytest.fixture
def workspace(config: AppConfig) -> Path:
return config.agent.workspace_root
@pytest.fixture
def tmp_workspace(tmp_path: Path, config: AppConfig) -> tuple[Path, AppConfig]:
"""Create a temporary workspace for write tests."""
# Override workspace_root to tmp_path for isolation
config.agent.workspace_root = tmp_path
return tmp_path, config
# --- WriteFileTool ---
class TestWriteFileTool:
def test_write_new_file(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = WriteFileTool(ws, cfg)
result = tool.run("tc-1", {"file_path": "hello.txt", "content": "Hello, world!"})
assert result.status == ToolResultStatus.SUCCESS
assert "13 characters" in result.output
assert (ws / "hello.txt").read_text() == "Hello, world!"
def test_write_creates_parent_dirs(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = WriteFileTool(ws, cfg)
result = tool.run("tc-2", {"file_path": "a/b/c/deep.txt", "content": "deep"})
assert result.status == ToolResultStatus.SUCCESS
assert (ws / "a" / "b" / "c" / "deep.txt").read_text() == "deep"
def test_write_overwrites_existing(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
(ws / "existing.txt").write_text("old content")
tool = WriteFileTool(ws, cfg)
result = tool.run("tc-3", {"file_path": "existing.txt", "content": "new content"})
assert result.status == ToolResultStatus.SUCCESS
assert (ws / "existing.txt").read_text() == "new content"
def test_write_path_traversal_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = WriteFileTool(ws, cfg)
result = tool.run("tc-4", {"file_path": "../../etc/evil.txt", "content": "bad"})
assert result.status == ToolResultStatus.ERROR
assert "outside" in (result.error or "").lower()
# --- MakeDirTool ---
class TestMakeDirTool:
def test_make_new_dir(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = MakeDirTool(ws, cfg)
result = tool.run("tc-1", {"directory_path": "newdir"})
assert result.status == ToolResultStatus.SUCCESS
assert (ws / "newdir").is_dir()
def test_make_nested_dirs(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = MakeDirTool(ws, cfg)
result = tool.run("tc-2", {"directory_path": "a/b/c"})
assert result.status == ToolResultStatus.SUCCESS
assert (ws / "a" / "b" / "c").is_dir()
def test_make_existing_dir_ok(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
(ws / "existing_dir").mkdir()
tool = MakeDirTool(ws, cfg)
result = tool.run("tc-3", {"directory_path": "existing_dir"})
assert result.status == ToolResultStatus.SUCCESS
def test_make_dir_over_file_fails(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
(ws / "afile").write_text("content")
tool = MakeDirTool(ws, cfg)
result = tool.run("tc-4", {"directory_path": "afile"})
assert result.status == ToolResultStatus.ERROR
assert "not a directory" in (result.error or "").lower()
def test_make_dir_path_traversal_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = MakeDirTool(ws, cfg)
result = tool.run("tc-5", {"directory_path": "../../outside"})
assert result.status == ToolResultStatus.ERROR
assert "outside" in (result.error or "").lower()
# --- DeleteFileTool ---
class TestDeleteFileTool:
def test_delete_existing_file(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
(ws / "doomed.txt").write_text("bye")
tool = DeleteFileTool(ws, cfg)
result = tool.run("tc-1", {"file_path": "doomed.txt"})
assert result.status == ToolResultStatus.SUCCESS
assert not (ws / "doomed.txt").exists()
def test_delete_nonexistent_file(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = DeleteFileTool(ws, cfg)
result = tool.run("tc-2", {"file_path": "ghost.txt"})
assert result.status == ToolResultStatus.ERROR
assert "not found" in (result.error or "").lower()
def test_delete_directory_refused(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
(ws / "adir").mkdir()
tool = DeleteFileTool(ws, cfg)
result = tool.run("tc-3", {"file_path": "adir"})
assert result.status == ToolResultStatus.ERROR
assert "directory" in (result.error or "").lower()
def test_delete_path_traversal_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = DeleteFileTool(ws, cfg)
result = tool.run("tc-4", {"file_path": "../../etc/passwd"})
assert result.status == ToolResultStatus.ERROR
assert "outside" in (result.error or "").lower()