738 lines
25 KiB
Python
738 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
import pytest
|
|
|
|
from opensquilla.tools.builtin import filesystem
|
|
from opensquilla.tools.builtin import patch as patch_tool
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import (
|
|
RetryableToolInputError,
|
|
SafeToolError,
|
|
ToolContext,
|
|
current_tool_context,
|
|
)
|
|
|
|
|
|
def _original_async(fn: Callable[..., Awaitable[str]]) -> Callable[..., Awaitable[str]]:
|
|
return fn.__wrapped__.__wrapped__ # type: ignore[attr-defined, no-any-return]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_filesystem_write_notifies_bootstrap_or_memory_sources(tmp_path) -> None:
|
|
memory_calls: list[tuple[str, str]] = []
|
|
bootstrap_calls: list[tuple[str, str]] = []
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
agent_id="main",
|
|
workspace_dir=str(tmp_path),
|
|
memory_source_dir=str(tmp_path),
|
|
on_memory_source_write=lambda agent_id, path: memory_calls.append((agent_id, path)),
|
|
on_bootstrap_source_write=lambda agent_id, path: bootstrap_calls.append(
|
|
(agent_id, path)
|
|
),
|
|
)
|
|
)
|
|
write_file = _original_async(filesystem.write_file)
|
|
try:
|
|
await write_file("USER.md", "Name: Alice\n")
|
|
await write_file("MEMORY.md", "# MEMORY\n")
|
|
await write_file("memory/USER.md", "not a bootstrap file\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert bootstrap_calls == [("main", "USER.md")]
|
|
assert ("main", "MEMORY.md") in memory_calls
|
|
assert ("main", "memory/USER.md") in memory_calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_filesystem_write_tools_record_workspace_writes(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("old\n", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
file_edit_requires_fresh_read=True,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
write_file = _original_async(filesystem.write_file)
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
write_result = await write_file(str(target), "new\n")
|
|
edit_result = await edit_file(str(target), "new\n", "newer\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "workspace file" in write_result
|
|
assert "inspect git_diff" in write_result
|
|
assert "workspace file" in edit_result
|
|
assert [entry["relative_path"] for entry in ctx.workspace_file_writes] == [
|
|
"src/app.py",
|
|
"src/app.py",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_requires_fresh_full_workspace_read(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("value = 1\n", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
file_edit_requires_fresh_read=True,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
with pytest.raises(RetryableToolInputError) as unread:
|
|
await edit_file(str(target), "value = 1\n", "value = 2\n")
|
|
|
|
await read_file(str(target))
|
|
result = await edit_file(str(target), "value = 1\n", "value = 2\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "must read" in unread.value.user_message
|
|
assert "Edited" in result
|
|
assert target.read_text(encoding="utf-8") == "value = 2\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fresh_read_guard_records_runtime_event_for_unread_workspace_edit(
|
|
tmp_path,
|
|
) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("value = 1\n", encoding="utf-8")
|
|
events: list[dict[str, object]] = []
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
agent_id="agent-1",
|
|
session_key="session-1",
|
|
workspace_dir=str(tmp_path),
|
|
file_edit_requires_fresh_read=True,
|
|
on_runtime_event=events.append,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
with pytest.raises(RetryableToolInputError):
|
|
await edit_file(str(target), "value = 1\n", "value = 2\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert target.read_text(encoding="utf-8") == "value = 1\n"
|
|
assert events == [
|
|
{
|
|
"feature": "fresh_read_guard",
|
|
"name": "fresh_read_guard.blocked",
|
|
"tool": "edit_file",
|
|
"tool_name": "edit_file",
|
|
"path": str(target),
|
|
"resolved_path": str(target.resolve()),
|
|
"relative_path": "src/app.py",
|
|
"reason": "missing_fresh_read",
|
|
"outcome": "blocked",
|
|
"agent_id": "agent-1",
|
|
"session_key": "session-1",
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_rejects_stale_workspace_read(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("value = 1\n", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
file_edit_requires_fresh_read=True,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
target.write_text("value = 2\n", encoding="utf-8")
|
|
with pytest.raises(RetryableToolInputError) as stale:
|
|
await edit_file(str(target), "value = 2\n", "value = 3\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "changed since it was read" in stale.value.user_message
|
|
assert "read_file" in stale.value.user_message
|
|
assert target.read_text(encoding="utf-8") == "value = 2\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fresh_read_guard_records_runtime_event_for_stale_workspace_edit(
|
|
tmp_path,
|
|
) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("value = 1\n", encoding="utf-8")
|
|
events: list[dict[str, object]] = []
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
agent_id="agent-1",
|
|
session_key="session-1",
|
|
workspace_dir=str(tmp_path),
|
|
file_edit_requires_fresh_read=True,
|
|
on_runtime_event=events.append,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
target.write_text("value = 2\n", encoding="utf-8")
|
|
with pytest.raises(RetryableToolInputError):
|
|
await edit_file(str(target), "value = 2\n", "value = 3\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert len(events) == 1
|
|
assert events[0]["feature"] == "fresh_read_guard"
|
|
assert events[0]["name"] == "fresh_read_guard.blocked"
|
|
assert events[0]["tool"] == "edit_file"
|
|
assert events[0]["relative_path"] == "src/app.py"
|
|
assert events[0]["reason"] == "stale_fresh_read"
|
|
assert events[0]["outcome"] == "blocked"
|
|
assert target.read_text(encoding="utf-8") == "value = 2\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_file_existing_workspace_requires_fresh_full_read(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("value = 1\n", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
file_edit_requires_fresh_read=True,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
write_file = _original_async(filesystem.write_file)
|
|
try:
|
|
with pytest.raises(RetryableToolInputError) as unread:
|
|
await write_file(str(target), "value = 2\n")
|
|
|
|
await read_file(str(target))
|
|
result = await write_file(str(target), "value = 2\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "must read" in unread.value.user_message
|
|
assert result.startswith("Written ")
|
|
assert target.read_text(encoding="utf-8") == "value = 2\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_file_allows_new_and_scratch_files_without_workspace_read(tmp_path) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
scratch = tmp_path / "scratch"
|
|
workspace.mkdir()
|
|
scratch.mkdir()
|
|
new_target = workspace / "src" / "new.py"
|
|
scratch_target = scratch / "debug.py"
|
|
scratch_target.write_text("old\n", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(workspace),
|
|
scratch_dir=str(scratch),
|
|
file_edit_requires_fresh_read=True,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
write_file = _original_async(filesystem.write_file)
|
|
try:
|
|
new_result = await write_file(str(new_target), "created = True\n")
|
|
scratch_result = await write_file(str(scratch_target), "debug = True\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert new_result.startswith("Written ")
|
|
assert scratch_result.startswith("Written ")
|
|
assert new_target.read_text(encoding="utf-8") == "created = True\n"
|
|
assert scratch_target.read_text(encoding="utf-8") == "debug = True\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_workspace_edit_does_not_require_read_by_default(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("value = 1\n", encoding="utf-8")
|
|
events: list[dict[str, object]] = []
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
on_runtime_event=events.append,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
result = await edit_file(str(target), "value = 1\n", "value = 2\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "Edited" in result
|
|
assert not any(
|
|
event.get("name") == "fresh_read_guard.blocked"
|
|
or (
|
|
event.get("feature") == "fresh_read_guard"
|
|
and event.get("outcome") == "blocked"
|
|
)
|
|
for event in events
|
|
)
|
|
assert target.read_text(encoding="utf-8") == "value = 2\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_filesystem_write_tools_note_docs_and_derived_workspace_writes(
|
|
tmp_path,
|
|
) -> None:
|
|
docs_target = tmp_path / "docs" / "content" / "manual" / "manual.yml"
|
|
derived_target = tmp_path / "jq.1.prebuilt"
|
|
docs_target.parent.mkdir(parents=True)
|
|
derived_target.write_text("old\n", encoding="utf-8")
|
|
ctx = ToolContext(is_owner=True, workspace_dir=str(tmp_path))
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
write_file = _original_async(filesystem.write_file)
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
docs_result = await write_file(str(docs_target), "docs\n")
|
|
await read_file(str(derived_target))
|
|
derived_result = await edit_file(str(derived_target), "old\n", "new\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "documentation file" in docs_result
|
|
assert "verify the docs build" in docs_result
|
|
assert "generated or derived-looking file" in derived_result
|
|
assert "regenerate/verify" in derived_result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_filesystem_write_tools_note_test_workspace_writes(tmp_path) -> None:
|
|
test_target = tmp_path / "packages" / "core" / "__tests__" / "feature.spec.ts"
|
|
test_target.parent.mkdir(parents=True)
|
|
test_target.write_text("old\n", encoding="utf-8")
|
|
ctx = ToolContext(is_owner=True, workspace_dir=str(tmp_path))
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(test_target))
|
|
result = await edit_file(str(test_target), "old\n", "new\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "test file" in result
|
|
assert "explicitly requested test updates" in result
|
|
assert "revert it before final" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_missing_old_text_is_model_retriable(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("actual = 1\n", encoding="utf-8")
|
|
token = current_tool_context.set(ToolContext(is_owner=True, workspace_dir=str(tmp_path)))
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
with pytest.raises(RetryableToolInputError) as exc_info:
|
|
await edit_file(str(target), "expected = 1\n", "expected = 2\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "could not find old_text" in exc_info.value.user_message
|
|
assert "retry with exact text" in exc_info.value.user_message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_ambiguous_old_text_is_model_retriable(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("flag = True\nflag = True\n", encoding="utf-8")
|
|
token = current_tool_context.set(ToolContext(is_owner=True, workspace_dir=str(tmp_path)))
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
with pytest.raises(RetryableToolInputError) as exc_info:
|
|
await edit_file(str(target), "flag = True\n", "flag = False\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "matches 2 locations" in exc_info.value.user_message
|
|
assert "unique surrounding context" in exc_info.value.user_message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_uses_unique_flexible_match_after_exact_miss(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text(
|
|
"def run():\n"
|
|
" if enabled:\n"
|
|
" return 1\n",
|
|
encoding="utf-8",
|
|
)
|
|
events: list[dict[str, object]] = []
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
on_runtime_event=events.append,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
result = await edit_file(
|
|
str(target),
|
|
"if enabled:\n return 1\n",
|
|
"if enabled:\n return 2\n",
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "Edited" in result
|
|
assert target.read_text(encoding="utf-8") == (
|
|
"def run():\n"
|
|
" if enabled:\n"
|
|
" return 2\n"
|
|
)
|
|
assert any(event["name"] == "edit_file.flexible_match_used" for event in events)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_can_disable_flexible_match_recovery(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text(
|
|
"def run():\n"
|
|
" if enabled:\n"
|
|
" return 1\n",
|
|
encoding="utf-8",
|
|
)
|
|
events: list[dict[str, object]] = []
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
file_edit_flexible_recovery=False,
|
|
on_runtime_event=events.append,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
with pytest.raises(RetryableToolInputError) as exc_info:
|
|
await edit_file(
|
|
str(target),
|
|
"if enabled:\n return 1\n",
|
|
"if enabled:\n return 2\n",
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "could not find old_text" in exc_info.value.user_message
|
|
assert target.read_text(encoding="utf-8") == (
|
|
"def run():\n"
|
|
" if enabled:\n"
|
|
" return 1\n"
|
|
)
|
|
assert any(
|
|
event["name"] == "edit_file.flexible_match_rejected"
|
|
and event["reason"] == "disabled"
|
|
for event in events
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_unescapes_old_text_before_flexible_match(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("def run():\n return 'old'\n", encoding="utf-8")
|
|
events: list[dict[str, object]] = []
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
on_runtime_event=events.append,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
await edit_file(
|
|
str(target),
|
|
"def run():\\n return 'old'\\n",
|
|
"def run():\n return 'new'\n",
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert target.read_text(encoding="utf-8") == "def run():\n return 'new'\n"
|
|
event_names = [event["name"] for event in events]
|
|
assert "edit_file.unescape_repair_used" in event_names
|
|
assert "edit_file.flexible_match_used" not in event_names
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_rejects_non_unique_flexible_match(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text(
|
|
"if enabled:\n"
|
|
" return 1\n"
|
|
"if enabled:\n"
|
|
" return 1\n",
|
|
encoding="utf-8",
|
|
)
|
|
events: list[dict[str, object]] = []
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
on_runtime_event=events.append,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
with pytest.raises(RetryableToolInputError) as exc_info:
|
|
await edit_file(
|
|
str(target),
|
|
"if enabled:\nreturn 1\n",
|
|
"if enabled:\nreturn 2\n",
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "could not find old_text" in exc_info.value.user_message
|
|
assert any(event["name"] == "edit_file.flexible_match_rejected" for event in events)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_accepts_multiple_precise_replacements(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text(
|
|
"alpha = 1\n"
|
|
"keep = True\n"
|
|
"beta = 2\n",
|
|
encoding="utf-8",
|
|
)
|
|
ctx = ToolContext(is_owner=True, workspace_dir=str(tmp_path))
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
result = await edit_file(
|
|
str(target),
|
|
edits=[
|
|
{"old_text": "alpha = 1\n", "new_text": "alpha = 10\n"},
|
|
{"oldText": "beta = 2\n", "newText": "beta = 20\n"},
|
|
],
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert target.read_text(encoding="utf-8") == (
|
|
"alpha = 10\n"
|
|
"keep = True\n"
|
|
"beta = 20\n"
|
|
)
|
|
assert "applied 2 replacement" in result
|
|
assert [entry["relative_path"] for entry in ctx.workspace_file_writes] == ["src/app.py"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_recovery_guidance_omits_hidden_apply_patch(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("value = 1\n", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
allowed_tools={
|
|
"exec_command",
|
|
"read_file",
|
|
"edit_file",
|
|
"write_file",
|
|
"glob_search",
|
|
"grep_search",
|
|
"list_dir",
|
|
"git_status",
|
|
"git_diff",
|
|
"retrieve_tool_result",
|
|
},
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
edit_file = _original_async(filesystem.edit_file)
|
|
try:
|
|
await read_file(str(target))
|
|
with pytest.raises(RetryableToolInputError) as exc_info:
|
|
await edit_file(str(target), "missing = True\n", "missing = False\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "could not find old_text" in exc_info.value.user_message
|
|
assert "apply_patch" not in exc_info.value.user_message
|
|
assert "smaller exact" in exc_info.value.user_message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_file_shrink_guard_omits_hidden_apply_patch(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("value = 1\n" * 700, encoding="utf-8")
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
workspace_dir=str(tmp_path),
|
|
allowed_tools={
|
|
"exec_command",
|
|
"read_file",
|
|
"edit_file",
|
|
"write_file",
|
|
"glob_search",
|
|
"grep_search",
|
|
"list_dir",
|
|
"git_status",
|
|
"git_diff",
|
|
"retrieve_tool_result",
|
|
},
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
read_file = filesystem.read_file
|
|
write_file = _original_async(filesystem.write_file)
|
|
try:
|
|
await read_file(str(target))
|
|
with pytest.raises(SafeToolError) as exc_info:
|
|
await write_file(str(target), "value = 2\n")
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "write_file refused to overwrite" in exc_info.value.user_message
|
|
assert "edit_file" in exc_info.value.user_message
|
|
assert "apply_patch" not in exc_info.value.user_message
|
|
|
|
|
|
def test_edit_file_schema_guides_multi_edit_and_numbered_read_output() -> None:
|
|
registered = get_default_registry().get("edit_file")
|
|
assert registered is not None
|
|
spec = registered.spec
|
|
description = spec.description
|
|
edits = spec.parameters["edits"]
|
|
|
|
assert spec.required == ["path"]
|
|
assert "edits[]" in description
|
|
assert "read_file without offset or limit" in description
|
|
assert "line-number" in description
|
|
assert "prefer apply_patch" in description
|
|
assert edits["type"] == "array"
|
|
assert "old_text" in edits["items"]["properties"]
|
|
assert "oldText" in edits["items"]["properties"]
|
|
|
|
|
|
def test_coding_tool_descriptions_explain_file_edit_workflow() -> None:
|
|
registry = get_default_registry()
|
|
|
|
read_file = registry.get("read_file")
|
|
write_file = registry.get("write_file")
|
|
apply_patch = registry.get("apply_patch")
|
|
exec_command = registry.get("exec_command")
|
|
execute_code = registry.get("execute_code")
|
|
|
|
assert read_file is not None
|
|
assert write_file is not None
|
|
assert apply_patch is not None
|
|
assert exec_command is not None
|
|
assert execute_code is not None
|
|
assert "establish fresh edit context" in read_file.spec.description
|
|
assert "Best for new files and scratch files" in write_file.spec.description
|
|
assert "Complete file content" in write_file.spec.parameters["content"]["description"]
|
|
assert "multi-line or larger source edits" in apply_patch.spec.description
|
|
assert "builds, tests" in exec_command.spec.description
|
|
assert "Prefer the file editing tools" in execute_code.spec.description
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_reports_workspace_write_progress(tmp_path) -> None:
|
|
target = tmp_path / "src" / "app.py"
|
|
target.parent.mkdir()
|
|
target.write_text("old\n", encoding="utf-8")
|
|
ctx = ToolContext(is_owner=True, workspace_dir=str(tmp_path))
|
|
token = current_tool_context.set(ctx)
|
|
apply_patch = _original_async(patch_tool.apply_patch)
|
|
try:
|
|
result = await apply_patch(
|
|
"""*** Begin Patch
|
|
*** Update File: src/app.py
|
|
@@@ -1,1 +1,1 @@@
|
|
-old
|
|
+new
|
|
*** End Patch"""
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert "Applied patch" in result
|
|
assert "inspect git_diff" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_notifies_bootstrap_and_memory_sources(tmp_path) -> None:
|
|
memory_calls: list[tuple[str, str]] = []
|
|
bootstrap_calls: list[tuple[str, str]] = []
|
|
(tmp_path / "USER.md").write_text("Name:\n", encoding="utf-8")
|
|
(tmp_path / "memory").mkdir()
|
|
(tmp_path / "memory" / "2026-05-01.md").write_text("old\n", encoding="utf-8")
|
|
token = current_tool_context.set(
|
|
ToolContext(
|
|
is_owner=True,
|
|
agent_id="main",
|
|
workspace_dir=str(tmp_path),
|
|
memory_source_dir=str(tmp_path),
|
|
on_memory_source_write=lambda agent_id, path: memory_calls.append((agent_id, path)),
|
|
on_bootstrap_source_write=lambda agent_id, path: bootstrap_calls.append(
|
|
(agent_id, path)
|
|
),
|
|
)
|
|
)
|
|
apply_patch = _original_async(patch_tool.apply_patch)
|
|
try:
|
|
await apply_patch(
|
|
"""*** Begin Patch
|
|
*** Update File: USER.md
|
|
@@@ -1,1 +1,1 @@@
|
|
-Name:
|
|
+Name: Alice
|
|
*** Update File: memory/2026-05-01.md
|
|
@@@ -1,1 +1,1 @@@
|
|
-old
|
|
+new
|
|
*** End Patch"""
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert bootstrap_calls == [("main", "USER.md")]
|
|
assert memory_calls == [("main", "memory/2026-05-01.md")]
|