270 lines
8.1 KiB
Python
270 lines
8.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from opensquilla.engine.types import ToolCall
|
|
from opensquilla.tools.dispatch import build_tool_handler, preflight_tool_call
|
|
from opensquilla.tools.registry import ToolRegistry
|
|
from opensquilla.tools.types import ToolContext, ToolSpec
|
|
|
|
|
|
def _payload(result) -> dict[str, object]:
|
|
assert result is not None
|
|
return json.loads(result.content)
|
|
|
|
|
|
def _assert_invalid_execution_status(result, reason_code: str) -> None:
|
|
assert result is not None
|
|
assert result.execution_status is not None
|
|
assert result.execution_status["status"] == "error"
|
|
assert result.execution_status["reason"] == "invalid_tool_arguments"
|
|
assert result.execution_status["preflight_rejected"] is True
|
|
assert result.execution_status["reason_code"] == reason_code
|
|
|
|
|
|
def _registry_with_edit_file() -> ToolRegistry:
|
|
registry = ToolRegistry()
|
|
|
|
async def _edit_file(
|
|
path: str,
|
|
old_text: str | None = None,
|
|
new_text: str | None = None,
|
|
edits: list[dict[str, object]] | None = None,
|
|
) -> str:
|
|
raise AssertionError("preflight should not execute edit_file handler")
|
|
|
|
registry.register(
|
|
ToolSpec(
|
|
name="edit_file",
|
|
description="edit file",
|
|
parameters={
|
|
"path": {"type": "string"},
|
|
"old_text": {"type": "string"},
|
|
"new_text": {"type": "string"},
|
|
"edits": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"old_text": {"type": "string"},
|
|
"new_text": {"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
required=["path"],
|
|
),
|
|
_edit_file,
|
|
)
|
|
return registry
|
|
|
|
|
|
def _registry_with_apply_patch() -> ToolRegistry:
|
|
registry = ToolRegistry()
|
|
|
|
async def _apply_patch(patch: str | None = None, path: str | None = None) -> str:
|
|
raise AssertionError("preflight should not execute apply_patch handler")
|
|
|
|
registry.register(
|
|
ToolSpec(
|
|
name="apply_patch",
|
|
description="apply patch",
|
|
parameters={
|
|
"patch": {"type": "string"},
|
|
"path": {"type": "string"},
|
|
},
|
|
required=[],
|
|
),
|
|
_apply_patch,
|
|
)
|
|
return registry
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_path_only_is_rejected_before_handler() -> None:
|
|
events: list[dict[str, object]] = []
|
|
result = await preflight_tool_call(
|
|
registry=_registry_with_edit_file(),
|
|
ctx=ToolContext(on_runtime_event=events.append, session_key="agent:test"),
|
|
tool_call=ToolCall(
|
|
tool_use_id="u1",
|
|
tool_name="edit_file",
|
|
arguments={"path": "src/a.py"},
|
|
),
|
|
)
|
|
|
|
_assert_invalid_execution_status(result, "missing_executable_shape")
|
|
payload = _payload(result)
|
|
assert payload["status"] == "rejected"
|
|
assert payload["reason_code"] == "missing_executable_shape"
|
|
assert payload["tool"] == "edit_file"
|
|
assert payload["received_keys"] == ["path"]
|
|
assert payload["retry_allowed"] is True
|
|
assert payload["error_class"] == "InvalidToolArgumentsError"
|
|
assert "old_text" in payload["user_message"]
|
|
matching = [
|
|
event
|
|
for event in events
|
|
if event["name"] == "dispatch.invalid_tool_arguments"
|
|
]
|
|
assert len(matching) == 1
|
|
event = matching[0]
|
|
assert event["reason"] == "missing_executable_shape"
|
|
assert event["tool"] == "edit_file"
|
|
assert event["executed"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_path_only_handler_path_emits_one_invalid_event() -> None:
|
|
events: list[dict[str, object]] = []
|
|
handler = build_tool_handler(
|
|
_registry_with_edit_file(),
|
|
ToolContext(on_runtime_event=events.append, session_key="agent:test"),
|
|
)
|
|
|
|
result = await handler(
|
|
ToolCall(
|
|
tool_use_id="u1-handler",
|
|
tool_name="edit_file",
|
|
arguments={"path": "src/a.py"},
|
|
)
|
|
)
|
|
|
|
_assert_invalid_execution_status(result, "missing_executable_shape")
|
|
payload = _payload(result)
|
|
assert payload["error_class"] == "InvalidToolArgumentsError"
|
|
matching = [
|
|
event
|
|
for event in events
|
|
if event["name"] == "dispatch.invalid_tool_arguments"
|
|
]
|
|
assert len(matching) == 1
|
|
assert matching[0]["reason"] == "missing_executable_shape"
|
|
assert matching[0]["tool"] == "edit_file"
|
|
assert matching[0]["executed"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_single_replacement_shape_passes() -> None:
|
|
result = await preflight_tool_call(
|
|
registry=_registry_with_edit_file(),
|
|
ctx=ToolContext(),
|
|
tool_call=ToolCall(
|
|
tool_use_id="u2",
|
|
tool_name="edit_file",
|
|
arguments={
|
|
"path": "src/a.py",
|
|
"old_text": "before",
|
|
"new_text": "after",
|
|
},
|
|
),
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_whitespace_old_text_shape_passes() -> None:
|
|
result = await preflight_tool_call(
|
|
registry=_registry_with_edit_file(),
|
|
ctx=ToolContext(),
|
|
tool_call=ToolCall(
|
|
tool_use_id="u2-whitespace",
|
|
tool_name="edit_file",
|
|
arguments={
|
|
"path": "src/a.py",
|
|
"old_text": " ",
|
|
"new_text": "x",
|
|
},
|
|
),
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_multi_replacement_shape_passes() -> None:
|
|
result = await preflight_tool_call(
|
|
registry=_registry_with_edit_file(),
|
|
ctx=ToolContext(),
|
|
tool_call=ToolCall(
|
|
tool_use_id="u3",
|
|
tool_name="edit_file",
|
|
arguments={
|
|
"path": "src/a.py",
|
|
"edits": [{"old_text": "before", "new_text": "after"}],
|
|
},
|
|
),
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_multi_replacement_alias_shape_passes() -> None:
|
|
result = await preflight_tool_call(
|
|
registry=_registry_with_edit_file(),
|
|
ctx=ToolContext(),
|
|
tool_call=ToolCall(
|
|
tool_use_id="u3-alias",
|
|
tool_name="edit_file",
|
|
arguments={
|
|
"path": "src/a.py",
|
|
"edits": [{"oldText": "before", "newText": "after"}],
|
|
},
|
|
),
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_empty_arguments_are_rejected_before_handler() -> None:
|
|
events: list[dict[str, object]] = []
|
|
result = await preflight_tool_call(
|
|
registry=_registry_with_apply_patch(),
|
|
ctx=ToolContext(on_runtime_event=events.append, session_key="agent:test"),
|
|
tool_call=ToolCall(
|
|
tool_use_id="u4",
|
|
tool_name="apply_patch",
|
|
arguments={},
|
|
),
|
|
)
|
|
|
|
_assert_invalid_execution_status(result, "missing_executable_shape")
|
|
payload = _payload(result)
|
|
assert payload["status"] == "rejected"
|
|
assert payload["reason_code"] == "missing_executable_shape"
|
|
assert payload["tool"] == "apply_patch"
|
|
assert payload["received_keys"] == []
|
|
assert payload["retry_allowed"] is True
|
|
assert payload["error_class"] == "InvalidToolArgumentsError"
|
|
assert "patch" in payload["user_message"]
|
|
matching = [
|
|
event
|
|
for event in events
|
|
if event["name"] == "dispatch.invalid_tool_arguments"
|
|
]
|
|
assert len(matching) == 1
|
|
event = matching[0]
|
|
assert event["reason"] == "missing_executable_shape"
|
|
assert event["tool"] == "apply_patch"
|
|
assert event["executed"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_inline_patch_shape_passes() -> None:
|
|
result = await preflight_tool_call(
|
|
registry=_registry_with_apply_patch(),
|
|
ctx=ToolContext(),
|
|
tool_call=ToolCall(
|
|
tool_use_id="u5",
|
|
tool_name="apply_patch",
|
|
arguments={"patch": "*** Begin Patch\n*** End Patch"},
|
|
),
|
|
)
|
|
|
|
assert result is None
|