Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/opencode_a2a/client/error_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,7 @@ def map_agent_card_error(
) -> A2AClientError:
if isinstance(exc, AgentCardResolutionError):
if exc.status_code is not None:
return _attach_http_status(
map_client_error("agent-card/fetch", SDKClientError(str(exc))),
exc.status_code,
)
return map_http_error("agent-card/fetch", exc)
return A2APeerProtocolError(
"Remote A2A peer returned an invalid agent card payload",
error_code="invalid_agent_card",
Expand Down
49 changes: 5 additions & 44 deletions src/opencode_a2a/jsonrpc/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,56 +94,23 @@ def _normalize_session_query_limit(
return {"limit": normalized_limit}


def _normalize_alias_field(
*,
params: dict[str, Any],
field: str,
parser,
) -> Any:
return parser(params.get(field), field=field)


def parse_list_sessions_params(params: dict[str, Any]) -> dict[str, Any]:
_reject_nested_query_params(params)
_validate_pagination_fields(params)
normalized_query = _normalize_session_query_limit(limit=params.get("limit"))
directory = _normalize_alias_field(
params=params,
field="directory",
parser=_parse_string_field,
)
roots = _normalize_alias_field(
params=params,
field="roots",
parser=_parse_bool_field,
)
start = _normalize_alias_field(
params=params,
field="start",
parser=_parse_non_negative_int,
)
search = _normalize_alias_field(
params=params,
field="search",
parser=_parse_string_field,
)
directory = _parse_string_field(params.get("directory"), field="directory")
roots = _parse_bool_field(params.get("roots"), field="roots")
start = _parse_non_negative_int(params.get("start"), field="start")
search = _parse_string_field(params.get("search"), field="search")

if directory is not None:
normalized_query["directory"] = directory
else:
normalized_query.pop("directory", None)
if roots is not None:
normalized_query["roots"] = roots
else:
normalized_query.pop("roots", None)
if start is not None:
normalized_query["start"] = start
else:
normalized_query.pop("start", None)
if search is not None:
normalized_query["search"] = search
else:
normalized_query.pop("search", None)
return normalized_query


Expand All @@ -158,13 +125,7 @@ def parse_get_session_messages_params(params: dict[str, Any]) -> tuple[str, dict
_reject_nested_query_params(params)
_validate_pagination_fields(params)
normalized_query = _normalize_session_query_limit(limit=params.get("limit"))
before = _normalize_alias_field(
params=params,
field="before",
parser=_parse_string_field,
)
before = _parse_string_field(params.get("before"), field="before")
if before is not None:
normalized_query["before"] = before
else:
normalized_query.pop("before", None)
return raw_session_id.strip(), normalized_query
171 changes: 170 additions & 1 deletion tests/client/test_error_mapping.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
from __future__ import annotations

import httpx
import pytest
from a2a.client.errors import (
A2AClientError as SDKClientError,
)
from a2a.client.errors import (
A2AClientTimeoutError,
AgentCardResolutionError,
)
from a2a.utils.errors import (
A2AError,
InvalidParamsError,
MethodNotFoundError,
TaskNotFoundError,
VersionNotSupportedError,
)

from opencode_a2a.client.error_mapping import (
map_a2a_error,
map_agent_card_error,
map_client_error,
map_http_error,
map_jsonrpc_error,
map_operation_error,
Expand All @@ -25,10 +42,129 @@
)


@pytest.mark.parametrize(
("exc", "expected_type", "error_code", "data"),
[
pytest.param(
TaskNotFoundError("missing"),
A2AUnsupportedOperationError,
"task_not_found",
None,
id="task-not-found",
),
pytest.param(
MethodNotFoundError("unsupported"),
A2AUnsupportedOperationError,
"method_not_supported",
None,
id="method-not-found",
),
pytest.param(
InvalidParamsError("bad", data={"field": "limit"}),
A2APeerProtocolError,
"invalid_params",
{"field": "limit"},
id="invalid-params",
),
pytest.param(
VersionNotSupportedError("bad version", data={"version": "2.0"}),
A2AUnsupportedOperationError,
"version_not_supported",
{"version": "2.0"},
id="unsupported-version",
),
pytest.param(
A2AError("generic", data={"detail": "boom"}),
A2APeerProtocolError,
"peer_protocol_error",
{"detail": "boom"},
id="generic-a2a-error",
),
],
)
def test_map_a2a_error_variants(
exc: A2AError,
expected_type: type[Exception],
error_code: str,
data: object | None,
) -> None:
mapped = map_a2a_error(exc)

assert isinstance(mapped, expected_type)
assert mapped.error_code == error_code
assert mapped.data == data


@pytest.mark.parametrize(
("exc", "expected_type", "http_status"),
[
pytest.param(
FakeA2AClientHTTPError(401, "denied"),
A2AAuthenticationError,
401,
id="401",
),
pytest.param(
FakeA2AClientHTTPError(403, "forbidden"),
A2APermissionDeniedError,
403,
id="403",
),
pytest.param(
FakeA2AClientHTTPError(404, "missing"),
A2AUnsupportedOperationError,
404,
id="404",
),
pytest.param(
FakeA2AClientHTTPError(408, "slow"),
A2ATimeoutError,
408,
id="408",
),
pytest.param(
SDKClientError("HTTP Error 503: busy"),
A2AClientResetRequiredError,
503,
id="503-from-message",
),
pytest.param(
FakeA2AClientHTTPError(500, "boom"),
A2AAgentUnavailableError,
500,
id="500",
),
],
)
def test_map_client_error_http_variants(
exc: SDKClientError,
expected_type: type[Exception],
http_status: int,
) -> None:
mapped = map_client_error("SendMessage", exc)

assert isinstance(mapped, expected_type)
assert mapped.http_status == http_status


def test_map_client_error_timeout_variant() -> None:
mapped = map_client_error("SendMessage", A2AClientTimeoutError("timed out"))

assert isinstance(mapped, A2ATimeoutError)
assert mapped.http_status is None


def test_map_client_error_without_status_returns_protocol_error() -> None:
mapped = map_client_error("SendMessage", SDKClientError("broken client"))

assert isinstance(mapped, A2APeerProtocolError)
assert mapped.error_code == "invalid_client_error"


def test_map_jsonrpc_error_variants() -> None:
invalid_params_error = FakeA2AClientJSONRPCError(
JSONRPCErrorResponse(
error=JSONRPCError(code=-32602, message="bad params"),
error=JSONRPCError(code=-32602, message="bad params", data={"field": "limit"}),
id="req-1",
)
)
Expand All @@ -51,9 +187,12 @@ def test_map_jsonrpc_error_variants() -> None:

assert isinstance(mapped_invalid, A2APeerProtocolError)
assert mapped_invalid.error_code == "invalid_params"
assert mapped_invalid.code == -32602
assert mapped_invalid.data == {"field": "limit"}
assert isinstance(mapped_internal, A2AClientResetRequiredError)
assert isinstance(mapped_generic, A2APeerProtocolError)
assert mapped_generic.error_code == "peer_protocol_error"
assert mapped_generic.data is None


def test_map_http_error_variants() -> None:
Expand Down Expand Up @@ -83,3 +222,33 @@ def test_map_agent_card_error_json_variant() -> None:

assert isinstance(mapped, A2APeerProtocolError)
assert mapped.error_code == "invalid_agent_card"


def test_map_agent_card_error_resolution_error_without_status_is_invalid_card() -> None:
mapped = map_agent_card_error(AgentCardResolutionError("invalid json"))

assert isinstance(mapped, A2APeerProtocolError)
assert mapped.error_code == "invalid_agent_card"


def test_map_agent_card_error_resolution_error_with_status_uses_http_mapping() -> None:
mapped = map_agent_card_error(AgentCardResolutionError("forbidden", status_code=403))

assert isinstance(mapped, A2APermissionDeniedError)
assert mapped.http_status == 403


@pytest.mark.parametrize(
("exc", "expected_type"),
[
pytest.param(httpx.ReadTimeout("timed out"), A2ATimeoutError, id="timeout"),
pytest.param(httpx.ConnectError("down"), A2AAgentUnavailableError, id="transport"),
],
)
def test_map_agent_card_error_transport_variants(
exc: httpx.TimeoutException | httpx.TransportError,
expected_type: type[Exception],
) -> None:
mapped = map_agent_card_error(exc)

assert isinstance(mapped, expected_type)
4 changes: 2 additions & 2 deletions tests/contracts/test_extension_contract_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@
from opencode_a2a.protocol_versions import A2A_PROTOCOL_VERSION
from opencode_a2a.server.agent_card import build_agent_card
from opencode_a2a.server.application import create_app
from tests.support.helpers import (
from tests.support.session_extensions import _extension_headers
from tests.support.session_query_client import (
DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient,
)
from tests.support.session_extensions import _extension_headers
from tests.support.settings import make_settings


Expand Down
2 changes: 1 addition & 1 deletion tests/jsonrpc/test_application_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
from opencode_a2a.contracts.extensions import SESSION_MANAGEMENT_EXTENSION_URI
from opencode_a2a.jsonrpc.models import JSONRPCRequest
from tests.support.async_iterators import iter_async
from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient
from tests.support.session_extensions import _BASE_SETTINGS, _jsonrpc_app
from tests.support.session_query_client import DummySessionQueryOpencodeUpstreamClient
from tests.support.settings import make_settings


Expand Down
2 changes: 1 addition & 1 deletion tests/jsonrpc/test_dispatch_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
from opencode_a2a.a2a_protocol import CORE_JSONRPC_METHODS
from opencode_a2a.contracts.extensions import SESSION_MANAGEMENT_EXTENSION_URI
from opencode_a2a.jsonrpc.application import OpencodeSessionManagementJSONRPCApplication
from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient
from tests.support.jsonrpc_error_assertions import assert_v1_error_reason, error_context_detail
from tests.support.session_extensions import _BASE_SETTINGS, _extension_headers, _jsonrpc_app
from tests.support.session_query_client import DummySessionQueryOpencodeUpstreamClient
from tests.support.settings import make_settings


Expand Down
6 changes: 3 additions & 3 deletions tests/jsonrpc/test_opencode_session_extension_commands.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import httpx
import pytest

from tests.support.helpers import (
DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient,
)
from tests.support.helpers import make_basic_auth_header
from tests.support.jsonrpc_error_assertions import (
assert_v1_error_context,
Expand All @@ -16,6 +13,9 @@
_jsonrpc_app,
_session_meta,
)
from tests.support.session_query_client import (
DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient,
)
from tests.support.settings import make_settings


Expand Down
6 changes: 3 additions & 3 deletions tests/jsonrpc/test_opencode_session_extension_interrupts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@

from opencode_a2a.config import Settings
from opencode_a2a.opencode_upstream_client import UpstreamConcurrencyLimitError
from tests.support.helpers import (
DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient,
)
from tests.support.jsonrpc_error_assertions import (
assert_v1_error_reason,
error_context_detail,
)
from tests.support.session_extensions import _BASE_SETTINGS, _extension_headers
from tests.support.session_query_client import (
DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient,
)
from tests.support.settings import make_settings


Expand Down
6 changes: 3 additions & 3 deletions tests/jsonrpc/test_opencode_session_extension_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
import httpx
import pytest

from tests.support.helpers import (
DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient,
)
from tests.support.jsonrpc_error_assertions import assert_v1_error_reason
from tests.support.session_extensions import (
_BASE_SETTINGS,
_extension_headers,
_jsonrpc_app,
_session_meta,
)
from tests.support.session_query_client import (
DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient,
)
from tests.support.settings import make_settings


Expand Down
Loading