Files
opensquilla--opensquilla/tests/test_tools/test_source_edit_tools.py
T
2026-07-13 13:12:33 +08:00

494 lines
16 KiB
Python

from __future__ import annotations
import json
from collections.abc import Awaitable, Callable, Iterator
from pathlib import Path
import pytest
from opensquilla.tools.builtin import filesystem
from opensquilla.tools.registry import get_default_registry
from opensquilla.tools.types import (
CallerKind,
RetryableToolInputError,
ToolContext,
ToolError,
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 workspace_context(tmp_path: Path) -> Iterator[tuple[Path, ToolContext]]:
workspace = tmp_path / "workspace"
workspace.mkdir()
ctx = ToolContext(
is_owner=True,
caller_kind=CallerKind.AGENT,
workspace_dir=str(workspace),
session_key="agent:main:test",
)
token = current_tool_context.set(ctx)
try:
yield workspace, ctx
finally:
current_tool_context.reset(token)
@pytest.mark.asyncio
async def test_read_source_returns_revision_receipt_and_plain_lines(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
target = workspace / "src" / "app.py"
target.parent.mkdir()
target.write_text("one\ntwo\nthree\n", encoding="utf-8")
result = await filesystem.read_source("src/app.py", start_line=2, end_line=3)
payload = json.loads(result)
assert payload["status"] == "success"
assert payload["path"] == "src/app.py"
assert payload["revision"].startswith("file_")
assert payload["range"] == [2, 3]
assert payload["total_lines"] == 3
assert payload["lines"] == [
{"line": 2, "text": "two"},
{"line": 3, "text": "three"},
]
@pytest.mark.asyncio
async def test_read_source_records_workspace_read(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, ctx = workspace_context
target = workspace / "src" / "app.py"
target.parent.mkdir()
target.write_text("one\ntwo\nthree\n", encoding="utf-8")
await filesystem.read_source(str(target), start_line=1, end_line=2)
assert ctx.workspace_file_reads[-1] == {
"path": str(target),
"relative_path": "src/app.py",
"name": "app.py",
"suffix": ".py",
"operation": "read_source",
"offset": 1,
"limit": 2,
"complete": False,
}
@pytest.mark.asyncio
async def test_read_source_defaults_end_line_and_returns_total_lines(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, ctx = workspace_context
target = workspace / "src" / "app.py"
target.parent.mkdir()
target.write_text("one\ntwo\nthree\n", encoding="utf-8")
result = await filesystem.read_source("src/app.py", start_line=2)
payload = json.loads(result)
assert payload["status"] == "success"
assert payload["path"] == "src/app.py"
assert payload["range"] == [2, 3]
assert payload["total_lines"] == 3
assert payload["lines"] == [
{"line": 2, "text": "two"},
{"line": 3, "text": "three"},
]
assert ctx.workspace_file_reads[-1]["offset"] == 2
assert ctx.workspace_file_reads[-1]["limit"] == 2
@pytest.mark.asyncio
async def test_read_source_defaults_start_line_to_first_line(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, ctx = workspace_context
target = workspace / "src" / "app.py"
target.parent.mkdir()
target.write_text("one\ntwo\nthree\n", encoding="utf-8")
result = await filesystem.read_source("src/app.py")
payload = json.loads(result)
assert payload["status"] == "success"
assert payload["range"] == [1, 3]
assert payload["total_lines"] == 3
assert payload["lines"][0] == {"line": 1, "text": "one"}
assert ctx.workspace_file_reads[-1]["offset"] == 1
assert ctx.workspace_file_reads[-1]["limit"] == 3
@pytest.mark.asyncio
async def test_read_source_rejects_binary_file(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
target = workspace / "src" / "blob.txt"
target.parent.mkdir()
target.write_bytes(b"abc\x00def")
with pytest.raises(ToolError, match="NUL"):
await filesystem.read_source(str(target), start_line=1, end_line=1)
@pytest.mark.asyncio
async def test_glob_search_returns_workspace_relative_paths(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
target = workspace / "pkg" / "module.py"
target.parent.mkdir()
target.write_text("VALUE = 1\n", encoding="utf-8")
result = await filesystem.glob_search("**/*.py")
assert "pkg/module.py" in result
assert str(workspace) not in result
@pytest.mark.asyncio
async def test_grep_search_returns_workspace_relative_paths(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
target = workspace / "pkg" / "module.py"
target.parent.mkdir()
target.write_text("VALUE = 1\n", encoding="utf-8")
result = await filesystem.grep_search("VALUE")
assert "pkg/module.py:1: VALUE = 1" in result
assert str(workspace) not in result
def test_read_source_is_not_visible_unless_surfaced_or_allowed() -> None:
registry = get_default_registry()
default_names = {tool.name for tool in registry.to_tool_definitions(ToolContext(is_owner=True))}
surfaced_names = {
tool.name
for tool in registry.to_tool_definitions(
ToolContext(is_owner=True, surfaced_tools={"read_source"})
)
}
allowed_names = {
tool.name
for tool in registry.to_tool_definitions(
ToolContext(is_owner=True, allowed_tools={"read_source"})
)
}
assert "read_source" not in default_names
assert "read_source" in surfaced_names
assert allowed_names == {"read_source"}
def test_read_source_schema_requires_only_path() -> None:
registry = get_default_registry()
tools = registry.to_tool_definitions(ToolContext(is_owner=True, surfaced_tools={"read_source"}))
read_source = next(tool for tool in tools if tool.name == "read_source")
assert read_source.input_schema.required == ["path"]
def test_scoped_source_write_tools_are_hidden_unless_surfaced() -> None:
registry = get_default_registry()
default_names = {tool.name for tool in registry.to_tool_definitions(ToolContext(is_owner=True))}
surfaced_names = {
tool.name
for tool in registry.to_tool_definitions(
ToolContext(is_owner=True, surfaced_tools={"create_source", "write_scratch"})
)
}
assert "create_source" not in default_names
assert "write_scratch" not in default_names
assert {"create_source", "write_scratch"}.issubset(surfaced_names)
@pytest.mark.asyncio
async def test_create_source_creates_new_workspace_file_with_mutation_receipt(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, ctx = workspace_context
create_source = _original_async(filesystem.create_source)
result = await create_source("src/new_module.py", "VALUE = 1\n")
payload = json.loads(result)
target = workspace / "src" / "new_module.py"
assert payload["status"] == "created"
assert payload["path"] == "src/new_module.py"
assert payload["changed"] is True
assert payload["after_revision"].startswith("file_")
assert payload["workspace_epoch"] == 1
assert "+VALUE = 1" in payload["diff_summary"]
assert target.read_text(encoding="utf-8") == "VALUE = 1\n"
receipt = ctx.workspace_mutation_receipts[-1]
assert receipt["tool"] == "create_source"
assert receipt["operation"] == "create_source"
assert receipt["relative_path"] == "src/new_module.py"
assert receipt["changed"] is True
assert receipt["workspace_epoch"] == 1
@pytest.mark.asyncio
async def test_create_source_rejects_existing_file(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
create_source = _original_async(filesystem.create_source)
target = workspace / "src" / "existing.py"
target.parent.mkdir()
target.write_text("old\n", encoding="utf-8")
with pytest.raises(RetryableToolInputError, match="already exists"):
await create_source("src/existing.py", "new\n")
assert target.read_text(encoding="utf-8") == "old\n"
@pytest.mark.asyncio
async def test_create_source_rejects_scratch_path(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, ctx = workspace_context
create_source = _original_async(filesystem.create_source)
scratch = workspace / ".opensquilla-scratch"
scratch.mkdir()
ctx.scratch_dir = str(scratch)
with pytest.raises(ToolError, match="scratch"):
await create_source(".opensquilla-scratch/repro.py", "print('x')\n")
@pytest.mark.asyncio
async def test_write_scratch_writes_only_configured_scratch_dir(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, ctx = workspace_context
write_scratch = _original_async(filesystem.write_scratch)
scratch = workspace / ".opensquilla-scratch"
scratch.mkdir()
ctx.scratch_dir = str(scratch)
result = await write_scratch("repro.py", "print('x')\n")
payload = json.loads(result)
target = scratch / "repro.py"
assert payload["status"] == "written"
assert payload["path"] == "repro.py"
assert payload["scratch"] is True
assert payload["changed"] is True
assert payload["bytes"] == len(b"print('x')\n")
assert payload["sha256"]
assert target.read_text(encoding="utf-8") == "print('x')\n"
assert ctx.scratch_file_writes[-1]["relative_path"] == "repro.py"
assert ctx.workspace_mutation_receipts == []
@pytest.mark.asyncio
async def test_write_scratch_requires_scratch_dir(
workspace_context: tuple[Path, ToolContext],
) -> None:
write_scratch = _original_async(filesystem.write_scratch)
with pytest.raises(ToolError, match="scratch_dir"):
await write_scratch("repro.py", "print('x')\n")
@pytest.mark.asyncio
async def test_write_scratch_rejects_paths_outside_scratch(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, ctx = workspace_context
write_scratch = _original_async(filesystem.write_scratch)
scratch = workspace / ".opensquilla-scratch"
scratch.mkdir()
ctx.scratch_dir = str(scratch)
with pytest.raises(ToolError, match="scratch"):
await write_scratch("../src/app.py", "print('x')\n")
@pytest.mark.asyncio
async def test_source_symbols_returns_workspace_relative_symbol_receipts(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
target = workspace / "pkg" / "module.py"
target.parent.mkdir()
target.write_text(
"class Widget:\n"
" def render(self):\n"
" return 1\n\n"
"def helper(value):\n"
" return value\n",
encoding="utf-8",
)
result = await filesystem.source_symbols(query="helper", path="pkg")
payload = json.loads(result)
assert payload["status"] == "success"
assert payload["results"] == [
{
"path": "pkg/module.py",
"line": 5,
"kind": "function",
"name": "helper",
"preview": "def helper(value):",
}
]
@pytest.mark.asyncio
async def test_source_symbols_has_no_required_arguments(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
target = workspace / "src" / "app.go"
target.parent.mkdir()
target.write_text("package main\n\nfunc Run() {}\n", encoding="utf-8")
result = await filesystem.source_symbols()
payload = json.loads(result)
assert payload["status"] == "success"
assert payload["results"][0]["path"] == "src/app.go"
assert payload["results"][0]["name"] == "Run"
@pytest.mark.asyncio
async def test_edit_source_applies_revision_gated_line_edit(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, ctx = workspace_context
edit_source = _original_async(filesystem.edit_source)
target = workspace / "src" / "app.py"
target.parent.mkdir()
target.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
read_payload = json.loads(await filesystem.read_source("src/app.py", start_line=1, end_line=3))
result = await edit_source(
"src/app.py",
expected_revision=read_payload["revision"],
edits=[{"start_line": 2, "end_line": 2, "replacement": "BETA\n"}],
)
payload = json.loads(result)
assert payload["status"] == "applied"
assert payload["path"] == "src/app.py"
assert payload["changed"] is True
assert payload["before_revision"] == read_payload["revision"]
assert payload["after_revision"].startswith("file_")
assert payload["after_revision"] != read_payload["revision"]
assert payload["workspace_epoch"] == 1
assert "-beta" in payload["diff_summary"]
assert "+BETA" in payload["diff_summary"]
assert target.read_text(encoding="utf-8") == "alpha\nBETA\ngamma\n"
receipt = ctx.workspace_mutation_receipts[-1]
assert receipt["operation"] == "edit_source"
assert receipt["changed"] is True
assert receipt["workspace_epoch"] == 1
@pytest.mark.asyncio
async def test_edit_source_noop_does_not_increment_workspace_epoch(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, ctx = workspace_context
edit_source = _original_async(filesystem.edit_source)
target = workspace / "src" / "app.py"
target.parent.mkdir()
target.write_text("alpha\nbeta\n", encoding="utf-8")
read_payload = json.loads(await filesystem.read_source("src/app.py", start_line=1, end_line=2))
result = await edit_source(
"src/app.py",
expected_revision=read_payload["revision"],
edits=[{"start_line": 2, "end_line": 2, "replacement": "beta\n"}],
)
payload = json.loads(result)
assert payload["changed"] is False
assert payload["workspace_epoch"] == 0
assert ctx.workspace_epoch == 0
assert target.read_text(encoding="utf-8") == "alpha\nbeta\n"
@pytest.mark.asyncio
async def test_edit_source_revision_conflict_leaves_file_unchanged(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
edit_source = _original_async(filesystem.edit_source)
target = workspace / "src" / "app.py"
target.parent.mkdir()
target.write_text("alpha\nbeta\n", encoding="utf-8")
read_payload = json.loads(await filesystem.read_source("src/app.py", start_line=1, end_line=2))
target.write_text("alpha\nchanged\n", encoding="utf-8")
with pytest.raises(RetryableToolInputError, match="revision_conflict"):
await edit_source(
"src/app.py",
expected_revision=read_payload["revision"],
edits=[{"start_line": 2, "end_line": 2, "replacement": "BETA\n"}],
)
assert target.read_text(encoding="utf-8") == "alpha\nchanged\n"
@pytest.mark.asyncio
async def test_edit_source_invalid_line_edit_leaves_file_unchanged(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
edit_source = _original_async(filesystem.edit_source)
target = workspace / "src" / "app.py"
target.parent.mkdir()
target.write_text("alpha\nbeta\n", encoding="utf-8")
read_payload = json.loads(await filesystem.read_source("src/app.py", start_line=1, end_line=2))
with pytest.raises(RetryableToolInputError, match="line range"):
await edit_source(
"src/app.py",
expected_revision=read_payload["revision"],
edits=[{"start_line": 3, "end_line": 3, "replacement": "gamma\n"}],
)
assert target.read_text(encoding="utf-8") == "alpha\nbeta\n"
@pytest.mark.asyncio
async def test_edit_source_overlapping_ranges_leave_file_unchanged(
workspace_context: tuple[Path, ToolContext],
) -> None:
workspace, _ctx = workspace_context
edit_source = _original_async(filesystem.edit_source)
target = workspace / "src" / "app.py"
target.parent.mkdir()
target.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
read_payload = json.loads(await filesystem.read_source("src/app.py", start_line=1, end_line=3))
with pytest.raises(RetryableToolInputError, match="overlap"):
await edit_source(
"src/app.py",
expected_revision=read_payload["revision"],
edits=[
{"start_line": 1, "end_line": 2, "replacement": "x\n"},
{"start_line": 2, "end_line": 3, "replacement": "y\n"},
],
)
assert target.read_text(encoding="utf-8") == "alpha\nbeta\ngamma\n"