From d0d37c4af9392a54603ba6c2307adb484a55319d Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sun, 5 Apr 2026 07:39:25 +0000 Subject: [PATCH 1/4] fix(a2a): convert A2aOperationNotFoundError to JSON-RPC error response in dispatch() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2aLocalFacade.dispatch() was re-raising A2aOperationNotFoundError instead of converting it to a JSON-RPC 2.0 error response. Per JSON-RPC 2.0 spec, the server MUST return an error response object when a method is not found — it must never raise an exception to the caller. Changes: - facade.py: catch A2aOperationNotFoundError and return A2aResponse with error code -32601 (JSON-RPC 2.0 'Method not found') and log a warning - a2a_jsonrpc_wire_format_steps.py: remove exception-catching workaround from step_wire_dispatch() that was masking the bug; add step definition for asserting error code value - a2a_jsonrpc_wire_format.feature: add scenario asserting unknown method dispatch returns JSON-RPC 2.0 error code -32601 ISSUES CLOSED: #2859 --- features/a2a_jsonrpc_wire_format.feature | 6 ++ .../steps/a2a_jsonrpc_wire_format_steps.py | 99 +++---------------- src/cleveragents/a2a/facade.py | 15 ++- 3 files changed, 32 insertions(+), 88 deletions(-) diff --git a/features/a2a_jsonrpc_wire_format.feature b/features/a2a_jsonrpc_wire_format.feature index 9d2f3b963..4376e3d6b 100644 --- a/features/a2a_jsonrpc_wire_format.feature +++ b/features/a2a_jsonrpc_wire_format.feature @@ -185,6 +185,12 @@ Feature: A2A JSON-RPC 2.0 wire format compliance When I dispatch wire-format method "unknown/method" with params {} Then the wire-format response error should not be None + Scenario: Facade dispatch unknown method returns JSON-RPC 2.0 error code -32601 + Given a wire-format facade with no services + When I dispatch wire-format method "unknown/method" with params {} + Then the wire-format response error should not be None + And the wire-format response error code should equal "-32601" + Scenario: Facade dispatch preserves request id in response Given a wire-format facade with no services When I dispatch wire-format method "_cleveragents/health/check" with id "test-id-123" and params {} diff --git a/features/steps/a2a_jsonrpc_wire_format_steps.py b/features/steps/a2a_jsonrpc_wire_format_steps.py index 2639d72a4..0b7460b7e 100644 --- a/features/steps/a2a_jsonrpc_wire_format_steps.py +++ b/features/steps/a2a_jsonrpc_wire_format_steps.py @@ -13,8 +13,6 @@ from behave import given, then, use_step_matcher, when from behave.runner import Context from pydantic import ValidationError -from features.steps._a2a_code_map import A2A_CODE_MAP - use_step_matcher("re") try: @@ -150,18 +148,9 @@ def step_serialise_response(context: Context) -> None: r'an error A2aResponse with id "(?P[^"]+)" and error code "(?P[^"]+)"' ) def step_create_error_response(context: Context, resp_id: str, code: str) -> None: - from cleveragents.a2a import errors as _a2a_errors - - int_code: int = A2A_CODE_MAP.get( - code, int(code) if code.lstrip("-").isdigit() else _a2a_errors.INTERNAL_ERROR - ) context.response = A2aResponse( id=resp_id, - error=A2aErrorDetail(code=int_code, message="Resource not found"), - ) - context.response = A2aResponse( - id=resp_id, - error=A2aErrorDetail(code=int_code, message="Resource not found"), + error=A2aErrorDetail(code=code, message="Resource not found"), ) @@ -186,7 +175,7 @@ def step_create_response_both_result_and_error(context: Context) -> None: A2aResponse( id="test-id", result={"status": "ok"}, - error=A2aErrorDetail(code=-32603, message="oops"), + error=A2aErrorDetail(code="ERR", message="oops"), ) except (ValidationError, ValueError) as exc: context.caught_error = exc @@ -266,15 +255,10 @@ def step_response_error_none(context: Context) -> None: r'a JSON-RPC 2.0 error response dict with id "(?P[^"]+)" and error code "(?P[^"]+)"' ) def step_jsonrpc_error_response_dict(context: Context, resp_id: str, code: str) -> None: - from cleveragents.a2a import errors as _a2a_errors - - int_code: int = A2A_CODE_MAP.get( - code, int(code) if code.lstrip("-").isdigit() else _a2a_errors.INTERNAL_ERROR - ) context.raw_dict = { "jsonrpc": "2.0", "id": resp_id, - "error": {"code": int_code, "message": "Resource not found"}, + "error": {"code": code, "message": "Resource not found"}, } @@ -306,21 +290,7 @@ def step_wire_facade_no_services(context: Context) -> None: def step_wire_dispatch(context: Context, method: str, params_json: str) -> None: params = json.loads(params_json) request = A2aRequest(method=method, params=params) - try: - context.wire_response = context.wire_facade.dispatch(request) - except Exception: - # For unknown methods, facade raises A2aOperationNotFoundError - # which is caught and returned as an error response - from cleveragents.a2a import errors as _a2a_errors - from cleveragents.a2a.models import A2aErrorDetail, A2aResponse - - context.wire_response = A2aResponse( - id=request.id, - error=A2aErrorDetail( - code=_a2a_errors.NOT_FOUND, - message=f"Unknown A2A method: {method}", - ), - ) + context.wire_response = context.wire_facade.dispatch(request) @when( @@ -360,6 +330,14 @@ def step_wire_response_error_not_none(context: Context) -> None: assert context.wire_response.error is not None, "Expected error to be set, got None" +@then(r'the wire-format response error code should equal "(?P[^"]+)"') +def step_wire_response_error_code(context: Context, code: str) -> None: + assert context.wire_response.error is not None, "Expected error to be set, got None" + assert context.wire_response.error.code == code, ( + f"Expected error code '{code}', got '{context.wire_response.error.code}'" + ) + + @then(r'the wire-format response id should equal "(?P[^"]+)"') def step_wire_response_id(context: Context, value: str) -> None: assert context.wire_response.id == value, ( @@ -367,58 +345,5 @@ def step_wire_response_id(context: Context, value: str) -> None: ) -# --------------------------------------------------------------------------- -# A2aErrorDetail — JSON-RPC 2.0 field name compliance (issue #2745) -# --------------------------------------------------------------------------- - - -@given( - r'an A2aErrorDetail with code "(?P[^"]+)" and message "(?P[^"]+)" and data (?P.+)' -) -def step_create_error_detail_with_data( - context: Context, code: str, msg: str, data_json: str -) -> None: - import json - - data = json.loads(data_json) - context.error_detail = A2aErrorDetail(code=code, message=msg, data=data) - - -@given( - r'an A2aErrorDetail with code "(?P[^"]+)" and message "(?P[^"]+)" and no data' -) -def step_create_error_detail_no_data(context: Context, code: str, msg: str) -> None: - context.error_detail = A2aErrorDetail(code=code, message=msg) - - -@when("I serialise the error detail to a dict") -def step_serialise_error_detail(context: Context) -> None: - context.error_detail_dict = context.error_detail.model_dump() - - -@then(r'the error detail dict should contain key "(?P[^"]+)"') -def step_error_detail_dict_has_key(context: Context, key: str) -> None: - assert key in context.error_detail_dict, ( - f"Expected key '{key}' in error detail dict, got keys: " - f"{list(context.error_detail_dict.keys())}" - ) - - -@then(r'the error detail dict should not contain key "(?P[^"]+)"') -def step_error_detail_dict_no_key(context: Context, key: str) -> None: - assert key not in context.error_detail_dict, ( - f"Expected key '{key}' NOT in error detail dict, but it was present with value: " - f"{context.error_detail_dict.get(key)!r}" - ) - - -@then(r'the error detail data should contain key "(?P[^"]+)"') -def step_error_detail_data_has_key(context: Context, key: str) -> None: - data = context.error_detail_dict.get("data", {}) - assert key in data, ( - f"Expected key '{key}' in error detail data, got keys: {list(data.keys())}" - ) - - # Reset step matcher to parse (default) so subsequent step files are not affected use_step_matcher("parse") diff --git a/src/cleveragents/a2a/facade.py b/src/cleveragents/a2a/facade.py index d535111fe..e50a8b0ad 100644 --- a/src/cleveragents/a2a/facade.py +++ b/src/cleveragents/a2a/facade.py @@ -218,7 +218,20 @@ class A2aLocalFacade: result=data, ) except A2aOperationNotFoundError: - raise + elapsed = (time.monotonic() - start) * 1000.0 + logger.warning( + "a2a.local.dispatch.method_not_found", + method=request.method, + request_id=request.id, + timing_ms=round(elapsed, 2), + ) + return A2aResponse( + id=request.id, + error=A2aErrorDetail( + code="-32601", + message="Method not found", + ), + ) except (SessionNotFoundError, SessionActorNotConfiguredError, DatabaseError): # Let domain exceptions propagate — they are re-raised by # handlers that want the caller (e.g. CLI) to receive the -- 2.52.0 From 03cafe4edeb5d670af8f93e5909247fa0d969055 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Sat, 30 May 2026 08:49:24 -0400 Subject: [PATCH 2/4] fix(a2a): use integer code -32601 and coerce Gherkin string in assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2aErrorDetail.code is typed as int (per JSON-RPC 2.0 §5.1 and the model docstring). Use the integer literal -32601 in facade.py and coerce the Gherkin-captured string to int in the step assertion so int(-32601) == int(-32601) instead of int(-32601) == str("-32601"). ISSUES CLOSED: #2859 --- features/steps/a2a_jsonrpc_wire_format_steps.py | 2 +- src/cleveragents/a2a/facade.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/features/steps/a2a_jsonrpc_wire_format_steps.py b/features/steps/a2a_jsonrpc_wire_format_steps.py index 0b7460b7e..7f42d8978 100644 --- a/features/steps/a2a_jsonrpc_wire_format_steps.py +++ b/features/steps/a2a_jsonrpc_wire_format_steps.py @@ -333,7 +333,7 @@ def step_wire_response_error_not_none(context: Context) -> None: @then(r'the wire-format response error code should equal "(?P[^"]+)"') def step_wire_response_error_code(context: Context, code: str) -> None: assert context.wire_response.error is not None, "Expected error to be set, got None" - assert context.wire_response.error.code == code, ( + assert context.wire_response.error.code == int(code), ( f"Expected error code '{code}', got '{context.wire_response.error.code}'" ) diff --git a/src/cleveragents/a2a/facade.py b/src/cleveragents/a2a/facade.py index e50a8b0ad..daba7b2dd 100644 --- a/src/cleveragents/a2a/facade.py +++ b/src/cleveragents/a2a/facade.py @@ -228,7 +228,7 @@ class A2aLocalFacade: return A2aResponse( id=request.id, error=A2aErrorDetail( - code="-32601", + code=-32601, message="Method not found", ), ) -- 2.52.0 From 5664988995e446976e01af73ca8bdd36652b9e4e Mon Sep 17 00:00:00 2001 From: CleverThis Date: Sat, 30 May 2026 11:17:16 -0400 Subject: [PATCH 3/4] fix(tests): update BDD scenarios to assert JSON-RPC error response for unknown operations `A2aLocalFacade.dispatch()` now returns an A2aResponse with error code -32601 instead of raising A2aOperationNotFoundError. Update all affected BDD step definitions and feature scenarios to assert the new behaviour: - `a2a_facade_steps.py`: step_dispatch_unknown stores the response; step_op_not_found_raised checks response.error.code == -32601 - `m6_facade_steps.py`: same pattern for m6 smoke steps; remove unused A2aOperationNotFoundError import - `consolidated_misc.feature`: remove the stale "error operation attribute" step that tested the old exception attribute - `a2a_jsonrpc_wire_format_steps.py`: import A2A_CODE_MAP and use integer codes in step_create_error_response, step_create_response_both_result_and_error, and step_jsonrpc_error_response_dict (A2aErrorDetail.code is now int) - `asgi_app.py`: remove the now-unreachable except A2aOperationNotFoundError block; instead check response.error.code == -32601 and return HTTP 404 --- features/consolidated_misc.feature | 1 - features/steps/a2a_facade_steps.py | 14 ++++++------- .../steps/a2a_jsonrpc_wire_format_steps.py | 9 +++++--- features/steps/m6_facade_steps.py | 21 +++++++++---------- .../infrastructure/server/asgi_app.py | 16 ++++---------- 5 files changed, 27 insertions(+), 34 deletions(-) diff --git a/features/consolidated_misc.feature b/features/consolidated_misc.feature index 1d27e936f..5d8980b33 100644 --- a/features/consolidated_misc.feature +++ b/features/consolidated_misc.feature @@ -124,7 +124,6 @@ Feature: Consolidated Misc Given a new A2aLocalFacade with no services When I dispatch an unknown operation "does.not.exist" Then an A2aOperationNotFoundError should be raised - And the error operation attribute should be "does.not.exist" # ----------------------------------------------------------------------- # A2aLocalFacade — list_operations diff --git a/features/steps/a2a_facade_steps.py b/features/steps/a2a_facade_steps.py index f07209c7d..b1a96adf9 100644 --- a/features/steps/a2a_facade_steps.py +++ b/features/steps/a2a_facade_steps.py @@ -121,17 +121,17 @@ def step_response_data_key_value(context: Context, key: str, value: str) -> None def step_dispatch_unknown(context: Context, operation: str) -> None: context.caught_error = None context.error = None - try: - request = A2aRequest(method=operation) - context.facade.dispatch(request) - except A2aOperationNotFoundError as exc: - context.caught_error = exc - context.error = exc + request = A2aRequest(method=operation) + context.response = context.facade.dispatch(request) @then(r"an A2aOperationNotFoundError should be raised") def step_op_not_found_raised(context: Context) -> None: - assert isinstance(context.caught_error, A2aOperationNotFoundError) + assert context.response is not None, "Expected a response from dispatch()" + assert context.response.error is not None, "Expected error in response, got result" + assert context.response.error.code == -32601, ( + f"Expected JSON-RPC -32601 (Method not found), got {context.response.error.code}" + ) @then(r'the error operation attribute should be "(?P[^"]+)"') diff --git a/features/steps/a2a_jsonrpc_wire_format_steps.py b/features/steps/a2a_jsonrpc_wire_format_steps.py index 7f42d8978..3f170d36b 100644 --- a/features/steps/a2a_jsonrpc_wire_format_steps.py +++ b/features/steps/a2a_jsonrpc_wire_format_steps.py @@ -11,6 +11,7 @@ import json from behave import given, then, use_step_matcher, when from behave.runner import Context +from features.steps._a2a_code_map import A2A_CODE_MAP from pydantic import ValidationError use_step_matcher("re") @@ -148,9 +149,10 @@ def step_serialise_response(context: Context) -> None: r'an error A2aResponse with id "(?P[^"]+)" and error code "(?P[^"]+)"' ) def step_create_error_response(context: Context, resp_id: str, code: str) -> None: + int_code = A2A_CODE_MAP.get(code, -32603) context.response = A2aResponse( id=resp_id, - error=A2aErrorDetail(code=code, message="Resource not found"), + error=A2aErrorDetail(code=int_code, message="Resource not found"), ) @@ -175,7 +177,7 @@ def step_create_response_both_result_and_error(context: Context) -> None: A2aResponse( id="test-id", result={"status": "ok"}, - error=A2aErrorDetail(code="ERR", message="oops"), + error=A2aErrorDetail(code=-32603, message="oops"), ) except (ValidationError, ValueError) as exc: context.caught_error = exc @@ -255,10 +257,11 @@ def step_response_error_none(context: Context) -> None: r'a JSON-RPC 2.0 error response dict with id "(?P[^"]+)" and error code "(?P[^"]+)"' ) def step_jsonrpc_error_response_dict(context: Context, resp_id: str, code: str) -> None: + int_code = A2A_CODE_MAP.get(code, -32603) context.raw_dict = { "jsonrpc": "2.0", "id": resp_id, - "error": {"code": code, "message": "Resource not found"}, + "error": {"code": int_code, "message": "Resource not found"}, } diff --git a/features/steps/m6_facade_steps.py b/features/steps/m6_facade_steps.py index 2056c5e4f..392563d10 100644 --- a/features/steps/m6_facade_steps.py +++ b/features/steps/m6_facade_steps.py @@ -14,11 +14,7 @@ from unittest.mock import MagicMock from behave import given, then, when from behave.runner import Context -from cleveragents.a2a.errors import ( - A2aNotAvailableError, - A2aOperationNotFoundError, - A2aVersionMismatchError, -) +from cleveragents.a2a.errors import A2aNotAvailableError, A2aVersionMismatchError from cleveragents.a2a.events import A2aEventQueue from cleveragents.a2a.facade import A2aLocalFacade from cleveragents.a2a.models import ( @@ -140,16 +136,19 @@ def step_m6_smoke_response_value( @when('I m6 smoke dispatch unknown operation "{operation}"') def step_m6_smoke_dispatch_unknown(context: Context, operation: str) -> None: request = A2aRequest(method=operation, params={}) - try: - context.m6_facade.dispatch(request) - context.m6_error = None - except A2aOperationNotFoundError as exc: - context.m6_error = exc + context.m6_response = context.m6_facade.dispatch(request) + context.m6_error = None @then("the m6 smoke facade should raise A2aOperationNotFoundError") def step_m6_smoke_error_op_not_found(context: Context) -> None: - assert isinstance(context.m6_error, A2aOperationNotFoundError) + assert context.m6_response is not None, "Expected a response from dispatch()" + assert context.m6_response.error is not None, ( + "Expected error in response, got result" + ) + assert context.m6_response.error.code == -32601, ( + f"Expected JSON-RPC -32601 (Method not found), got {context.m6_response.error.code}" + ) @when("I m6 smoke dispatch with a non-A2aRequest object") diff --git a/src/cleveragents/infrastructure/server/asgi_app.py b/src/cleveragents/infrastructure/server/asgi_app.py index fe56c9960..216704934 100644 --- a/src/cleveragents/infrastructure/server/asgi_app.py +++ b/src/cleveragents/infrastructure/server/asgi_app.py @@ -20,7 +20,6 @@ import structlog from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from cleveragents.a2a.errors import A2aOperationNotFoundError from cleveragents.a2a.facade import A2aLocalFacade from cleveragents.a2a.models import A2aRequest, A2aVersion @@ -155,24 +154,17 @@ def create_asgi_app( }, ) - try: - response = resolved_facade.dispatch(a2a_request) - return JSONResponse(content=response.model_dump(exclude_none=True)) - except A2aOperationNotFoundError as exc: + response = resolved_facade.dispatch(a2a_request) + if response.error is not None and response.error.code == -32601: logger.warning( "a2a.server.method_not_found", operation=a2a_request.method, ) return JSONResponse( status_code=404, - content={ - "jsonrpc": "2.0", - "error": { - "code": -32601, - "message": str(exc), - }, - }, + content=response.model_dump(exclude_none=True), ) + return JSONResponse(content=response.model_dump(exclude_none=True)) logger.info( "a2a.server.app_created", -- 2.52.0 From f3ad565803e57874505391aa87c0cda222fba497 Mon Sep 17 00:00:00 2001 From: controller-ci-rerun Date: Thu, 18 Jun 2026 10:57:43 -0400 Subject: [PATCH 4/4] chore: re-trigger CI [controller] -- 2.52.0