195 lines
6.0 KiB
Python
195 lines
6.0 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from opensquilla.engine.types import ToolCall
|
|
from opensquilla.result_budget import ToolResultBudgetPolicy
|
|
from opensquilla.tools.dispatch import build_tool_handler
|
|
from opensquilla.tools.registry import ToolRegistry
|
|
from opensquilla.tools.types import ToolContext, ToolSpec
|
|
|
|
|
|
def _registry(name: str, result: str) -> ToolRegistry:
|
|
registry = ToolRegistry()
|
|
|
|
async def handler() -> str:
|
|
return result
|
|
|
|
registry.register(ToolSpec(name=name, description=name, parameters={}), handler)
|
|
return registry
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_propagates_cancellation_to_outer_timeout() -> None:
|
|
registry = ToolRegistry()
|
|
cancelled = asyncio.Event()
|
|
|
|
async def handler() -> str:
|
|
try:
|
|
await asyncio.sleep(1.0)
|
|
except asyncio.CancelledError:
|
|
cancelled.set()
|
|
raise
|
|
return "late"
|
|
|
|
registry.register(
|
|
ToolSpec(name="slow_tool", description="slow_tool", parameters={}), handler
|
|
)
|
|
handler = build_tool_handler(registry)
|
|
|
|
with pytest.raises(asyncio.TimeoutError):
|
|
await asyncio.wait_for(
|
|
handler(ToolCall("call_slow_timeout", "slow_tool", {})),
|
|
timeout=0.01,
|
|
)
|
|
|
|
assert cancelled.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_command_nonzero_exit_gets_trusted_execution_status() -> None:
|
|
handler = build_tool_handler(_registry("exec_command", "exit_code=2\nfailed"))
|
|
|
|
result = await handler(ToolCall("call_exec_1", "exec_command", {}))
|
|
|
|
assert result.is_error is True
|
|
assert result.execution_status == {
|
|
"version": 1,
|
|
"status": "error",
|
|
"exit_code": 2,
|
|
"timed_out": False,
|
|
"truncated": False,
|
|
"reason": "nonzero_exit",
|
|
"source": "adapter",
|
|
"preservation_class": "diagnostic",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_code_timeout_gets_trusted_execution_status() -> None:
|
|
handler = build_tool_handler(
|
|
_registry(
|
|
"execute_code",
|
|
json.dumps(
|
|
{
|
|
"exit_code": 124,
|
|
"stdout": "",
|
|
"stderr": "timed out",
|
|
"timed_out": True,
|
|
}
|
|
),
|
|
)
|
|
)
|
|
|
|
result = await handler(ToolCall("call_code_1", "execute_code", {}))
|
|
|
|
assert result.is_error is True
|
|
assert result.execution_status["status"] == "timeout"
|
|
assert result.execution_status["timed_out"] is True
|
|
assert result.execution_status["reason"] == "tool_timeout"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unmapped_json_is_not_trusted_as_execution_status() -> None:
|
|
handler = build_tool_handler(_registry("unknown_tool", json.dumps({"exit_code": 1})))
|
|
|
|
result = await handler(ToolCall("call_unknown_1", "unknown_tool", {}))
|
|
|
|
assert result.is_error is False
|
|
assert result.execution_status is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_code_json_without_exit_code_is_not_trusted() -> None:
|
|
handler = build_tool_handler(_registry("execute_code", json.dumps({"ok": False})))
|
|
|
|
result = await handler(ToolCall("call_code_untrusted", "execute_code", {}))
|
|
|
|
assert result.is_error is False
|
|
assert result.execution_status is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_process_running_is_unknown_non_error() -> None:
|
|
handler = build_tool_handler(
|
|
_registry("background_process", "session_id=abc123\ncommand: sleep 1\nstatus: running")
|
|
)
|
|
|
|
result = await handler(ToolCall("call_bg_running", "background_process", {}))
|
|
|
|
assert result.is_error is False
|
|
assert result.execution_status is not None
|
|
assert result.execution_status["status"] == "unknown"
|
|
assert result.execution_status["reason"] == "background_running"
|
|
assert result.execution_status["preservation_class"] == "ephemeral"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_process_terminal_nonzero_is_error() -> None:
|
|
handler = build_tool_handler(
|
|
_registry(
|
|
"process",
|
|
json.dumps(
|
|
{
|
|
"status": "ok",
|
|
"action": "poll",
|
|
"session": {
|
|
"status": "done",
|
|
"returncode": 7,
|
|
"timed_out": False,
|
|
"killed": False,
|
|
},
|
|
}
|
|
),
|
|
)
|
|
)
|
|
|
|
result = await handler(ToolCall("call_bg_failed", "process", {}))
|
|
|
|
assert result.is_error is True
|
|
assert result.execution_status is not None
|
|
assert result.execution_status["status"] == "error"
|
|
assert result.execution_status["exit_code"] == 7
|
|
assert result.execution_status["reason"] == "nonzero_exit"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approval_denial_preserves_approval_denied_reason() -> None:
|
|
handler = build_tool_handler(
|
|
_registry(
|
|
"exec_command",
|
|
json.dumps({"status": "approval_denied", "message": "operator rejected"}),
|
|
)
|
|
)
|
|
|
|
result = await handler(ToolCall("call_approval_denied", "exec_command", {}))
|
|
|
|
assert result.is_error is True
|
|
assert result.terminates_turn is False
|
|
assert result.execution_status is not None
|
|
assert result.execution_status["status"] == "error"
|
|
assert result.execution_status["reason"] == "approval_denied"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_budget_truncation_marks_status_truncated_without_changing_failure() -> None:
|
|
handler = build_tool_handler(
|
|
_registry("exec_command", "exit_code=1\n" + ("x" * 2000)),
|
|
ToolContext(
|
|
tool_result_budget_policy=ToolResultBudgetPolicy(
|
|
max_single_tool_result_chars=160,
|
|
max_tool_result_chars_per_turn=160,
|
|
)
|
|
),
|
|
)
|
|
|
|
result = await handler(ToolCall("call_exec_2", "exec_command", {}))
|
|
|
|
assert result.is_error is True
|
|
assert result.execution_status["status"] == "error"
|
|
assert result.execution_status["truncated"] is True
|
|
assert result.execution_status["preservation_class"] == "retain_summary"
|