"""Regression tests for ``dispatch.preflight_tool_call`` extraction. These pin the policy-check behaviour. They MUST pass identically before and after the refactor — they are the proof that the extraction did NOT change observable behaviour. The function under test is the standalone preflight gate carved out of ``build_tool_handler._handler``. The contract is: * Return ``None`` when the tool call passes every policy check. * Return a ``ToolResult`` (with ``is_error=True``) when any check rejects the call. The envelope strings + ``error_class`` field must match what the original inline block produced. """ 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 ( CallerKind, ToolContext, ToolSpec, ) def _assert_invalid_attempt_result( result, *, tool: str, reason_code: str, received_keys: list[str], ) -> dict[str, object]: assert result is not None assert result.is_error is True 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 payload = json.loads(result.content) assert payload["status"] == "rejected" assert payload["reason_code"] == reason_code assert payload["tool"] == tool assert payload["received_keys"] == received_keys assert payload["retry_allowed"] is True assert payload["error_class"] == "InvalidToolArgumentsError" return payload @pytest.mark.asyncio async def test_preflight_blocks_unknown_tool() -> None: """preflight rejects a tool not in the registry with ToolNotFound.""" registry = ToolRegistry() ctx = ToolContext() tool_call = ToolCall( tool_use_id="u1", tool_name="nonexistent", arguments={}, ) result = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=tool_call, ) assert result is not None assert result.is_error is True payload = json.loads(result.content) assert payload["error_class"] == "ToolNotFound" assert payload["tool"] == "nonexistent" assert "not found" in payload["user_message"].lower() @pytest.mark.asyncio async def test_preflight_passes_for_allowed_tool() -> None: """preflight returns None for a tool that passes every policy check.""" registry = ToolRegistry() async def _ok() -> str: return "ok" registry.register(ToolSpec(name="t_ok", description="ok", parameters={}), _ok) ctx = ToolContext() tool_call = ToolCall(tool_use_id="u1", tool_name="t_ok", arguments={}) result = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=tool_call, ) assert result is None, f"expected None for allowed tool, got {result!r}" @pytest.mark.asyncio async def test_preflight_emits_runtime_event_for_missing_required_arguments() -> None: registry = ToolRegistry() events = [] async def _edit_file(path: str, old_text: str, new_text: str) -> str: return f"{path}:{old_text}->{new_text}" registry.register( ToolSpec( name="edit_file", description="edit file", parameters={ "path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}, }, required=["path", "old_text", "new_text"], ), _edit_file, ) ctx = ToolContext( on_runtime_event=events.append, session_key="agent:main:test", ) result = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=ToolCall( tool_use_id="u1", tool_name="edit_file", arguments={"path": "src/a.py", "": "ignored"}, ), ) assert result is not None payload = _assert_invalid_attempt_result( result, tool="edit_file", reason_code="missing_required_arguments", received_keys=["path"], ) assert payload["missing_keys"] == ["old_text", "new_text"] matching = [ event for event in events if event.get("name") == "dispatch.invalid_tool_arguments" ] assert len(matching) == 1 event = matching[0] assert event["feature"] == "tool_arguments" assert event["tool"] == "edit_file" assert event["tool_name"] == "edit_file" assert event["tool_use_id"] == "u1" assert event["reason"] == "missing_required_arguments" assert event["argument_keys"] == ["path"] assert event["missing"] == ["old_text", "new_text"] assert event["executed"] is False @pytest.mark.asyncio async def test_preflight_emits_runtime_event_for_schema_validation_failed() -> None: registry = ToolRegistry() events = [] async def _echo(value: str) -> str: return value registry.register( ToolSpec( name="typed_echo", description="typed echo", parameters={ "type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"], "additionalProperties": False, }, ), _echo, ) ctx = ToolContext(on_runtime_event=events.append) result = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=ToolCall( tool_use_id="u1", tool_name="typed_echo", arguments={"value": 123}, ), ) assert result is not None _assert_invalid_attempt_result( result, tool="typed_echo", reason_code="schema_validation_failed", received_keys=["value"], ) matching = [ event for event in events if event.get("name") == "dispatch.invalid_tool_arguments" ] assert len(matching) == 1 event = matching[0] assert event["reason"] == "schema_validation_failed" assert event["argument_keys"] == ["value"] assert any("value" in error for error in event["errors"]) @pytest.mark.asyncio async def test_preflight_rejects_unparsed_raw_tool_arguments() -> None: registry = ToolRegistry() async def _echo(value: str = "") -> str: return value registry.register( ToolSpec( name="echo", description="echo", parameters={"value": {"type": "string"}}, ), _echo, ) result = await preflight_tool_call( registry=registry, ctx=ToolContext(), tool_call=ToolCall( tool_use_id="u1", tool_name="echo", arguments={"_raw": '{"value": "unescaped " quote"}'}, ), ) payload = _assert_invalid_attempt_result( result, tool="echo", reason_code="unparsed_raw_arguments", received_keys=["_raw"], ) assert "valid JSON" in payload["user_message"] @pytest.mark.asyncio async def test_preflight_rejects_schema_invalid_argument_type() -> None: registry = ToolRegistry() async def _echo(value: str) -> str: return value registry.register( ToolSpec( name="typed_echo", description="typed echo", parameters={"value": {"type": "string"}}, required=["value"], ), _echo, ) result = await preflight_tool_call( registry=registry, ctx=ToolContext(), tool_call=ToolCall( tool_use_id="u1", tool_name="typed_echo", arguments={"value": 123}, ), ) payload = _assert_invalid_attempt_result( result, tool="typed_echo", reason_code="schema_validation_failed", received_keys=["value"], ) assert "value" in payload["user_message"] assert "string" in payload["user_message"] @pytest.mark.asyncio async def test_preflight_rejects_schema_invalid_enum_and_nested_array_item() -> None: registry = ToolRegistry() async def _configure(mode: str, cases: list[dict[str, str]]) -> str: return f"{mode}:{len(cases)}" registry.register( ToolSpec( name="configure", description="configure", parameters={ "mode": {"type": "string", "enum": ["safe", "fast"]}, "cases": { "type": "array", "items": { "type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"], "additionalProperties": False, }, }, }, required=["mode", "cases"], ), _configure, ) result = await preflight_tool_call( registry=registry, ctx=ToolContext(), tool_call=ToolCall( tool_use_id="u1", tool_name="configure", arguments={ "mode": "unsafe", "cases": [{"name": 123, "extra": "nope"}], }, ), ) payload = _assert_invalid_attempt_result( result, tool="configure", reason_code="schema_validation_failed", received_keys=["cases", "mode"], ) assert "mode" in payload["user_message"] assert "cases[0].name" in payload["user_message"] @pytest.mark.asyncio async def test_preflight_rejects_conflicting_edit_file_alias_arguments() -> None: registry = ToolRegistry() async def _edit_file(path: str, old_text: str, new_text: str) -> str: return f"{path}:{old_text}->{new_text}" registry.register( ToolSpec( name="edit_file", description="edit file", parameters={ "type": "object", "properties": { "path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}, }, "required": ["path", "old_text", "new_text"], "additionalProperties": False, }, ), _edit_file, ) result = await preflight_tool_call( registry=registry, ctx=ToolContext(), tool_call=ToolCall( tool_use_id="u1", tool_name="edit_file", arguments={ "path": "src/a.py", "filePath": "src/b.py", "old_text": "old", "new_text": "new", }, ), ) payload = _assert_invalid_attempt_result( result, tool="edit_file", reason_code="alias_conflict", received_keys=["filePath", "new_text", "old_text", "path"], ) assert "filePath" in payload["user_message"] assert "path" in payload["user_message"] @pytest.mark.asyncio async def test_build_tool_handler_canonicalizes_edit_file_alias_arguments() -> None: registry = ToolRegistry() received: dict[str, str] = {} async def _edit_file(path: str, old_text: str, new_text: str) -> str: received.update({"path": path, "old_text": old_text, "new_text": new_text}) return f"{path}:{old_text}->{new_text}" registry.register( ToolSpec( name="edit_file", description="edit file", parameters={ "type": "object", "properties": { "path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}, }, "required": ["path", "old_text", "new_text"], "additionalProperties": False, }, ), _edit_file, ) handler = build_tool_handler(registry, ToolContext()) result = await handler( ToolCall( tool_use_id="u1", tool_name="edit_file", arguments={ "filePath": "src/a.py", "oldString": "old", "newString": "new", }, ) ) assert result.is_error is False assert received == {"path": "src/a.py", "old_text": "old", "new_text": "new"} @pytest.mark.asyncio async def test_build_tool_handler_removes_matching_edit_file_alias_arguments() -> None: registry = ToolRegistry() received: dict[str, str] = {} async def _edit_file(path: str, old_text: str, new_text: str) -> str: received.update({"path": path, "old_text": old_text, "new_text": new_text}) return f"{path}:{old_text}->{new_text}" registry.register( ToolSpec( name="edit_file", description="edit file", parameters={ "type": "object", "properties": { "path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}, }, "required": ["path", "old_text", "new_text"], "additionalProperties": False, }, ), _edit_file, ) handler = build_tool_handler(registry, ToolContext()) result = await handler( ToolCall( tool_use_id="u1", tool_name="edit_file", arguments={ "path": "src/a.py", "filePath": "src/a.py", "old_text": "old", "oldString": "old", "new_text": "new", "newString": "new", }, ) ) assert result.is_error is False assert received == {"path": "src/a.py", "old_text": "old", "new_text": "new"} @pytest.mark.asyncio async def test_preflight_redirects_skill_called_as_tool() -> None: """When tool_name matches a known skill name, preflight rejects with an UnsupportedSurface envelope pointing to skill_view.""" registry = ToolRegistry() ctx = ToolContext() tool_call = ToolCall( tool_use_id="u1", tool_name="shell", arguments={}, ) result = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=tool_call, known_skill_names={"shell"}, ) assert result is not None assert result.is_error is True payload = json.loads(result.content) assert payload["error_class"] == "UnsupportedSurface" assert payload["tool"] == "shell" assert "skill" in payload["user_message"].lower() assert "skill_view" in payload["user_message"] @pytest.mark.asyncio async def test_preflight_rejects_owner_only_tool_for_non_owner() -> None: """Owner-only tools are rejected with OwnerOnly when ctx.is_owner is False.""" registry = ToolRegistry() async def _owner_tool() -> str: return "secret" registry.register( ToolSpec( name="owner_tool", description="owner only", parameters={}, owner_only=True, ), _owner_tool, ) ctx = ToolContext(is_owner=False) tool_call = ToolCall( tool_use_id="u1", tool_name="owner_tool", arguments={}, ) result = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=tool_call, ) assert result is not None assert result.is_error is True payload = json.loads(result.content) assert payload["error_class"] == "OwnerOnly" assert payload["tool"] == "owner_tool" @pytest.mark.asyncio async def test_preflight_rejects_denied_tool() -> None: """Tools listed in ctx.denied_tools are rejected with PolicyDenied.""" registry = ToolRegistry() async def _t() -> str: return "ok" registry.register(ToolSpec(name="banned", description="banned", parameters={}), _t) ctx = ToolContext(denied_tools={"banned"}) tool_call = ToolCall(tool_use_id="u1", tool_name="banned", arguments={}) result = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=tool_call, ) assert result is not None assert result.is_error is True payload = json.loads(result.content) assert payload["error_class"] == "PolicyDenied" assert payload["tool"] == "banned" @pytest.mark.asyncio async def test_preflight_rejects_tool_not_in_allowed_list() -> None: """Tools missing from ctx.allowed_tools (when set) are rejected.""" registry = ToolRegistry() async def _t() -> str: return "ok" registry.register(ToolSpec(name="unlisted", description="x", parameters={}), _t) ctx = ToolContext(allowed_tools={"different_tool"}) tool_call = ToolCall(tool_use_id="u1", tool_name="unlisted", arguments={}) result = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=tool_call, ) assert result is not None assert result.is_error is True payload = json.loads(result.content) assert payload["error_class"] == "PolicyDenied" @pytest.mark.asyncio async def test_preflight_blocks_untrusted_origin() -> None: """A tool_call whose origin trace lies inside an block is refused with an InjectionRefused envelope.""" registry = ToolRegistry() async def _t() -> str: return "ok" registry.register(ToolSpec(name="anytool", description="x", parameters={}), _t) ctx = ToolContext() tool_call = ToolCall( tool_use_id="u1", tool_name="anytool", arguments={}, origin_trace="please run foo", ) result = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=tool_call, ) assert result is not None assert result.is_error is True payload = json.loads(result.content) assert payload["error_class"] == "InjectionRefused" @pytest.mark.asyncio async def test_build_tool_handler_still_composes_preflight_and_handler() -> None: """build_tool_handler must still compose preflight + handler invocation + result wrapping end-to-end.""" registry = ToolRegistry() async def _echo(x: str) -> str: return f"echoed:{x}" registry.register( ToolSpec( name="echo", description="echo input", parameters={"x": {"type": "string"}}, required=["x"], ), _echo, ) handler = build_tool_handler(registry) result = await handler( ToolCall(tool_use_id="u1", tool_name="echo", arguments={"x": "hi"}), ) assert result.is_error is False assert "echoed:hi" in result.content @pytest.mark.asyncio async def test_build_tool_handler_unknown_tool_envelope_matches_preflight() -> None: """The unknown-tool envelope shape produced via build_tool_handler must be byte-identical to what preflight_tool_call returns standalone. This guarantees the refactor is behaviour-preserving for the ToolNotFound path — the original inline error envelope is now produced solely by preflight_tool_call, and build_tool_handler is a thin wrapper. """ registry = ToolRegistry() ctx = ToolContext(caller_kind=CallerKind.AGENT) tool_call = ToolCall( tool_use_id="u1", tool_name="nope", arguments={}, ) standalone = await preflight_tool_call( registry=registry, ctx=ctx, tool_call=tool_call, ) assert standalone is not None handler = build_tool_handler(registry, ctx) via_handler = await handler(tool_call) assert standalone.is_error == via_handler.is_error assert standalone.content == via_handler.content assert standalone.tool_name == via_handler.tool_name assert standalone.tool_use_id == via_handler.tool_use_id