Files
pocketpaw/tests/test_edit_file.py
Prakash Dalai 18dd74a3a4 Add agent primitives: run_python, install_package, edit_file (#582)
* feat(tools): add run_python, install_package, and edit_file primitives

Three new builtin tools that close the gap between "agent that chats"
and "agent that builds things":

- run_python: sandboxed Python execution via subprocess (trust: elevated).
  Writes code to a temp file in the file jail, runs with sys.executable,
  Guardian AI reviews before execution, cleanup in finally block.

- install_package: pip install with input validation and Guardian review
  (trust: elevated). Whitelist regex blocks shell injection. Subprocess
  runs without shell=True for defense-in-depth.

- edit_file: find-and-replace file editing (trust: standard). Closes the
  gap where policy.py referenced edit_file in group:fs but no tool existed.
  Supports unique match (default) and replace_all modes.

Wiring changes:
- __init__.py: lazy imports for all three tools
- policy.py: run_python added to group:shell, new group:packages with
  install_package, coding profile includes group:packages
- tool_bridge.py: EditFileTool excluded for claude_agent_sdk (has native Edit)

31 new tests across 3 test files, all passing.

Closes #581

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tools): add deliver_artifact tool for sending files to users

Standalone tool that lets the agent explicitly send any file (image,
video, audio, PDF) to the user through their current channel. Bridges
the gap between _media_result() (tool-internal) and agent-accessible
file delivery.

Uses the existing media pipeline: _media_result() -> AgentLoop extraction
-> OutboundMessage.media -> channel adapter _send_media_file().

9 new tests, all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use get_running_loop, remove whitespace from regex, clean up em dashes

- Replace deprecated asyncio.get_event_loop() with get_running_loop()
  in python_exec.py and pip_install.py
- Remove \s from package spec regex (single package at a time, no spaces)
- Replace em dashes with hyphens/commas per project convention

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Rohit Kushwaha <rohitk290106@gmail.com>
Co-authored-by: Rohit Kushwaha <technicalrohit06@gmail.com>
2026-03-16 21:36:34 +05:30

176 lines
5.0 KiB
Python

# Tests for EditFileTool - find-and-replace file editing.
# Created: 2026-03-12
from unittest.mock import patch
import pytest
from pocketpaw.config import Settings
from pocketpaw.tools.builtin.filesystem import EditFileTool
@pytest.fixture
def jail(tmp_path):
"""Temporary directory used as the file jail."""
return tmp_path
@pytest.fixture
def mock_settings(jail):
"""Patch filesystem.get_settings to use the temp jail."""
settings = Settings(file_jail_path=jail)
with patch("pocketpaw.tools.builtin.filesystem.get_settings", return_value=settings):
yield settings
@pytest.mark.asyncio
async def test_edit_file_basic(jail, mock_settings):
"""Replace one occurrence, content should change."""
f = jail / "hello.txt"
f.write_text("Hello World")
tool = EditFileTool()
result = await tool.execute(path=str(f), old_string="World", new_string="PocketPaw")
assert "replacement" in result
assert f.read_text() == "Hello PocketPaw"
@pytest.mark.asyncio
async def test_edit_file_not_found(jail, mock_settings):
"""Editing a non-existent file returns a 'not found' error."""
tool = EditFileTool()
result = await tool.execute(
path=str(jail / "missing.txt"),
old_string="anything",
new_string="replacement",
)
assert "Error:" in result
assert "not found" in result.lower()
@pytest.mark.asyncio
async def test_edit_file_old_string_missing(jail, mock_settings):
"""old_string not in file returns an error."""
f = jail / "content.txt"
f.write_text("The quick brown fox")
tool = EditFileTool()
result = await tool.execute(path=str(f), old_string="lazy dog", new_string="cat")
assert "Error:" in result
assert "not found" in result
@pytest.mark.asyncio
async def test_edit_file_ambiguous(jail, mock_settings):
"""old_string appearing 3 times with replace_all=False returns an error mentioning the count."""
f = jail / "repeat.txt"
f.write_text("foo bar foo baz foo")
tool = EditFileTool()
result = await tool.execute(path=str(f), old_string="foo", new_string="qux", replace_all=False)
assert "Error:" in result
assert "3" in result
@pytest.mark.asyncio
async def test_edit_file_replace_all(jail, mock_settings):
"""replace_all=True replaces every occurrence."""
f = jail / "repeat.txt"
f.write_text("foo bar foo baz foo")
tool = EditFileTool()
result = await tool.execute(path=str(f), old_string="foo", new_string="qux", replace_all=True)
assert "Error:" not in result
assert f.read_text() == "qux bar qux baz qux"
assert "3" in result
@pytest.mark.asyncio
async def test_edit_file_multiline(jail, mock_settings):
"""Replace a multi-line block."""
original = "line one\nline two\nline three\n"
f = jail / "multi.txt"
f.write_text(original)
tool = EditFileTool()
result = await tool.execute(
path=str(f),
old_string="line one\nline two\n",
new_string="replaced block\n",
)
assert "Error:" not in result
assert f.read_text() == "replaced block\nline three\n"
@pytest.mark.asyncio
async def test_edit_file_empty_new_string(jail, mock_settings):
"""Replace with empty string effectively deletes the matched text."""
f = jail / "delete.txt"
f.write_text("keep this DELETE that")
tool = EditFileTool()
result = await tool.execute(path=str(f), old_string=" DELETE", new_string="")
assert "Error:" not in result
assert f.read_text() == "keep this that"
@pytest.mark.asyncio
async def test_edit_file_file_jail(jail, mock_settings):
"""Paths outside the jail are denied."""
outside = jail.parent / "outside_secret.txt"
outside.write_text("sensitive data")
tool = EditFileTool()
result = await tool.execute(
path=str(outside),
old_string="sensitive",
new_string="redacted",
)
assert "Access denied" in result
# File should be unchanged
assert outside.read_text() == "sensitive data"
@pytest.mark.asyncio
async def test_edit_file_preserves_rest(jail, mock_settings):
"""Editing one part of a file leaves the rest of the content intact."""
f = jail / "partial.txt"
f.write_text("alpha beta gamma delta")
tool = EditFileTool()
await tool.execute(path=str(f), old_string="beta", new_string="BETA")
content = f.read_text()
assert "alpha" in content
assert "BETA" in content
assert "gamma" in content
assert "delta" in content
assert "beta" not in content
@pytest.mark.asyncio
async def test_edit_file_definition(mock_settings):
"""Tool definition has correct name, trust level, and required parameters."""
tool = EditFileTool()
defn = tool.definition
assert defn.name == "edit_file"
assert defn.trust_level == "standard"
required = defn.parameters.get("required", [])
assert "path" in required
assert "old_string" in required
assert "new_string" in required
props = defn.parameters.get("properties", {})
assert "replace_all" in props
assert props["replace_all"]["type"] == "boolean"