222 lines
6.6 KiB
Python
222 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable, Iterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from opensquilla.tools.builtin import patch as patch_tool
|
|
from opensquilla.tools.mutation_receipts import fingerprint_file
|
|
from opensquilla.tools.types import 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.fixture
|
|
def patch_context(
|
|
tmp_path: Path,
|
|
) -> Iterator[tuple[Path, ToolContext, list[dict[str, Any]]]]:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
events: list[dict[str, Any]] = []
|
|
ctx = ToolContext(
|
|
workspace_dir=str(workspace),
|
|
session_key="agent:main:test",
|
|
on_runtime_event=events.append,
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
yield workspace, ctx, events
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_failure_does_not_leave_partial_mutation(
|
|
patch_context: tuple[Path, ToolContext, list[dict[str, Any]]],
|
|
) -> None:
|
|
workspace, ctx, _events = patch_context
|
|
first = workspace / "src" / "first.py"
|
|
second = workspace / "src" / "second.py"
|
|
first.parent.mkdir(parents=True)
|
|
first.write_text("one\n", encoding="utf-8")
|
|
second.write_text("two\n", encoding="utf-8")
|
|
apply_patch = _original_async(patch_tool.apply_patch)
|
|
|
|
patch = """*** Begin Patch
|
|
*** Update File: src/first.py
|
|
@@ -1,1 +1,1 @@
|
|
-one
|
|
+ONE
|
|
*** Update File: src/second.py
|
|
@@ -1,1 +1,1 @@
|
|
-wrong
|
|
+TWO
|
|
*** End Patch"""
|
|
|
|
with pytest.raises(Exception):
|
|
await apply_patch(patch=patch)
|
|
|
|
assert first.read_text(encoding="utf-8") == "one\n"
|
|
assert second.read_text(encoding="utf-8") == "two\n"
|
|
assert ctx.workspace_mutation_receipts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_success_records_semantic_mutation_receipt(
|
|
patch_context: tuple[Path, ToolContext, list[dict[str, Any]]],
|
|
) -> None:
|
|
workspace, ctx, events = patch_context
|
|
target = workspace / "src" / "app.py"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("one\n", encoding="utf-8")
|
|
apply_patch = _original_async(patch_tool.apply_patch)
|
|
|
|
result = await apply_patch(
|
|
patch="""*** Begin Patch
|
|
*** Update File: src/app.py
|
|
@@ -1,1 +1,1 @@
|
|
-one
|
|
+ONE
|
|
*** End Patch"""
|
|
)
|
|
|
|
assert "1 file(s) modified" in result
|
|
assert target.read_text(encoding="utf-8") == "ONE\n"
|
|
receipt = ctx.workspace_mutation_receipts[-1]
|
|
assert receipt["tool"] == "apply_patch"
|
|
assert receipt["changed"] is True
|
|
assert receipt["relative_path"] == "src/app.py"
|
|
assert any(
|
|
event.get("name") == "workspace.semantic_mutation_receipt" for event in events
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_commit_preflight_parent_file_conflict_is_atomic(
|
|
patch_context: tuple[Path, ToolContext, list[dict[str, Any]]],
|
|
) -> None:
|
|
workspace, ctx, _events = patch_context
|
|
target = workspace / "first.txt"
|
|
blocked_parent = workspace / "blocked_parent"
|
|
target.write_text("one\n", encoding="utf-8")
|
|
blocked_parent.write_text("not a directory\n", encoding="utf-8")
|
|
apply_patch = _original_async(patch_tool.apply_patch)
|
|
|
|
patch = """*** Begin Patch
|
|
*** Update File: first.txt
|
|
@@ -1,1 +1,1 @@
|
|
-one
|
|
+ONE
|
|
*** Add File: blocked_parent/new.txt
|
|
+new
|
|
*** End Patch"""
|
|
|
|
with pytest.raises(Exception):
|
|
await apply_patch(patch=patch)
|
|
|
|
assert target.read_text(encoding="utf-8") == "one\n"
|
|
assert blocked_parent.read_text(encoding="utf-8") == "not a directory\n"
|
|
assert ctx.workspace_mutation_receipts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_commit_preflight_child_first_parent_file_conflict_is_atomic(
|
|
patch_context: tuple[Path, ToolContext, list[dict[str, Any]]],
|
|
) -> None:
|
|
workspace, ctx, _events = patch_context
|
|
apply_patch = _original_async(patch_tool.apply_patch)
|
|
|
|
patch = """*** Begin Patch
|
|
*** Add File: a/b.txt
|
|
+child
|
|
*** Add File: a
|
|
+parent
|
|
*** End Patch"""
|
|
|
|
with pytest.raises(Exception):
|
|
await apply_patch(patch=patch)
|
|
|
|
assert not (workspace / "a").exists()
|
|
assert not (workspace / "a" / "b.txt").exists()
|
|
assert ctx.workspace_mutation_receipts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_repeated_updates_to_same_file_compose_in_memory(
|
|
patch_context: tuple[Path, ToolContext, list[dict[str, Any]]],
|
|
) -> None:
|
|
workspace, ctx, _events = patch_context
|
|
target = workspace / "src" / "app.py"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("alpha\nbeta\n", encoding="utf-8")
|
|
before = fingerprint_file(target)
|
|
apply_patch = _original_async(patch_tool.apply_patch)
|
|
|
|
await apply_patch(
|
|
patch="""*** Begin Patch
|
|
*** Update File: src/app.py
|
|
@@ -1,1 +1,1 @@
|
|
-alpha
|
|
+ALPHA
|
|
*** Update File: src/app.py
|
|
@@ -2,1 +2,1 @@
|
|
-beta
|
|
+BETA
|
|
*** End Patch"""
|
|
)
|
|
|
|
assert target.read_text(encoding="utf-8") == "ALPHA\nBETA\n"
|
|
after = fingerprint_file(target)
|
|
assert len(ctx.workspace_mutation_receipts) == 1
|
|
receipt = ctx.workspace_mutation_receipts[0]
|
|
assert receipt["tool"] == "apply_patch"
|
|
assert receipt["operation"] == "apply_patch"
|
|
assert receipt["relative_path"] == "src/app.py"
|
|
assert receipt["before"] == before
|
|
assert receipt["after"] == after
|
|
assert receipt["operation_count"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_receipt_after_fingerprints_committed_file_bytes(
|
|
patch_context: tuple[Path, ToolContext, list[dict[str, Any]]],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
workspace, ctx, _events = patch_context
|
|
target = workspace / "src" / "app.py"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("alpha\nbeta\n", encoding="utf-8")
|
|
apply_patch = _original_async(patch_tool.apply_patch)
|
|
original_apply_ops = patch_tool._apply_ops
|
|
|
|
def apply_ops_with_disk_newlines(ops: Any, root: Any = None) -> Any:
|
|
result = original_apply_ops(ops, root)
|
|
target.write_bytes(
|
|
target.read_text(encoding="utf-8").replace("\n", "\r\n").encode("utf-8")
|
|
)
|
|
return result
|
|
|
|
monkeypatch.setattr(patch_tool, "_apply_ops", apply_ops_with_disk_newlines)
|
|
|
|
await apply_patch(
|
|
patch="""*** Begin Patch
|
|
*** Update File: src/app.py
|
|
@@ -1,1 +1,1 @@
|
|
-alpha
|
|
+ALPHA
|
|
*** Update File: src/app.py
|
|
@@ -2,1 +2,1 @@
|
|
-beta
|
|
+BETA
|
|
*** End Patch"""
|
|
)
|
|
|
|
after = fingerprint_file(target)
|
|
planned_after = patch_tool._fingerprint_content("ALPHA\nBETA\n")
|
|
assert after["size"] != planned_after["size"]
|
|
assert ctx.workspace_mutation_receipts[0]["after"] == after
|