From 99b354382fcb87c3cd9c19712d7445542608b773 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Fri, 27 Mar 2026 00:15:59 -0400 Subject: [PATCH 1/3] refactor(jsonrpc): drop legacy uppercase upstream error tokens --- docs/guide.md | 10 +++---- src/opencode_a2a/contracts/extensions.py | 26 +++++++++---------- src/opencode_a2a/jsonrpc/application.py | 18 ++++++------- src/opencode_a2a/jsonrpc/error_responses.py | 6 ++--- tests/jsonrpc/test_error_responses.py | 6 ++--- ...est_opencode_session_extension_commands.py | 4 +-- ...t_opencode_session_extension_interrupts.py | 2 +- ...opencode_session_extension_prompt_async.py | 10 +++---- ...test_opencode_session_extension_queries.py | 12 ++++----- tests/server/test_agent_card.py | 26 +++++++++---------- 10 files changed, 60 insertions(+), 60 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index 099be4a..a183aa8 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -722,9 +722,9 @@ Response: - `SESSION_NOT_FOUND` - `SESSION_FORBIDDEN` - `METHOD_DISABLED` (not applicable to prompt_async) - - `UPSTREAM_UNREACHABLE` - - `UPSTREAM_HTTP_ERROR` - - `UPSTREAM_PAYLOAD_ERROR` + - `upstream_unreachable` + - `upstream_http_error` + - `upstream_payload_error` Validation notes: @@ -892,8 +892,8 @@ Notes: - `INTERRUPT_REQUEST_NOT_FOUND` - `INTERRUPT_REQUEST_EXPIRED` - `INTERRUPT_TYPE_MISMATCH` - - `UPSTREAM_UNREACHABLE` - - `UPSTREAM_HTTP_ERROR` + - `upstream_unreachable` + - `upstream_http_error` Permission reply example: diff --git a/src/opencode_a2a/contracts/extensions.py b/src/opencode_a2a/contracts/extensions.py index 4e95cdd..89393c0 100644 --- a/src/opencode_a2a/contracts/extensions.py +++ b/src/opencode_a2a/contracts/extensions.py @@ -188,11 +188,11 @@ class ProviderDiscoveryMethodContract: ) SESSION_QUERY_ERROR_BUSINESS_CODES: dict[str, int] = { - "SESSION_NOT_FOUND": -32001, - "SESSION_FORBIDDEN": -32006, - "UPSTREAM_UNREACHABLE": -32002, - "UPSTREAM_HTTP_ERROR": -32003, - "UPSTREAM_PAYLOAD_ERROR": -32005, + "session_not_found": -32001, + "session_forbidden": -32006, + "upstream_unreachable": -32002, + "upstream_http_error": -32003, + "upstream_payload_error": -32005, } SESSION_QUERY_ERROR_DATA_FIELDS: tuple[str, ...] = ( "type", @@ -256,16 +256,16 @@ class ProviderDiscoveryMethodContract: INTERRUPT_SUCCESS_RESULT_FIELDS: tuple[str, ...] = ("ok", "request_id") INTERRUPT_ERROR_BUSINESS_CODES: dict[str, int] = { - "INTERRUPT_REQUEST_NOT_FOUND": -32004, - "UPSTREAM_UNREACHABLE": -32002, - "UPSTREAM_HTTP_ERROR": -32003, + "interrupt_request_not_found": -32004, + "upstream_unreachable": -32002, + "upstream_http_error": -32003, } INTERRUPT_ERROR_TYPES: tuple[str, ...] = ( "INTERRUPT_REQUEST_NOT_FOUND", "INTERRUPT_REQUEST_EXPIRED", "INTERRUPT_TYPE_MISMATCH", - "UPSTREAM_UNREACHABLE", - "UPSTREAM_HTTP_ERROR", + "upstream_unreachable", + "upstream_http_error", ) INTERRUPT_ERROR_DATA_FIELDS: tuple[str, ...] = ("type", "request_id", "upstream_status") INTERRUPT_INVALID_PARAMS_DATA_FIELDS: tuple[str, ...] = ( @@ -277,9 +277,9 @@ class ProviderDiscoveryMethodContract: "actual", ) PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES: dict[str, int] = { - "UPSTREAM_UNREACHABLE": -32002, - "UPSTREAM_HTTP_ERROR": -32003, - "UPSTREAM_PAYLOAD_ERROR": -32005, + "upstream_unreachable": -32002, + "upstream_http_error": -32003, + "upstream_payload_error": -32005, } PROVIDER_DISCOVERY_ERROR_DATA_FIELDS: tuple[str, ...] = ( "type", diff --git a/src/opencode_a2a/jsonrpc/application.py b/src/opencode_a2a/jsonrpc/application.py index 0b2419e..f89fd4a 100644 --- a/src/opencode_a2a/jsonrpc/application.py +++ b/src/opencode_a2a/jsonrpc/application.py @@ -76,16 +76,16 @@ "_validate_shell_request_payload", ] -ERR_SESSION_NOT_FOUND = SESSION_QUERY_ERROR_BUSINESS_CODES["SESSION_NOT_FOUND"] -ERR_SESSION_FORBIDDEN = SESSION_QUERY_ERROR_BUSINESS_CODES["SESSION_FORBIDDEN"] -ERR_UPSTREAM_UNREACHABLE = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_UNREACHABLE"] -ERR_UPSTREAM_HTTP_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_HTTP_ERROR"] -ERR_INTERRUPT_NOT_FOUND = INTERRUPT_ERROR_BUSINESS_CODES["INTERRUPT_REQUEST_NOT_FOUND"] -ERR_UPSTREAM_PAYLOAD_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_PAYLOAD_ERROR"] -ERR_DISCOVERY_UPSTREAM_UNREACHABLE = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["UPSTREAM_UNREACHABLE"] -ERR_DISCOVERY_UPSTREAM_HTTP_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["UPSTREAM_HTTP_ERROR"] +ERR_SESSION_NOT_FOUND = SESSION_QUERY_ERROR_BUSINESS_CODES["session_not_found"] +ERR_SESSION_FORBIDDEN = SESSION_QUERY_ERROR_BUSINESS_CODES["session_forbidden"] +ERR_UPSTREAM_UNREACHABLE = SESSION_QUERY_ERROR_BUSINESS_CODES["upstream_unreachable"] +ERR_UPSTREAM_HTTP_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["upstream_http_error"] +ERR_INTERRUPT_NOT_FOUND = INTERRUPT_ERROR_BUSINESS_CODES["interrupt_request_not_found"] +ERR_UPSTREAM_PAYLOAD_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["upstream_payload_error"] +ERR_DISCOVERY_UPSTREAM_UNREACHABLE = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["upstream_unreachable"] +ERR_DISCOVERY_UPSTREAM_HTTP_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["upstream_http_error"] ERR_DISCOVERY_UPSTREAM_PAYLOAD_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES[ - "UPSTREAM_PAYLOAD_ERROR" + "upstream_payload_error" ] diff --git a/src/opencode_a2a/jsonrpc/error_responses.py b/src/opencode_a2a/jsonrpc/error_responses.py index 8b9a3ac..2500902 100644 --- a/src/opencode_a2a/jsonrpc/error_responses.py +++ b/src/opencode_a2a/jsonrpc/error_responses.py @@ -73,7 +73,7 @@ def upstream_http_error( detail: str | None = None, ) -> JSONRPCError: data: dict[str, Any] = { - "type": "UPSTREAM_HTTP_ERROR", + "type": "upstream_http_error", "upstream_status": upstream_status, } if method is not None: @@ -95,7 +95,7 @@ def upstream_unreachable_error( request_id: str | None = None, detail: str | None = None, ) -> JSONRPCError: - data: dict[str, Any] = {"type": "UPSTREAM_UNREACHABLE"} + data: dict[str, Any] = {"type": "upstream_unreachable"} if method is not None: data["method"] = method if session_id is not None: @@ -116,7 +116,7 @@ def upstream_payload_error( request_id: str | None = None, ) -> JSONRPCError: data: dict[str, Any] = { - "type": "UPSTREAM_PAYLOAD_ERROR", + "type": "upstream_payload_error", "detail": detail, } if method is not None: diff --git a/tests/jsonrpc/test_error_responses.py b/tests/jsonrpc/test_error_responses.py index 59cdee2..a16ea0c 100644 --- a/tests/jsonrpc/test_error_responses.py +++ b/tests/jsonrpc/test_error_responses.py @@ -48,7 +48,7 @@ def test_jsonrpc_error_mapping_helpers_build_upstream_envelopes() -> None: session_id="s-1", ) assert http_error.data == { - "type": "UPSTREAM_HTTP_ERROR", + "type": "upstream_http_error", "upstream_status": 503, "method": "opencode.sessions.command", "session_id": "s-1", @@ -60,7 +60,7 @@ def test_jsonrpc_error_mapping_helpers_build_upstream_envelopes() -> None: detail=backpressure_detail, ) assert unreachable.data == { - "type": "UPSTREAM_UNREACHABLE", + "type": "upstream_unreachable", "request_id": "req-1", "detail": backpressure_detail, } @@ -71,7 +71,7 @@ def test_jsonrpc_error_mapping_helpers_build_upstream_envelopes() -> None: method="opencode.providers.list", ) assert payload_error.data == { - "type": "UPSTREAM_PAYLOAD_ERROR", + "type": "upstream_payload_error", "detail": "payload mismatch", "method": "opencode.providers.list", } diff --git a/tests/jsonrpc/test_opencode_session_extension_commands.py b/tests/jsonrpc/test_opencode_session_extension_commands.py index 297e4aa..ac21519 100644 --- a/tests/jsonrpc/test_opencode_session_extension_commands.py +++ b/tests/jsonrpc/test_opencode_session_extension_commands.py @@ -441,7 +441,7 @@ async def session_command(self, session_id: str, request: dict, *, directory=Non ) payload = resp.json() assert payload["error"]["code"] == -32003 - assert payload["error"]["data"]["type"] == "UPSTREAM_HTTP_ERROR" + assert payload["error"]["data"]["type"] == "upstream_http_error" assert payload["error"]["data"]["upstream_status"] == 500 @@ -483,4 +483,4 @@ async def session_shell(self, session_id: str, request: dict, *, directory=None) ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" + assert payload["error"]["data"]["type"] == "upstream_unreachable" diff --git a/tests/jsonrpc/test_opencode_session_extension_interrupts.py b/tests/jsonrpc/test_opencode_session_extension_interrupts.py index 93a2227..a861a8f 100644 --- a/tests/jsonrpc/test_opencode_session_extension_interrupts.py +++ b/tests/jsonrpc/test_opencode_session_extension_interrupts.py @@ -503,5 +503,5 @@ async def permission_reply( ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" + assert payload["error"]["data"]["type"] == "upstream_unreachable" assert "concurrency limit exceeded" in payload["error"]["data"]["detail"] diff --git a/tests/jsonrpc/test_opencode_session_extension_prompt_async.py b/tests/jsonrpc/test_opencode_session_extension_prompt_async.py index a3a454b..9bb52f3 100644 --- a/tests/jsonrpc/test_opencode_session_extension_prompt_async.py +++ b/tests/jsonrpc/test_opencode_session_extension_prompt_async.py @@ -366,7 +366,7 @@ async def session_prompt_async(self, session_id: str, request: dict, *, director ) payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" + assert payload["error"]["data"]["type"] == "upstream_payload_error" @pytest.mark.asyncio @@ -403,7 +403,7 @@ async def session_prompt_async(self, session_id: str, request: dict, *, director ) payload = resp.json() assert payload["error"]["code"] == -32003 - assert payload["error"]["data"]["type"] == "UPSTREAM_HTTP_ERROR" + assert payload["error"]["data"]["type"] == "upstream_http_error" assert payload["error"]["data"]["upstream_status"] == 500 @@ -440,7 +440,7 @@ async def session_prompt_async(self, session_id: str, request: dict, *, director ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" + assert payload["error"]["data"]["type"] == "upstream_unreachable" @pytest.mark.asyncio @@ -483,7 +483,7 @@ async def _release_raises(self: SessionManager, *, identity: str, session_id: st ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" + assert payload["error"]["data"]["type"] == "upstream_unreachable" assert any( "Failed to release pending session claim" in record.message for record in caplog.records @@ -526,7 +526,7 @@ async def session_prompt_async(self, session_id: str, request: dict, *, director ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" + assert payload["error"]["data"]["type"] == "upstream_unreachable" assert "concurrency limit exceeded" in payload["error"]["data"]["detail"] diff --git a/tests/jsonrpc/test_opencode_session_extension_queries.py b/tests/jsonrpc/test_opencode_session_extension_queries.py index c2b97ba..1904aa4 100644 --- a/tests/jsonrpc/test_opencode_session_extension_queries.py +++ b/tests/jsonrpc/test_opencode_session_extension_queries.py @@ -337,7 +337,7 @@ async def test_provider_discovery_extension_maps_payload_mismatch(monkeypatch): ) payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" + assert payload["error"]["data"]["type"] == "upstream_payload_error" @pytest.mark.asyncio @@ -373,7 +373,7 @@ async def list_provider_catalog(self, *, directory: str | None = None): ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" + assert payload["error"]["data"]["type"] == "upstream_unreachable" assert "concurrency limit exceeded" in payload["error"]["data"]["detail"] @@ -407,7 +407,7 @@ def __init__(self, _settings: Settings) -> None: assert resp.status_code == 200 payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" + assert payload["error"]["data"]["type"] == "upstream_payload_error" @pytest.mark.asyncio @@ -438,7 +438,7 @@ async def list_sessions(self, *, params=None): ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" + assert payload["error"]["data"]["type"] == "upstream_unreachable" assert "concurrency limit exceeded" in payload["error"]["data"]["detail"] @@ -611,7 +611,7 @@ def __init__(self, _settings: Settings) -> None: ) payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" + assert payload["error"]["data"]["type"] == "upstream_payload_error" resp = await client.post( "/", @@ -625,7 +625,7 @@ def __init__(self, _settings: Settings) -> None: ) payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" + assert payload["error"]["data"]["type"] == "upstream_payload_error" @pytest.mark.asyncio diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index d540ff6..d0404d2 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -209,11 +209,11 @@ def test_agent_card_injects_profile_into_extensions() -> None: == "metadata.shared.session.id" ) assert session_query.params["errors"]["business_codes"] == { - "SESSION_NOT_FOUND": -32001, - "SESSION_FORBIDDEN": -32006, - "UPSTREAM_UNREACHABLE": -32002, - "UPSTREAM_HTTP_ERROR": -32003, - "UPSTREAM_PAYLOAD_ERROR": -32005, + "session_not_found": -32001, + "session_forbidden": -32006, + "upstream_unreachable": -32002, + "upstream_http_error": -32003, + "upstream_payload_error": -32005, } assert session_query.params["errors"]["error_data_fields"] == [ "type", @@ -249,9 +249,9 @@ def test_agent_card_injects_profile_into_extensions() -> None: "items_type": "ModelSummary[]", } assert provider_discovery.params["errors"]["business_codes"] == { - "UPSTREAM_UNREACHABLE": -32002, - "UPSTREAM_HTTP_ERROR": -32003, - "UPSTREAM_PAYLOAD_ERROR": -32005, + "upstream_unreachable": -32002, + "upstream_http_error": -32003, + "upstream_payload_error": -32005, } interrupt = ext_by_uri[INTERRUPT_CALLBACK_EXTENSION_URI] @@ -261,16 +261,16 @@ def test_agent_card_injects_profile_into_extensions() -> None: assert interrupt.params["provider_private_metadata"] == ["opencode.directory"] assert interrupt.params["context_fields"]["directory"] == "metadata.opencode.directory" assert interrupt.params["errors"]["business_codes"] == { - "INTERRUPT_REQUEST_NOT_FOUND": -32004, - "UPSTREAM_UNREACHABLE": -32002, - "UPSTREAM_HTTP_ERROR": -32003, + "interrupt_request_not_found": -32004, + "upstream_unreachable": -32002, + "upstream_http_error": -32003, } assert interrupt.params["errors"]["error_types"] == [ "INTERRUPT_REQUEST_NOT_FOUND", "INTERRUPT_REQUEST_EXPIRED", "INTERRUPT_TYPE_MISMATCH", - "UPSTREAM_UNREACHABLE", - "UPSTREAM_HTTP_ERROR", + "upstream_unreachable", + "upstream_http_error", ] assert interrupt.params["errors"]["invalid_params_data_fields"] == [ "type", From 1b42f8d5fd494835ff6af0acb7c6d8b86437b6e5 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Fri, 27 Mar 2026 00:40:33 -0400 Subject: [PATCH 2/3] fix(jsonrpc): restore uppercase external error contract --- docs/guide.md | 10 ++--- src/opencode_a2a/contracts/extensions.py | 38 ++++++++++-------- src/opencode_a2a/jsonrpc/application.py | 39 +++++++++---------- src/opencode_a2a/jsonrpc/error_responses.py | 26 +++++++++++-- tests/jsonrpc/test_error_responses.py | 20 ++++++++-- ...est_opencode_session_extension_commands.py | 4 +- ...t_opencode_session_extension_interrupts.py | 8 ++-- ...opencode_session_extension_prompt_async.py | 10 ++--- ...test_opencode_session_extension_queries.py | 12 +++--- tests/server/test_agent_card.py | 37 +++++++++++------- 10 files changed, 125 insertions(+), 79 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index a183aa8..099be4a 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -722,9 +722,9 @@ Response: - `SESSION_NOT_FOUND` - `SESSION_FORBIDDEN` - `METHOD_DISABLED` (not applicable to prompt_async) - - `upstream_unreachable` - - `upstream_http_error` - - `upstream_payload_error` + - `UPSTREAM_UNREACHABLE` + - `UPSTREAM_HTTP_ERROR` + - `UPSTREAM_PAYLOAD_ERROR` Validation notes: @@ -892,8 +892,8 @@ Notes: - `INTERRUPT_REQUEST_NOT_FOUND` - `INTERRUPT_REQUEST_EXPIRED` - `INTERRUPT_TYPE_MISMATCH` - - `upstream_unreachable` - - `upstream_http_error` + - `UPSTREAM_UNREACHABLE` + - `UPSTREAM_HTTP_ERROR` Permission reply example: diff --git a/src/opencode_a2a/contracts/extensions.py b/src/opencode_a2a/contracts/extensions.py index 89393c0..7c0cbe7 100644 --- a/src/opencode_a2a/contracts/extensions.py +++ b/src/opencode_a2a/contracts/extensions.py @@ -188,11 +188,11 @@ class ProviderDiscoveryMethodContract: ) SESSION_QUERY_ERROR_BUSINESS_CODES: dict[str, int] = { - "session_not_found": -32001, - "session_forbidden": -32006, - "upstream_unreachable": -32002, - "upstream_http_error": -32003, - "upstream_payload_error": -32005, + "SESSION_NOT_FOUND": -32001, + "SESSION_FORBIDDEN": -32006, + "UPSTREAM_UNREACHABLE": -32002, + "UPSTREAM_HTTP_ERROR": -32003, + "UPSTREAM_PAYLOAD_ERROR": -32005, } SESSION_QUERY_ERROR_DATA_FIELDS: tuple[str, ...] = ( "type", @@ -256,30 +256,36 @@ class ProviderDiscoveryMethodContract: INTERRUPT_SUCCESS_RESULT_FIELDS: tuple[str, ...] = ("ok", "request_id") INTERRUPT_ERROR_BUSINESS_CODES: dict[str, int] = { - "interrupt_request_not_found": -32004, - "upstream_unreachable": -32002, - "upstream_http_error": -32003, + "INTERRUPT_REQUEST_NOT_FOUND": -32004, + "INTERRUPT_REQUEST_EXPIRED": -32007, + "INTERRUPT_TYPE_MISMATCH": -32008, + "UPSTREAM_UNREACHABLE": -32002, + "UPSTREAM_HTTP_ERROR": -32003, } INTERRUPT_ERROR_TYPES: tuple[str, ...] = ( "INTERRUPT_REQUEST_NOT_FOUND", "INTERRUPT_REQUEST_EXPIRED", "INTERRUPT_TYPE_MISMATCH", - "upstream_unreachable", - "upstream_http_error", + "UPSTREAM_UNREACHABLE", + "UPSTREAM_HTTP_ERROR", +) +INTERRUPT_ERROR_DATA_FIELDS: tuple[str, ...] = ( + "type", + "request_id", + "expected_interrupt_type", + "actual_interrupt_type", + "upstream_status", ) -INTERRUPT_ERROR_DATA_FIELDS: tuple[str, ...] = ("type", "request_id", "upstream_status") INTERRUPT_INVALID_PARAMS_DATA_FIELDS: tuple[str, ...] = ( "type", "field", "fields", "request_id", - "expected", - "actual", ) PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES: dict[str, int] = { - "upstream_unreachable": -32002, - "upstream_http_error": -32003, - "upstream_payload_error": -32005, + "UPSTREAM_UNREACHABLE": -32002, + "UPSTREAM_HTTP_ERROR": -32003, + "UPSTREAM_PAYLOAD_ERROR": -32005, } PROVIDER_DISCOVERY_ERROR_DATA_FIELDS: tuple[str, ...] = ( "type", diff --git a/src/opencode_a2a/jsonrpc/application.py b/src/opencode_a2a/jsonrpc/application.py index f89fd4a..77569e1 100644 --- a/src/opencode_a2a/jsonrpc/application.py +++ b/src/opencode_a2a/jsonrpc/application.py @@ -28,6 +28,7 @@ ) from .error_responses import ( interrupt_not_found_error, + interrupt_type_mismatch_error, invalid_params_error, method_not_supported_error, session_forbidden_error, @@ -76,16 +77,18 @@ "_validate_shell_request_payload", ] -ERR_SESSION_NOT_FOUND = SESSION_QUERY_ERROR_BUSINESS_CODES["session_not_found"] -ERR_SESSION_FORBIDDEN = SESSION_QUERY_ERROR_BUSINESS_CODES["session_forbidden"] -ERR_UPSTREAM_UNREACHABLE = SESSION_QUERY_ERROR_BUSINESS_CODES["upstream_unreachable"] -ERR_UPSTREAM_HTTP_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["upstream_http_error"] -ERR_INTERRUPT_NOT_FOUND = INTERRUPT_ERROR_BUSINESS_CODES["interrupt_request_not_found"] -ERR_UPSTREAM_PAYLOAD_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["upstream_payload_error"] -ERR_DISCOVERY_UPSTREAM_UNREACHABLE = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["upstream_unreachable"] -ERR_DISCOVERY_UPSTREAM_HTTP_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["upstream_http_error"] +ERR_SESSION_NOT_FOUND = SESSION_QUERY_ERROR_BUSINESS_CODES["SESSION_NOT_FOUND"] +ERR_SESSION_FORBIDDEN = SESSION_QUERY_ERROR_BUSINESS_CODES["SESSION_FORBIDDEN"] +ERR_UPSTREAM_UNREACHABLE = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_UNREACHABLE"] +ERR_UPSTREAM_HTTP_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_HTTP_ERROR"] +ERR_INTERRUPT_NOT_FOUND = INTERRUPT_ERROR_BUSINESS_CODES["INTERRUPT_REQUEST_NOT_FOUND"] +ERR_INTERRUPT_EXPIRED = INTERRUPT_ERROR_BUSINESS_CODES["INTERRUPT_REQUEST_EXPIRED"] +ERR_INTERRUPT_TYPE_MISMATCH = INTERRUPT_ERROR_BUSINESS_CODES["INTERRUPT_TYPE_MISMATCH"] +ERR_UPSTREAM_PAYLOAD_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_PAYLOAD_ERROR"] +ERR_DISCOVERY_UPSTREAM_UNREACHABLE = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["UPSTREAM_UNREACHABLE"] +ERR_DISCOVERY_UPSTREAM_HTTP_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["UPSTREAM_HTTP_ERROR"] ERR_DISCOVERY_UPSTREAM_PAYLOAD_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES[ - "upstream_payload_error" + "UPSTREAM_PAYLOAD_ERROR" ] @@ -812,7 +815,7 @@ async def _handle_interrupt_callback_request( return self._generate_error_response( base_request.id, interrupt_not_found_error( - ERR_INTERRUPT_NOT_FOUND, + ERR_INTERRUPT_EXPIRED if status == "expired" else ERR_INTERRUPT_NOT_FOUND, request_id=request_id, expired=status == "expired", ), @@ -820,17 +823,11 @@ async def _handle_interrupt_callback_request( if binding.interrupt_type != expected_interrupt_type: return self._generate_error_response( base_request.id, - invalid_params_error( - ( - "Interrupt type mismatch: " - f"expected {expected_interrupt_type}, got {binding.interrupt_type}" - ), - data={ - "type": "INTERRUPT_TYPE_MISMATCH", - "request_id": request_id, - "expected": expected_interrupt_type, - "actual": binding.interrupt_type, - }, + interrupt_type_mismatch_error( + ERR_INTERRUPT_TYPE_MISMATCH, + request_id=request_id, + expected_interrupt_type=expected_interrupt_type, + actual_interrupt_type=binding.interrupt_type, ), ) if ( diff --git a/src/opencode_a2a/jsonrpc/error_responses.py b/src/opencode_a2a/jsonrpc/error_responses.py index 2500902..b170b0d 100644 --- a/src/opencode_a2a/jsonrpc/error_responses.py +++ b/src/opencode_a2a/jsonrpc/error_responses.py @@ -63,6 +63,25 @@ def interrupt_not_found_error( ) +def interrupt_type_mismatch_error( + code: int, + *, + request_id: str, + expected_interrupt_type: str, + actual_interrupt_type: str, +) -> JSONRPCError: + return JSONRPCError( + code=code, + message="Interrupt callback type mismatch", + data={ + "type": "INTERRUPT_TYPE_MISMATCH", + "request_id": request_id, + "expected_interrupt_type": expected_interrupt_type, + "actual_interrupt_type": actual_interrupt_type, + }, + ) + + def upstream_http_error( code: int, *, @@ -73,7 +92,7 @@ def upstream_http_error( detail: str | None = None, ) -> JSONRPCError: data: dict[str, Any] = { - "type": "upstream_http_error", + "type": "UPSTREAM_HTTP_ERROR", "upstream_status": upstream_status, } if method is not None: @@ -95,7 +114,7 @@ def upstream_unreachable_error( request_id: str | None = None, detail: str | None = None, ) -> JSONRPCError: - data: dict[str, Any] = {"type": "upstream_unreachable"} + data: dict[str, Any] = {"type": "UPSTREAM_UNREACHABLE"} if method is not None: data["method"] = method if session_id is not None: @@ -116,7 +135,7 @@ def upstream_payload_error( request_id: str | None = None, ) -> JSONRPCError: data: dict[str, Any] = { - "type": "upstream_payload_error", + "type": "UPSTREAM_PAYLOAD_ERROR", "detail": detail, } if method is not None: @@ -130,6 +149,7 @@ def upstream_payload_error( __all__ = [ "interrupt_not_found_error", + "interrupt_type_mismatch_error", "invalid_params_error", "method_not_supported_error", "session_forbidden_error", diff --git a/tests/jsonrpc/test_error_responses.py b/tests/jsonrpc/test_error_responses.py index a16ea0c..e864f53 100644 --- a/tests/jsonrpc/test_error_responses.py +++ b/tests/jsonrpc/test_error_responses.py @@ -4,6 +4,7 @@ from opencode_a2a.jsonrpc.error_responses import ( interrupt_not_found_error, + interrupt_type_mismatch_error, invalid_params_error, method_not_supported_error, session_forbidden_error, @@ -36,6 +37,19 @@ def test_jsonrpc_error_mapping_helpers_preserve_business_contract_fields() -> No "request_id": "req-1", } + mismatch_interrupt = interrupt_type_mismatch_error( + -32008, + request_id="req-2", + expected_interrupt_type="permission", + actual_interrupt_type="question", + ) + assert mismatch_interrupt.data == { + "type": "INTERRUPT_TYPE_MISMATCH", + "request_id": "req-2", + "expected_interrupt_type": "permission", + "actual_interrupt_type": "question", + } + def test_jsonrpc_error_mapping_helpers_build_upstream_envelopes() -> None: backpressure_detail = ( @@ -48,7 +62,7 @@ def test_jsonrpc_error_mapping_helpers_build_upstream_envelopes() -> None: session_id="s-1", ) assert http_error.data == { - "type": "upstream_http_error", + "type": "UPSTREAM_HTTP_ERROR", "upstream_status": 503, "method": "opencode.sessions.command", "session_id": "s-1", @@ -60,7 +74,7 @@ def test_jsonrpc_error_mapping_helpers_build_upstream_envelopes() -> None: detail=backpressure_detail, ) assert unreachable.data == { - "type": "upstream_unreachable", + "type": "UPSTREAM_UNREACHABLE", "request_id": "req-1", "detail": backpressure_detail, } @@ -71,7 +85,7 @@ def test_jsonrpc_error_mapping_helpers_build_upstream_envelopes() -> None: method="opencode.providers.list", ) assert payload_error.data == { - "type": "upstream_payload_error", + "type": "UPSTREAM_PAYLOAD_ERROR", "detail": "payload mismatch", "method": "opencode.providers.list", } diff --git a/tests/jsonrpc/test_opencode_session_extension_commands.py b/tests/jsonrpc/test_opencode_session_extension_commands.py index ac21519..297e4aa 100644 --- a/tests/jsonrpc/test_opencode_session_extension_commands.py +++ b/tests/jsonrpc/test_opencode_session_extension_commands.py @@ -441,7 +441,7 @@ async def session_command(self, session_id: str, request: dict, *, directory=Non ) payload = resp.json() assert payload["error"]["code"] == -32003 - assert payload["error"]["data"]["type"] == "upstream_http_error" + assert payload["error"]["data"]["type"] == "UPSTREAM_HTTP_ERROR" assert payload["error"]["data"]["upstream_status"] == 500 @@ -483,4 +483,4 @@ async def session_shell(self, session_id: str, request: dict, *, directory=None) ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "upstream_unreachable" + assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" diff --git a/tests/jsonrpc/test_opencode_session_extension_interrupts.py b/tests/jsonrpc/test_opencode_session_extension_interrupts.py index a861a8f..73b494c 100644 --- a/tests/jsonrpc/test_opencode_session_extension_interrupts.py +++ b/tests/jsonrpc/test_opencode_session_extension_interrupts.py @@ -326,7 +326,7 @@ async def resolve_interrupt_request(self, request_id: str): }, ) payload = resp.json() - assert payload["error"]["code"] == -32004 + assert payload["error"]["code"] == -32007 assert payload["error"]["data"]["type"] == "INTERRUPT_REQUEST_EXPIRED" @@ -412,8 +412,10 @@ class InterruptClient(DummyOpencodeUpstreamClient): }, ) payload = resp.json() - assert payload["error"]["code"] == -32602 + assert payload["error"]["code"] == -32008 assert payload["error"]["data"]["type"] == "INTERRUPT_TYPE_MISMATCH" + assert payload["error"]["data"]["expected_interrupt_type"] == "permission" + assert payload["error"]["data"]["actual_interrupt_type"] == "question" @pytest.mark.asyncio @@ -503,5 +505,5 @@ async def permission_reply( ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "upstream_unreachable" + assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" assert "concurrency limit exceeded" in payload["error"]["data"]["detail"] diff --git a/tests/jsonrpc/test_opencode_session_extension_prompt_async.py b/tests/jsonrpc/test_opencode_session_extension_prompt_async.py index 9bb52f3..a3a454b 100644 --- a/tests/jsonrpc/test_opencode_session_extension_prompt_async.py +++ b/tests/jsonrpc/test_opencode_session_extension_prompt_async.py @@ -366,7 +366,7 @@ async def session_prompt_async(self, session_id: str, request: dict, *, director ) payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "upstream_payload_error" + assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" @pytest.mark.asyncio @@ -403,7 +403,7 @@ async def session_prompt_async(self, session_id: str, request: dict, *, director ) payload = resp.json() assert payload["error"]["code"] == -32003 - assert payload["error"]["data"]["type"] == "upstream_http_error" + assert payload["error"]["data"]["type"] == "UPSTREAM_HTTP_ERROR" assert payload["error"]["data"]["upstream_status"] == 500 @@ -440,7 +440,7 @@ async def session_prompt_async(self, session_id: str, request: dict, *, director ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "upstream_unreachable" + assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" @pytest.mark.asyncio @@ -483,7 +483,7 @@ async def _release_raises(self: SessionManager, *, identity: str, session_id: st ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "upstream_unreachable" + assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" assert any( "Failed to release pending session claim" in record.message for record in caplog.records @@ -526,7 +526,7 @@ async def session_prompt_async(self, session_id: str, request: dict, *, director ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "upstream_unreachable" + assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" assert "concurrency limit exceeded" in payload["error"]["data"]["detail"] diff --git a/tests/jsonrpc/test_opencode_session_extension_queries.py b/tests/jsonrpc/test_opencode_session_extension_queries.py index 1904aa4..c2b97ba 100644 --- a/tests/jsonrpc/test_opencode_session_extension_queries.py +++ b/tests/jsonrpc/test_opencode_session_extension_queries.py @@ -337,7 +337,7 @@ async def test_provider_discovery_extension_maps_payload_mismatch(monkeypatch): ) payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "upstream_payload_error" + assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" @pytest.mark.asyncio @@ -373,7 +373,7 @@ async def list_provider_catalog(self, *, directory: str | None = None): ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "upstream_unreachable" + assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" assert "concurrency limit exceeded" in payload["error"]["data"]["detail"] @@ -407,7 +407,7 @@ def __init__(self, _settings: Settings) -> None: assert resp.status_code == 200 payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "upstream_payload_error" + assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" @pytest.mark.asyncio @@ -438,7 +438,7 @@ async def list_sessions(self, *, params=None): ) payload = resp.json() assert payload["error"]["code"] == -32002 - assert payload["error"]["data"]["type"] == "upstream_unreachable" + assert payload["error"]["data"]["type"] == "UPSTREAM_UNREACHABLE" assert "concurrency limit exceeded" in payload["error"]["data"]["detail"] @@ -611,7 +611,7 @@ def __init__(self, _settings: Settings) -> None: ) payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "upstream_payload_error" + assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" resp = await client.post( "/", @@ -625,7 +625,7 @@ def __init__(self, _settings: Settings) -> None: ) payload = resp.json() assert payload["error"]["code"] == -32005 - assert payload["error"]["data"]["type"] == "upstream_payload_error" + assert payload["error"]["data"]["type"] == "UPSTREAM_PAYLOAD_ERROR" @pytest.mark.asyncio diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index d0404d2..a9e7c74 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -209,11 +209,11 @@ def test_agent_card_injects_profile_into_extensions() -> None: == "metadata.shared.session.id" ) assert session_query.params["errors"]["business_codes"] == { - "session_not_found": -32001, - "session_forbidden": -32006, - "upstream_unreachable": -32002, - "upstream_http_error": -32003, - "upstream_payload_error": -32005, + "SESSION_NOT_FOUND": -32001, + "SESSION_FORBIDDEN": -32006, + "UPSTREAM_UNREACHABLE": -32002, + "UPSTREAM_HTTP_ERROR": -32003, + "UPSTREAM_PAYLOAD_ERROR": -32005, } assert session_query.params["errors"]["error_data_fields"] == [ "type", @@ -249,9 +249,9 @@ def test_agent_card_injects_profile_into_extensions() -> None: "items_type": "ModelSummary[]", } assert provider_discovery.params["errors"]["business_codes"] == { - "upstream_unreachable": -32002, - "upstream_http_error": -32003, - "upstream_payload_error": -32005, + "UPSTREAM_UNREACHABLE": -32002, + "UPSTREAM_HTTP_ERROR": -32003, + "UPSTREAM_PAYLOAD_ERROR": -32005, } interrupt = ext_by_uri[INTERRUPT_CALLBACK_EXTENSION_URI] @@ -261,24 +261,31 @@ def test_agent_card_injects_profile_into_extensions() -> None: assert interrupt.params["provider_private_metadata"] == ["opencode.directory"] assert interrupt.params["context_fields"]["directory"] == "metadata.opencode.directory" assert interrupt.params["errors"]["business_codes"] == { - "interrupt_request_not_found": -32004, - "upstream_unreachable": -32002, - "upstream_http_error": -32003, + "INTERRUPT_REQUEST_NOT_FOUND": -32004, + "INTERRUPT_REQUEST_EXPIRED": -32007, + "INTERRUPT_TYPE_MISMATCH": -32008, + "UPSTREAM_UNREACHABLE": -32002, + "UPSTREAM_HTTP_ERROR": -32003, } assert interrupt.params["errors"]["error_types"] == [ "INTERRUPT_REQUEST_NOT_FOUND", "INTERRUPT_REQUEST_EXPIRED", "INTERRUPT_TYPE_MISMATCH", - "upstream_unreachable", - "upstream_http_error", + "UPSTREAM_UNREACHABLE", + "UPSTREAM_HTTP_ERROR", + ] + assert interrupt.params["errors"]["error_data_fields"] == [ + "type", + "request_id", + "expected_interrupt_type", + "actual_interrupt_type", + "upstream_status", ] assert interrupt.params["errors"]["invalid_params_data_fields"] == [ "type", "field", "fields", "request_id", - "expected", - "actual", ] for method_name in ( "a2a.interrupt.permission.reply", From 5710dd587170226e6d1ad45c301a463b8f718d65 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Fri, 27 Mar 2026 01:04:06 -0400 Subject: [PATCH 3/3] fix(contracts): declare interrupt detail error field --- src/opencode_a2a/contracts/extensions.py | 1 + tests/server/test_agent_card.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/opencode_a2a/contracts/extensions.py b/src/opencode_a2a/contracts/extensions.py index 7c0cbe7..e098bf0 100644 --- a/src/opencode_a2a/contracts/extensions.py +++ b/src/opencode_a2a/contracts/extensions.py @@ -275,6 +275,7 @@ class ProviderDiscoveryMethodContract: "expected_interrupt_type", "actual_interrupt_type", "upstream_status", + "detail", ) INTERRUPT_INVALID_PARAMS_DATA_FIELDS: tuple[str, ...] = ( "type", diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index a9e7c74..5c3ad76 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -280,6 +280,7 @@ def test_agent_card_injects_profile_into_extensions() -> None: "expected_interrupt_type", "actual_interrupt_type", "upstream_status", + "detail", ] assert interrupt.params["errors"]["invalid_params_data_fields"] == [ "type",