"""Step definitions for a2a_jsonrpc_wire_format.feature. Verifies that A2aRequest and A2aResponse models use JSON-RPC 2.0 compliant field names on the wire, and that the A2aLocalFacade produces compliant responses. """ from __future__ import annotations 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 use_step_matcher("re") try: from cleveragents.a2a.facade import A2aLocalFacade from cleveragents.a2a.models import ( A2aErrorDetail, A2aRequest, A2aResponse, ) except ImportError: A2aLocalFacade = None # type: ignore[assignment,misc] A2aRequest = None # type: ignore[assignment,misc] A2aResponse = None # type: ignore[assignment,misc] A2aErrorDetail = None # type: ignore[assignment,misc] # --------------------------------------------------------------------------- # A2aRequest — construction and serialisation # --------------------------------------------------------------------------- @given( r'a valid A2aRequest with method "(?P[^"]+)" and params (?P.+)' ) def step_create_request(context: Context, method: str, params_json: str) -> None: params = json.loads(params_json) context.request = A2aRequest(method=method, params=params) @given(r'an A2aRequest with method "(?P[^"]+)" and id "(?P[^"]+)"') def step_create_request_with_id(context: Context, method: str, req_id: str) -> None: context.request = A2aRequest(method=method, id=req_id) @when("I serialise the request to a dict") def step_serialise_request(context: Context) -> None: context.serialised = context.request.model_dump() @then( r'the serialised dict should contain key "(?P[^"]+)" with value "(?P[^"]+)"' ) def step_dict_has_key_value(context: Context, key: str, value: str) -> None: assert key in context.serialised, ( f"Expected key '{key}' in serialised dict, got keys: {list(context.serialised.keys())}" ) assert str(context.serialised[key]) == value, ( f"Expected serialised['{key}'] = '{value}', got '{context.serialised[key]}'" ) @then(r'the serialised dict should contain key "(?P[^"]+)"') def step_dict_has_key(context: Context, key: str) -> None: assert key in context.serialised, ( f"Expected key '{key}' in serialised dict, got keys: {list(context.serialised.keys())}" ) @then(r'the serialised dict should not contain key "(?P[^"]+)"') def step_dict_no_key(context: Context, key: str) -> None: assert key not in context.serialised, ( f"Expected key '{key}' NOT in serialised dict, but it was present with value: " f"{context.serialised.get(key)!r}" ) @then("the request id should be non-empty") def step_request_id_non_empty(context: Context) -> None: assert context.request.id, ( f"Expected non-empty request id, got: {context.request.id!r}" ) @then(r'the request id should equal "(?P[^"]+)"') def step_request_id_equals(context: Context, value: str) -> None: assert context.request.id == value, ( f"Expected request id '{value}', got '{context.request.id}'" ) @when("I try to create an A2aRequest with empty method") def step_create_request_empty_method(context: Context) -> None: context.caught_error = None try: A2aRequest(method="") except (ValidationError, ValueError) as exc: context.caught_error = exc @when(r'I try to create an A2aRequest with jsonrpc "(?P[^"]+)"') def step_create_request_bad_jsonrpc(context: Context, version: str) -> None: context.caught_error = None try: A2aRequest(method="test/method", jsonrpc=version) except (ValidationError, ValueError) as exc: context.caught_error = exc @then("a wire format ValidationError should be raised") def step_validation_error_raised(context: Context) -> None: assert context.caught_error is not None, "Expected a ValidationError to be raised" assert isinstance(context.caught_error, (ValidationError, ValueError)), ( f"Expected ValidationError or ValueError, got {type(context.caught_error)}" ) # --------------------------------------------------------------------------- # A2aResponse — construction and serialisation (success) # --------------------------------------------------------------------------- @given( r'a successful A2aResponse with id "(?P[^"]+)" and result (?P.+)' ) def step_create_success_response( context: Context, resp_id: str, result_json: str ) -> None: result = json.loads(result_json) context.response = A2aResponse(id=resp_id, result=result) @when("I serialise the response to a dict") def step_serialise_response(context: Context) -> None: context.serialised = context.response.model_dump(exclude_none=True) # --------------------------------------------------------------------------- # A2aResponse — construction and serialisation (error) # --------------------------------------------------------------------------- @given( 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"), ) # --------------------------------------------------------------------------- # A2aResponse — validation # --------------------------------------------------------------------------- @when("I try to create an A2aResponse with neither result nor error") def step_create_response_no_result_no_error(context: Context) -> None: context.caught_error = None try: A2aResponse(id="test-id") except (ValidationError, ValueError) as exc: context.caught_error = exc @when("I try to create an A2aResponse with both result and error") def step_create_response_both_result_and_error(context: Context) -> None: context.caught_error = None try: A2aResponse( id="test-id", result={"status": "ok"}, error=A2aErrorDetail(code=-32603, message="oops"), ) except (ValidationError, ValueError) as exc: context.caught_error = exc # --------------------------------------------------------------------------- # Deserialisation — inbound JSON-RPC 2.0 payloads # --------------------------------------------------------------------------- @given( r'a JSON-RPC 2.0 request dict with method "(?P[^"]+)" and id "(?P[^"]+)"' ) def step_jsonrpc_request_dict(context: Context, method: str, req_id: str) -> None: context.raw_dict = { "jsonrpc": "2.0", "id": req_id, "method": method, "params": {}, } @when("I deserialise the dict into an A2aRequest") def step_deserialise_request(context: Context) -> None: context.request = A2aRequest.model_validate(context.raw_dict) @then(r'the request method should equal "(?P[^"]+)"') def step_request_method_equals(context: Context, value: str) -> None: assert context.request.method == value, ( f"Expected method '{value}', got '{context.request.method}'" ) @then(r'the request jsonrpc should equal "(?P[^"]+)"') def step_request_jsonrpc_equals(context: Context, value: str) -> None: assert context.request.jsonrpc == value, ( f"Expected jsonrpc '{value}', got '{context.request.jsonrpc}'" ) @given( r'a JSON-RPC 2.0 success response dict with id "(?P[^"]+)" and result (?P.+)' ) def step_jsonrpc_success_response_dict( context: Context, resp_id: str, result_json: str ) -> None: result = json.loads(result_json) context.raw_dict = { "jsonrpc": "2.0", "id": resp_id, "result": result, } @when("I deserialise the dict into an A2aResponse") def step_deserialise_response(context: Context) -> None: context.response = A2aResponse.model_validate(context.raw_dict) @then(r'the response result should contain key "(?P[^"]+)"') def step_response_result_has_key(context: Context, key: str) -> None: assert context.response.result is not None, "Expected result to be set" assert key in context.response.result, ( f"Expected key '{key}' in result, got: {list(context.response.result.keys())}" ) @then("the response error should be None") def step_response_error_none(context: Context) -> None: assert context.response.error is None, ( f"Expected error to be None, got: {context.response.error}" ) @given( 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"}, } @then("the response error should not be None") def step_response_error_not_none(context: Context) -> None: assert context.response.error is not None, "Expected error to be set" @then("the response result should be None") def step_response_result_none(context: Context) -> None: assert context.response.result is None, ( f"Expected result to be None, got: {context.response.result}" ) # --------------------------------------------------------------------------- # Facade dispatch — JSON-RPC 2.0 compliant responses # --------------------------------------------------------------------------- @given("a wire-format facade with no services") def step_wire_facade_no_services(context: Context) -> None: context.wire_facade = A2aLocalFacade() @when( r'I dispatch wire-format method "(?P[^"]+)" with params (?P.+)' ) 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}", ), ) @when( r'I dispatch wire-format method "(?P[^"]+)" with id "(?P[^"]+)" and params (?P.+)' ) def step_wire_dispatch_with_id( context: Context, method: str, req_id: str, params_json: str ) -> None: params = json.loads(params_json) request = A2aRequest(method=method, id=req_id, params=params) context.wire_response = context.wire_facade.dispatch(request) @then(r'the wire-format response jsonrpc should equal "(?P[^"]+)"') def step_wire_response_jsonrpc(context: Context, value: str) -> None: assert context.wire_response.jsonrpc == value, ( f"Expected jsonrpc '{value}', got '{context.wire_response.jsonrpc}'" ) @then("the wire-format response result should not be None") def step_wire_response_result_not_none(context: Context) -> None: assert context.wire_response.result is not None, ( f"Expected result to be set, got None. Error: {context.wire_response.error}" ) @then("the wire-format response error should be None") def step_wire_response_error_none(context: Context) -> None: assert context.wire_response.error is None, ( f"Expected error to be None, got: {context.wire_response.error}" ) @then("the wire-format response error should not be None") 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 id should equal "(?P[^"]+)"') def step_wire_response_id(context: Context, value: str) -> None: assert context.wire_response.id == value, ( f"Expected response id '{value}', got '{context.wire_response.id}'" ) # --------------------------------------------------------------------------- # 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")