chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents import Agent, CustomTool, RunHooks
|
||||
from agents.editor import ApplyPatchOperation, ApplyPatchResult
|
||||
from agents.items import ToolApprovalItem, ToolCallOutputItem
|
||||
from agents.models.openai_responses import Converter
|
||||
from agents.run import RunConfig
|
||||
from agents.run_context import RunContextWrapper
|
||||
from agents.run_internal.run_steps import ToolRunCustom
|
||||
from agents.run_internal.tool_actions import CustomToolAction
|
||||
from agents.sandbox.capabilities.tools import SandboxApplyPatchTool
|
||||
from agents.sandbox.types import User
|
||||
from tests.sandbox._apply_patch_test_session import (
|
||||
ApplyPatchSession,
|
||||
UserRecordingApplyPatchSession,
|
||||
)
|
||||
from tests.utils.hitl import make_context_wrapper
|
||||
|
||||
|
||||
class TestSandboxApplyPatchTool:
|
||||
def test_exposes_custom_apply_patch_tool(self) -> None:
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession())
|
||||
|
||||
assert isinstance(tool, CustomTool)
|
||||
assert tool.name == "apply_patch"
|
||||
assert tool.tool_config["type"] == "custom"
|
||||
assert tool.tool_config["name"] == "apply_patch"
|
||||
assert tool.tool_config["format"]["type"] == "grammar"
|
||||
assert tool.tool_config["format"]["syntax"] == "lark"
|
||||
|
||||
def test_converter_uses_sandbox_custom_apply_patch_tool_config(self) -> None:
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession())
|
||||
|
||||
converted = Converter.convert_tools([tool], handoffs=[])
|
||||
|
||||
assert converted.tools[0]["type"] == "custom"
|
||||
assert converted.tools[0]["name"] == "apply_patch"
|
||||
description = converted.tools[0]["description"]
|
||||
assert isinstance(description, str)
|
||||
assert "This is a FREEFORM tool" in description
|
||||
assert "A full patch can combine several operations" in description
|
||||
tool_format = cast(dict[str, Any], converted.tools[0]["format"])
|
||||
assert tool_format["syntax"] == "lark"
|
||||
|
||||
def test_needs_approval_exposes_operation_typed_setting(self) -> None:
|
||||
async def needs_approval(
|
||||
_ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, _call_id: str
|
||||
) -> bool:
|
||||
return operation.type != "create_file"
|
||||
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession(), needs_approval=needs_approval)
|
||||
|
||||
assert cast(object, tool.needs_approval) is needs_approval
|
||||
assert cast(object, tool.operation_needs_approval) is needs_approval
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_needs_approval_assignment_drives_runtime_approval(self) -> None:
|
||||
async def needs_approval(
|
||||
_ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, _call_id: str
|
||||
) -> bool:
|
||||
return operation.type == "delete_file"
|
||||
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession())
|
||||
tool.needs_approval = needs_approval
|
||||
|
||||
result = await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=make_context_wrapper(),
|
||||
raw_input="*** Begin Patch\n*** Delete File: notes.txt\n*** End Patch\n",
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolApprovalItem)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_patch_input_surfaces_tool_error_after_approval_precheck(self) -> None:
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession(), needs_approval=True)
|
||||
|
||||
result = await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=make_context_wrapper(),
|
||||
raw_input="not a valid patch",
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolCallOutputItem)
|
||||
assert "apply_patch input must start with '*** Begin Patch'" in result.output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_create_update_delete_round_trip(self) -> None:
|
||||
session = ApplyPatchSession()
|
||||
tool = SandboxApplyPatchTool(session=session)
|
||||
|
||||
create_result = await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.create_file(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="notes.txt",
|
||||
diff="+hello\n+world\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
assert isinstance(create_result, ApplyPatchResult)
|
||||
assert create_result.output == "Created notes.txt"
|
||||
assert session.files[Path("/workspace/notes.txt")] == b"hello\nworld"
|
||||
|
||||
update_result = await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.update_file(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="notes.txt",
|
||||
diff="@@\n-hello\n+hi\n world\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
assert isinstance(update_result, ApplyPatchResult)
|
||||
assert update_result.output == "Updated notes.txt"
|
||||
assert session.files[Path("/workspace/notes.txt")] == b"hi\nworld"
|
||||
|
||||
delete_result = await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.delete_file(
|
||||
ApplyPatchOperation(
|
||||
type="delete_file",
|
||||
path="notes.txt",
|
||||
)
|
||||
),
|
||||
)
|
||||
assert isinstance(delete_result, ApplyPatchResult)
|
||||
assert delete_result.output == "Deleted notes.txt"
|
||||
assert Path("/workspace/notes.txt") not in session.files
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_runs_file_operations_as_bound_user(self) -> None:
|
||||
session = UserRecordingApplyPatchSession()
|
||||
session.files[Path("/workspace/existing.txt")] = b"old\n"
|
||||
tool = SandboxApplyPatchTool(session=session, user=User(name="sandbox-user"))
|
||||
|
||||
await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.update_file(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="existing.txt",
|
||||
diff="@@\n-old\n+new\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.create_file(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="created.txt",
|
||||
diff="+created\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.delete_file(
|
||||
ApplyPatchOperation(
|
||||
type="delete_file",
|
||||
path="existing.txt",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert session.read_users == ["sandbox-user", "sandbox-user"]
|
||||
assert session.mkdir_users == ["sandbox-user", "sandbox-user"]
|
||||
assert session.write_users == ["sandbox-user", "sandbox-user"]
|
||||
assert session.rm_users == ["sandbox-user"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_tool_input_create_update_move_delete(self) -> None:
|
||||
session = ApplyPatchSession()
|
||||
tool = SandboxApplyPatchTool(session=session)
|
||||
context_wrapper = make_context_wrapper()
|
||||
|
||||
await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=context_wrapper,
|
||||
raw_input=("*** Begin Patch\n*** Add File: notes.txt\n+hello\n+world\n*** End Patch\n"),
|
||||
)
|
||||
assert session.files[Path("/workspace/notes.txt")] == b"hello\nworld"
|
||||
|
||||
result = await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=context_wrapper,
|
||||
raw_input=(
|
||||
"*** Begin Patch\n"
|
||||
"*** Update File: notes.txt\n"
|
||||
"*** Move to: moved.txt\n"
|
||||
"@@\n"
|
||||
"-hello\n"
|
||||
"+hi\n"
|
||||
" world\n"
|
||||
"*** End Patch\n"
|
||||
),
|
||||
)
|
||||
assert "Updated notes.txt" in result.output
|
||||
assert "Moved notes.txt to moved.txt" in result.output
|
||||
assert Path("/workspace/notes.txt") not in session.files
|
||||
assert session.files[Path("/workspace/moved.txt")] == b"hi\nworld"
|
||||
|
||||
await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=context_wrapper,
|
||||
raw_input="*** Begin Patch\n*** Delete File: moved.txt\n*** End Patch\n",
|
||||
)
|
||||
assert Path("/workspace/moved.txt") not in session.files
|
||||
|
||||
|
||||
async def _execute_custom_tool_call(
|
||||
tool: SandboxApplyPatchTool,
|
||||
*,
|
||||
context_wrapper: RunContextWrapper[Any],
|
||||
raw_input: str,
|
||||
) -> Any:
|
||||
result = await CustomToolAction.execute(
|
||||
agent=Agent(name="patcher", tools=[tool]),
|
||||
call=ToolRunCustom(
|
||||
custom_tool=tool,
|
||||
tool_call={
|
||||
"type": "custom_tool_call",
|
||||
"name": "apply_patch",
|
||||
"call_id": "call_apply",
|
||||
"input": raw_input,
|
||||
},
|
||||
),
|
||||
hooks=RunHooks[Any](),
|
||||
context_wrapper=context_wrapper,
|
||||
config=RunConfig(),
|
||||
)
|
||||
return result
|
||||
Reference in New Issue
Block a user