Files
2026-07-13 13:12:33 +08:00

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