"""Tests for arcade_core.errors. Covers the empty-message guard in ``ToolkitError.with_context()`` — without it, ``raise FatalToolError("")`` produces prefixed text like ``"...tool 'foo': "`` that carries no diagnostic payload in logs/agent output. """ import pytest from arcade_core.errors import ( ErrorKind, FatalToolError, NetworkTransportError, RetryableToolError, ToolkitLoadError, UpstreamError, ) from arcade_core.schema import ToolCallError @pytest.mark.parametrize("empty_message", ["", " ", "\t", "\n \n"]) def test_with_context_empty_message_substitutes_placeholder(empty_message): err = FatalToolError(empty_message).with_context("my_tool") # Prefix is preserved — kind, error type and tool name are still in the message. assert "[TOOL_RUNTIME_FATAL]" in err.message assert "my_tool" in err.message # And the empty body is replaced with a recognizable placeholder so the # message ends with diagnostic content rather than ``": "``. assert "(no details provided)" in err.message assert not err.message.endswith(": ") def test_with_context_nonempty_message_unchanged(): err = FatalToolError("Spreadsheet not found").with_context("get_sheet") assert err.message.endswith(": Spreadsheet not found") assert "(no details provided)" not in err.message def test_with_context_developer_message_with_empty_message_still_works(): # A non-empty developer_message is preserved alongside the placeholder body. err = FatalToolError("", developer_message="trace: foo.py:42").with_context("my_tool") assert "(no details provided)" in err.message assert err.developer_message is not None assert err.developer_message.endswith(": trace: foo.py:42") def test_with_context_retryable_error_empty_message(): err = RetryableToolError(" ").with_context("flaky_tool") assert "[TOOL_RUNTIME_RETRY]" in err.message assert "(no details provided)" in err.message def test_with_context_toolkit_load_error_empty_message(): err = ToolkitLoadError("").with_context("broken_toolkit") assert "broken_toolkit" in err.message assert "(no details provided)" in err.message # ---- NetworkTransportError ------------------------------------------------- def test_network_transport_error_is_sibling_to_upstream_error(): """NetworkTransportError and UpstreamError serve different semantic roles. The classification helpers must not mix them up — any consumer keying on ``is_upstream_error`` (telemetry dashboards, retry playbooks) relies on that distinction being clean. """ nte = NetworkTransportError("boom") ue = UpstreamError("boom", status_code=500) assert nte.is_network_transport_error is True assert nte.is_upstream_error is False assert ue.is_upstream_error is True assert ue.is_network_transport_error is False def test_network_transport_error_defaults(): err = NetworkTransportError("boom") assert err.kind is ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED assert err.can_retry is True # No complete response was received, so there is no status code. assert err.status_code is None def test_network_transport_error_rejects_non_network_kind(): """The class invariant — kind must be in the NETWORK_TRANSPORT_ namespace — protects telemetry and classification helpers from accidental pollution.""" with pytest.raises(ValueError, match="NETWORK_TRANSPORT_"): NetworkTransportError("x", kind=ErrorKind.UPSTREAM_RUNTIME_SERVER_ERROR) def test_network_transport_error_to_payload_omits_status_code(): err = NetworkTransportError( "timed out", kind=ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT, can_retry=True, extra={"error_type": "PoolTimeout"}, ) payload = err.to_payload() assert payload["status_code"] is None assert payload["kind"] is ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT assert payload["can_retry"] is True assert payload["error_type"] == "PoolTimeout" # ---- ToolCallError classification properties (wire-format schema) ---------- @pytest.mark.parametrize( "kind, expected_tool, expected_upstream, expected_network, expected_toolkit", [ (ErrorKind.TOOL_RUNTIME_FATAL, True, False, False, False), (ErrorKind.TOOL_RUNTIME_RETRY, True, False, False, False), (ErrorKind.TOOL_RUNTIME_CONTEXT_REQUIRED, True, False, False, False), (ErrorKind.TOOL_RUNTIME_BAD_INPUT_VALUE, True, False, False, False), (ErrorKind.TOOL_RUNTIME_BAD_OUTPUT_VALUE, True, False, False, False), (ErrorKind.UPSTREAM_RUNTIME_SERVER_ERROR, False, True, False, False), (ErrorKind.UPSTREAM_RUNTIME_AUTH_ERROR, False, True, False, False), (ErrorKind.UPSTREAM_RUNTIME_NOT_FOUND, False, True, False, False), (ErrorKind.UPSTREAM_RUNTIME_RATE_LIMIT, False, True, False, False), (ErrorKind.UPSTREAM_RUNTIME_BAD_REQUEST, False, True, False, False), (ErrorKind.UPSTREAM_RUNTIME_UNMAPPED, False, True, False, False), (ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT, False, False, True, False), (ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNREACHABLE, False, False, True, False), (ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED, False, False, True, False), (ErrorKind.TOOLKIT_LOAD_FAILED, False, False, False, True), ], ) def test_tool_call_error_classification_properties( kind, expected_tool, expected_upstream, expected_network, expected_toolkit ): """ToolCallError (Pydantic wire-format model) classification helpers must be consistent with the ToolkitError class hierarchy helpers.""" err = ToolCallError(message="test", kind=kind, can_retry=False) assert err.is_tool_error is expected_tool assert err.is_upstream_error is expected_upstream assert err.is_network_transport_error is expected_network assert err.is_toolkit_error is expected_toolkit