494 lines
16 KiB
Python
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"
|