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/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 2639d72a4..3f170d36b 100644 --- a/features/steps/a2a_jsonrpc_wire_format_steps.py +++ b/features/steps/a2a_jsonrpc_wire_format_steps.py @@ -11,9 +11,8 @@ import json 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 +from pydantic import ValidationError use_step_matcher("re") @@ -150,15 +149,7 @@ 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"), - ) + int_code = A2A_CODE_MAP.get(code, -32603) context.response = A2aResponse( id=resp_id, error=A2aErrorDetail(code=int_code, message="Resource not found"), @@ -266,11 +257,7 @@ 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 - ) + int_code = A2A_CODE_MAP.get(code, -32603) context.raw_dict = { "jsonrpc": "2.0", "id": resp_id, @@ -306,21 +293,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 +333,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 == int(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 +348,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/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/a2a/facade.py b/src/cleveragents/a2a/facade.py index d535111fe..daba7b2dd 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 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",