diff --git a/features/a2a_facade_coverage.feature b/features/a2a_facade_coverage.feature index 684dcf7d..eaee174d 100644 --- a/features/a2a_facade_coverage.feature +++ b/features/a2a_facade_coverage.feature @@ -256,7 +256,6 @@ Feature: A2A local facade coverage — uncovered handler and edge-case paths When I dispatch facade-cov operation "plan.execute" with params {"plan_id": "PLAN-FAIL"} Then the facade-cov response status should be "error" And the facade-cov response error should not be None - And the facade-cov response should have timing_ms set # ------------------------------------------------------------------- # Dispatch TypeError — non-request argument diff --git a/features/a2a_jsonrpc_wire_format.feature b/features/a2a_jsonrpc_wire_format.feature new file mode 100644 index 00000000..c1255857 --- /dev/null +++ b/features/a2a_jsonrpc_wire_format.feature @@ -0,0 +1,191 @@ +Feature: A2A JSON-RPC 2.0 wire format compliance + As a developer maintaining the A2A protocol layer + I want A2aRequest and A2aResponse to use JSON-RPC 2.0 field names + So that external A2A-compliant clients can communicate with the server + + # ------------------------------------------------------------------- + # A2aRequest serialisation — JSON-RPC 2.0 field names + # ------------------------------------------------------------------- + + Scenario: A2aRequest serialises with jsonrpc field set to "2.0" + Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"} + When I serialise the request to a dict + Then the serialised dict should contain key "jsonrpc" with value "2.0" + + Scenario: A2aRequest serialises with id field + Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"} + When I serialise the request to a dict + Then the serialised dict should contain key "id" + + Scenario: A2aRequest serialises with method field + Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"} + When I serialise the request to a dict + Then the serialised dict should contain key "method" with value "_cleveragents/plan/status" + + Scenario: A2aRequest serialises with params field + Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"} + When I serialise the request to a dict + Then the serialised dict should contain key "params" + + Scenario: A2aRequest does not contain non-standard field a2a_version + Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"} + When I serialise the request to a dict + Then the serialised dict should not contain key "a2a_version" + + Scenario: A2aRequest does not contain non-standard field request_id + Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"} + When I serialise the request to a dict + Then the serialised dict should not contain key "request_id" + + Scenario: A2aRequest does not contain non-standard field operation + Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"} + When I serialise the request to a dict + Then the serialised dict should not contain key "operation" + + Scenario: A2aRequest does not contain non-standard field auth + Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"} + When I serialise the request to a dict + Then the serialised dict should not contain key "auth" + + Scenario: A2aRequest auto-generates id when not provided + Given a valid A2aRequest with method "session.create" and params {} + Then the request id should be non-empty + + Scenario: A2aRequest accepts explicit id + Given an A2aRequest with method "session.create" and id "my-custom-id" + Then the request id should equal "my-custom-id" + + Scenario: A2aRequest rejects empty method + When I try to create an A2aRequest with empty method + Then a wire format ValidationError should be raised + + Scenario: A2aRequest rejects non-2.0 jsonrpc version + When I try to create an A2aRequest with jsonrpc "1.0" + Then a wire format ValidationError should be raised + + # ------------------------------------------------------------------- + # A2aResponse serialisation — JSON-RPC 2.0 field names (success) + # ------------------------------------------------------------------- + + Scenario: A2aResponse success serialises with jsonrpc field set to "2.0" + Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"} + When I serialise the response to a dict + Then the serialised dict should contain key "jsonrpc" with value "2.0" + + Scenario: A2aResponse success serialises with id field + Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"} + When I serialise the response to a dict + Then the serialised dict should contain key "id" with value "REQ-001" + + Scenario: A2aResponse success serialises with result field + Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"} + When I serialise the response to a dict + Then the serialised dict should contain key "result" + + Scenario: A2aResponse success does not contain non-standard field a2a_version + Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"} + When I serialise the response to a dict + Then the serialised dict should not contain key "a2a_version" + + Scenario: A2aResponse success does not contain non-standard field request_id + Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"} + When I serialise the response to a dict + Then the serialised dict should not contain key "request_id" + + Scenario: A2aResponse success does not contain non-standard field status + Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"} + When I serialise the response to a dict + Then the serialised dict should not contain key "status" + + Scenario: A2aResponse success does not contain non-standard field data + Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"} + When I serialise the response to a dict + Then the serialised dict should not contain key "data" + + Scenario: A2aResponse success does not contain non-standard field timing_ms + Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"} + When I serialise the response to a dict + Then the serialised dict should not contain key "timing_ms" + + # ------------------------------------------------------------------- + # A2aResponse serialisation — JSON-RPC 2.0 field names (error) + # ------------------------------------------------------------------- + + Scenario: A2aResponse error serialises with error field + Given an error A2aResponse with id "REQ-002" and error code "NOT_FOUND" + When I serialise the response to a dict + Then the serialised dict should contain key "error" + + Scenario: A2aResponse error does not contain result field + Given an error A2aResponse with id "REQ-002" and error code "NOT_FOUND" + When I serialise the response to a dict + Then the serialised dict should not contain key "result" + + Scenario: A2aResponse error does not contain non-standard field status + Given an error A2aResponse with id "REQ-002" and error code "NOT_FOUND" + When I serialise the response to a dict + Then the serialised dict should not contain key "status" + + # ------------------------------------------------------------------- + # A2aResponse validation — mutual exclusion of result and error + # ------------------------------------------------------------------- + + Scenario: A2aResponse requires either result or error + When I try to create an A2aResponse with neither result nor error + Then a wire format ValidationError should be raised + + Scenario: A2aResponse rejects both result and error set simultaneously + When I try to create an A2aResponse with both result and error + Then a wire format ValidationError should be raised + + # ------------------------------------------------------------------- + # Deserialisation — inbound JSON-RPC 2.0 payloads + # ------------------------------------------------------------------- + + Scenario: A2aRequest deserialises from JSON-RPC 2.0 compliant dict + Given a JSON-RPC 2.0 request dict with method "message/send" and id "42" + When I deserialise the dict into an A2aRequest + Then the request method should equal "message/send" + And the request id should equal "42" + And the request jsonrpc should equal "2.0" + + Scenario: A2aResponse deserialises from JSON-RPC 2.0 success dict + Given a JSON-RPC 2.0 success response dict with id "42" and result {"plan_id": "P1"} + When I deserialise the dict into an A2aResponse + Then the response result should contain key "plan_id" + And the response error should be None + + Scenario: A2aResponse deserialises from JSON-RPC 2.0 error dict + Given a JSON-RPC 2.0 error response dict with id "42" and error code "NOT_FOUND" + When I deserialise the dict into an A2aResponse + Then the response error should not be None + And the response result should be None + + # ------------------------------------------------------------------- + # Facade dispatch — produces JSON-RPC 2.0 compliant responses + # ------------------------------------------------------------------- + + Scenario: Facade dispatch produces response with jsonrpc "2.0" + Given a wire-format facade with no services + When I dispatch wire-format method "_cleveragents/health/check" with params {} + Then the wire-format response jsonrpc should equal "2.0" + + Scenario: Facade dispatch success produces response with result field + Given a wire-format facade with no services + When I dispatch wire-format method "_cleveragents/health/check" with params {} + Then the wire-format response result should not be None + + Scenario: Facade dispatch success produces response without error field + Given a wire-format facade with no services + When I dispatch wire-format method "_cleveragents/health/check" with params {} + Then the wire-format response error should be None + + Scenario: Facade dispatch error produces response with error field + 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 + + 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 {} + Then the wire-format response id should equal "test-id-123" diff --git a/features/steps/a2a_cli_facade_integration_steps.py b/features/steps/a2a_cli_facade_integration_steps.py index ee1dbaa2..6c0b13ff 100644 --- a/features/steps/a2a_cli_facade_integration_steps.py +++ b/features/steps/a2a_cli_facade_integration_steps.py @@ -64,37 +64,37 @@ def step_facade_raises_error(context: Any) -> None: @when("I dispatch a session.create operation via the CLI facade helper") def step_dispatch_session_create(context: Any) -> None: - request = A2aRequest(operation="session.create", params={"actor": "stub"}) + request = A2aRequest(method="session.create", params={"actor": "stub"}) context.response = context.facade.dispatch(request) @when("I dispatch a plan.create operation via the CLI facade helper") def step_dispatch_plan_create(context: Any) -> None: - request = A2aRequest(operation="plan.create", params={"action_name": "test"}) + request = A2aRequest(method="plan.create", params={"action_name": "test"}) context.response = context.facade.dispatch(request) @when("I dispatch a plan.execute operation via the CLI facade helper") def step_dispatch_plan_execute(context: Any) -> None: - request = A2aRequest(operation="plan.execute", params={"plan_id": "test-plan-001"}) + request = A2aRequest(method="plan.execute", params={"plan_id": "test-plan-001"}) context.response = context.facade.dispatch(request) @when("I dispatch a plan.apply operation via the CLI facade helper") def step_dispatch_plan_apply(context: Any) -> None: - request = A2aRequest(operation="plan.apply", params={"plan_id": "test-plan-001"}) + request = A2aRequest(method="plan.apply", params={"plan_id": "test-plan-001"}) context.response = context.facade.dispatch(request) @when("I dispatch a plan.status operation via the CLI facade helper") def step_dispatch_plan_status(context: Any) -> None: - request = A2aRequest(operation="plan.status", params={"plan_id": "test-plan-001"}) + request = A2aRequest(method="plan.status", params={"plan_id": "test-plan-001"}) context.response = context.facade.dispatch(request) @when("I dispatch a plan.diff operation via the CLI facade helper") def step_dispatch_plan_diff(context: Any) -> None: - request = A2aRequest(operation="plan.diff", params={"plan_id": "test-plan-001"}) + request = A2aRequest(method="plan.diff", params={"plan_id": "test-plan-001"}) context.response = context.facade.dispatch(request) @@ -139,8 +139,8 @@ def step_check_facade_cached(context: Any) -> None: @then("the facade should return an ok response") def step_check_ok_response(context: Any) -> None: - assert context.response.status == "ok", ( - f"Expected 'ok', got '{context.response.status}'" + assert context.response.error is None, ( + f"Expected no error, got error: {context.response.error}" ) diff --git a/features/steps/a2a_extension_methods_steps.py b/features/steps/a2a_extension_methods_steps.py index 8e126802..ee805453 100644 --- a/features/steps/a2a_extension_methods_steps.py +++ b/features/steps/a2a_extension_methods_steps.py @@ -29,28 +29,28 @@ def step_create_extension_facade(context: Any) -> None: @when('I dispatch "{operation}" with params {params_json}') def step_dispatch_operation(context: Any, operation: str, params_json: str) -> None: params = json.loads(params_json) - request = A2aRequest(operation=operation, params=params) + request = A2aRequest(method=operation, params=params) context.response = context.facade.dispatch(request) @then("the response should be ok") def step_response_ok(context: Any) -> None: - assert context.response.status == "ok", ( - f"Expected 'ok', got '{context.response.status}'" + assert context.response.error is None, ( + f"Expected no error, got error: {context.response.error}" ) @then('the response data should contain key "{key}"') def step_response_has_key(context: Any, key: str) -> None: - assert key in context.response.data, ( - f"Expected key '{key}' in response data, got: {list(context.response.data.keys())}" + assert key in context.response.result, ( + f"Expected key '{key}' in response data, got: {list(context.response.result.keys())}" ) @then('the response data should have "{key}" set to true') def step_response_stub_true(context: Any, key: str) -> None: - assert context.response.data.get(key) is True, ( - f"Expected data['{key}'] to be True, got: {context.response.data.get(key)}" + assert context.response.result.get(key) is True, ( + f"Expected data['{key}'] to be True, got: {context.response.result.get(key)}" ) diff --git a/features/steps/a2a_facade_coverage_boost_steps.py b/features/steps/a2a_facade_coverage_boost_steps.py index 5eecd883..c785435d 100644 --- a/features/steps/a2a_facade_coverage_boost_steps.py +++ b/features/steps/a2a_facade_coverage_boost_steps.py @@ -89,7 +89,7 @@ def step_cb_create_facade_non_dict(context: Context) -> None: ) def step_cb_dispatch(context: Context, operation: str, params_json: str) -> None: params: dict[str, Any] = json.loads(params_json) - request = A2aRequest(operation=operation, params=params) + request = A2aRequest(method=operation, params=params) context.cb_response = context.cb_facade.dispatch(request) @@ -110,8 +110,8 @@ def step_cb_type_error_raised(context: Context, msg: str) -> None: @then(r'the coverage-boost response status should be "(?P[^"]+)"') def step_cb_response_status(context: Context, status: str) -> None: - assert context.cb_response.status == status, ( - f"Expected '{status}', got '{context.cb_response.status}'" + assert ((context.cb_response.error is None) == (status == 'ok')), ( + f"Expected status '{status}', got error={context.cb_response.error}" ) diff --git a/features/steps/a2a_facade_coverage_steps.py b/features/steps/a2a_facade_coverage_steps.py index 92d8f39a..38c01a5b 100644 --- a/features/steps/a2a_facade_coverage_steps.py +++ b/features/steps/a2a_facade_coverage_steps.py @@ -229,7 +229,7 @@ def step_fc_facade_failing_cleanup(context: Context) -> None: ) def step_fc_dispatch(context: Context, operation: str, params_json: str) -> None: params: dict[str, Any] = json.loads(params_json) - request = A2aRequest(operation=operation, params=params) + request = A2aRequest(method=operation, params=params) context.fc_response = context.fc_facade.dispatch(request) @@ -263,8 +263,8 @@ def step_fc_register_empty_name(context: Context) -> None: @then(r'the facade-cov response status should be "(?P[^"]+)"') def step_fc_response_status(context: Context, status: str) -> None: - assert context.fc_response.status == status, ( - f"Expected '{status}', got '{context.fc_response.status}'" + assert ((context.fc_response.error is None) == (status == 'ok')), ( + f"Expected status '{status}', got error={context.fc_response.error}" ) @@ -272,27 +272,27 @@ def step_fc_response_status(context: Context, status: str) -> None: r'the facade-cov response data key "(?P[^"]+)" should equal "(?P[^"]+)"' ) def step_fc_data_key_equals(context: Context, key: str, value: str) -> None: - actual = context.fc_response.data.get(key) + actual = context.fc_response.result.get(key) assert str(actual) == value, f"Expected data['{key}'] = '{value}', got '{actual}'" @then(r'the facade-cov response data key "(?P[^"]+)" should be true') def step_fc_data_key_true(context: Context, key: str) -> None: - actual = context.fc_response.data.get(key) + actual = context.fc_response.result.get(key) assert actual is True, f"Expected data['{key}'] to be True, got {actual!r}" @then(r'the facade-cov response data should contain key "(?P[^"]+)"') def step_fc_data_has_key(context: Context, key: str) -> None: - assert key in context.fc_response.data, ( + assert key in context.fc_response.result, ( f"Expected key '{key}' in response data, " - f"got: {list(context.fc_response.data.keys())}" + f"got: {list(context.fc_response.result.keys())}" ) @then(r'the facade-cov response data should not contain key "(?P[^"]+)"') def step_fc_data_no_key(context: Context, key: str) -> None: - assert key not in context.fc_response.data, ( + assert key not in context.fc_response.result, ( f"Expected key '{key}' not in response data, but found it" ) @@ -304,10 +304,8 @@ def step_fc_error_not_none(context: Context) -> None: @then(r"the facade-cov response should have timing_ms set") def step_fc_timing_set(context: Context) -> None: - assert context.fc_response.timing_ms is not None, "Expected timing_ms to be set" - assert context.fc_response.timing_ms >= 0, ( - f"Expected non-negative timing_ms, got {context.fc_response.timing_ms}" - ) + # timing_ms removed from wire format per JSON-RPC 2.0 compliance + assert context.fc_response.result is not None, "Expected result to be set" @then(r'a facade-cov TypeError should be raised with message "(?P[^"]+)"') @@ -331,19 +329,19 @@ def step_fc_value_error_raised(context: Context) -> None: def step_fc_session_create_used_service(context: Context) -> None: # After registering a new service and dispatching again, # the response should reflect the mock service return value - assert context.fc_response.status == "ok" - assert context.fc_response.data["session_id"] == "MOCK-SESSION-001" + assert context.fc_response.error is None + assert context.fc_response.result["session_id"] == "MOCK-SESSION-001" @then(r"the facade-cov response tools list should have (?P\d+) items") def step_fc_tools_count(context: Context, count: str) -> None: - tools = context.fc_response.data.get("tools", []) + tools = context.fc_response.result.get("tools", []) assert len(tools) == int(count), f"Expected {count} tools, got {len(tools)}" @then(r"the facade-cov response resources list should have (?P\d+) items") def step_fc_resources_count(context: Context, count: str) -> None: - resources = context.fc_response.data.get("resources", []) + resources = context.fc_response.result.get("resources", []) assert len(resources) == int(count), ( f"Expected {count} resources, got {len(resources)}" ) diff --git a/features/steps/a2a_facade_steps.py b/features/steps/a2a_facade_steps.py index 6360e735..40347bec 100644 --- a/features/steps/a2a_facade_steps.py +++ b/features/steps/a2a_facade_steps.py @@ -86,28 +86,30 @@ def step_value_error_raised(context: Context) -> None: @when(r'I dispatch operation "(?P[^"]+)" with params (?P.+)') def step_dispatch_operation(context: Context, operation: str, params_json: str) -> None: params: dict[str, Any] = json.loads(params_json) - request = A2aRequest(operation=operation, params=params) + request = A2aRequest(method=operation, params=params) context.response = context.facade.dispatch(request) @then(r'the response status should be "(?P[^"]+)"') def step_response_status(context: Context, status: str) -> None: - assert context.response.status == status, ( - f"Expected status '{status}', got '{context.response.status}'" + is_ok = context.response.error is None + expected_ok = (status == "ok") + assert is_ok == expected_ok, ( + f"Expected status '{status}', got error={context.response.error}" ) @then(r'response data includes key "(?P[^"]+)"') def step_response_data_has_key(context: Context, key: str) -> None: - assert key in context.response.data, ( - f"Key '{key}' not found in response data: {context.response.data}" + assert key in context.response.result, ( + f"Key '{key}' not found in response data: {context.response.result}" ) @then(r'response data key "(?P[^"]+)" equals "(?P[^"]+)"') def step_response_data_key_value(context: Context, key: str, value: str) -> None: - assert key in context.response.data, f"Key '{key}' not found in response data" - actual = context.response.data[key] + assert key in context.response.result, f"Key '{key}' not found in response data" + actual = context.response.result[key] assert str(actual) == value, f"Expected '{value}', got '{actual}'" @@ -119,7 +121,7 @@ def step_dispatch_unknown(context: Context, operation: str) -> None: context.caught_error = None context.error = None try: - request = A2aRequest(operation=operation) + request = A2aRequest(method=operation) context.facade.dispatch(request) except A2aOperationNotFoundError as exc: context.caught_error = exc @@ -170,7 +172,7 @@ def step_transport_send(context: Context) -> None: context.caught_error = None context.error = None try: - request = A2aRequest(operation="test.op") + request = A2aRequest(method="test.op") context.transport.send(request) except A2aNotAvailableError as exc: context.caught_error = exc @@ -360,7 +362,7 @@ def step_current_version(context: Context, version: str) -> None: @when(r'I create an A2aRequest with operation "(?P[^"]+)"') def step_create_request(context: Context, operation: str) -> None: - context.request = A2aRequest(operation=operation) + context.request = A2aRequest(method=operation) @then(r"the request should have a non-empty request_id") @@ -378,7 +380,7 @@ def step_create_request_empty(context: Context) -> None: context.caught_error = None context.error = None try: - A2aRequest(operation="") + A2aRequest(method="") except ValidationError as exc: context.caught_error = exc context.error = exc diff --git a/features/steps/a2a_jsonrpc_wire_format_steps.py b/features/steps/a2a_jsonrpc_wire_format_steps.py new file mode 100644 index 00000000..f8633da2 --- /dev/null +++ b/features/steps/a2a_jsonrpc_wire_format_steps.py @@ -0,0 +1,352 @@ +"""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, when +from behave.runner import Context +from pydantic import ValidationError + +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: + context.response = A2aResponse( + id=resp_id, + error=A2aErrorDetail(code=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="ERR", 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: + context.raw_dict = { + "jsonrpc": "2.0", + "id": resp_id, + "error": {"code": 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.models import A2aErrorDetail, A2aResponse + + context.wire_response = A2aResponse( + id=request.id, + error=A2aErrorDetail( + code="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}'" + ) diff --git a/robot/a2a_jsonrpc_wire_format.robot b/robot/a2a_jsonrpc_wire_format.robot new file mode 100644 index 00000000..bb78a209 --- /dev/null +++ b/robot/a2a_jsonrpc_wire_format.robot @@ -0,0 +1,73 @@ +*** Settings *** +Documentation Integration tests verifying A2aRequest/A2aResponse produce +... JSON-RPC 2.0 compliant wire frames end-to-end. +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_a2a_jsonrpc_wire_format.py + +*** Test Cases *** +A2A Request Wire Frame Is JSON-RPC 2.0 Compliant + [Documentation] Verify A2aRequest serialises to JSON-RPC 2.0 wire format + ... with jsonrpc, id, method, params fields and no non-standard fields. + ${result}= Run Process ${PYTHON} ${HELPER} request-wire-format cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} a2a-request-wire-format-ok + +A2A Response Success Wire Frame Is JSON-RPC 2.0 Compliant + [Documentation] Verify A2aResponse (success) serialises to JSON-RPC 2.0 wire format + ... with jsonrpc, id, result fields and no non-standard fields. + ${result}= Run Process ${PYTHON} ${HELPER} response-success-wire-format cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} a2a-response-success-wire-format-ok + +A2A Response Error Wire Frame Is JSON-RPC 2.0 Compliant + [Documentation] Verify A2aResponse (error) serialises to JSON-RPC 2.0 wire format + ... with jsonrpc, id, error fields and no non-standard fields. + ${result}= Run Process ${PYTHON} ${HELPER} response-error-wire-format cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} a2a-response-error-wire-format-ok + +A2A Facade Dispatch Produces JSON-RPC 2.0 Response + [Documentation] Verify end-to-end: facade.dispatch() returns a response + ... with jsonrpc="2.0", id matching the request, and result set. + ${result}= Run Process ${PYTHON} ${HELPER} facade-dispatch-jsonrpc cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} a2a-facade-dispatch-jsonrpc-ok + +A2A Request Deserialises From JSON-RPC 2.0 Payload + [Documentation] Verify A2aRequest can be constructed from a JSON-RPC 2.0 dict + ... (simulating inbound network payload). + ${result}= Run Process ${PYTHON} ${HELPER} request-deserialise cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} a2a-request-deserialise-ok + +A2A Response Deserialises From JSON-RPC 2.0 Payload + [Documentation] Verify A2aResponse can be constructed from a JSON-RPC 2.0 dict + ... (simulating inbound network payload). + ${result}= Run Process ${PYTHON} ${HELPER} response-deserialise cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} a2a-response-deserialise-ok + +A2A Request Rejects Non-Standard Field Names + [Documentation] Verify that A2aRequest no longer accepts old non-standard + ... field names (a2a_version, request_id, operation). + ${result}= Run Process ${PYTHON} ${HELPER} request-rejects-old-fields cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} a2a-request-rejects-old-fields-ok diff --git a/robot/helper_a2a_facade.py b/robot/helper_a2a_facade.py index aa221d20..b9f521ad 100644 --- a/robot/helper_a2a_facade.py +++ b/robot/helper_a2a_facade.py @@ -31,9 +31,13 @@ from cleveragents.a2a.versioning import A2aVersionNegotiator # noqa: E402 def facade_dispatch() -> None: """Dispatch session.create via local facade.""" facade = A2aLocalFacade() - request = A2aRequest(operation="session.create") + request = A2aRequest(method="session.create") response = facade.dispatch(request) - if response.status == "ok" and "session_id" in response.data: + if ( + response.error is None + and response.result is not None + and "session_id" in response.result + ): print("a2a-facade-dispatch-ok") else: print(f"FAIL: unexpected response {response}", file=sys.stderr) diff --git a/robot/helper_a2a_jsonrpc_wire_format.py b/robot/helper_a2a_jsonrpc_wire_format.py new file mode 100644 index 00000000..958c6c6a --- /dev/null +++ b/robot/helper_a2a_jsonrpc_wire_format.py @@ -0,0 +1,335 @@ +"""Helper script for a2a_jsonrpc_wire_format.robot integration tests. + +Each subcommand is a self-contained check that prints a sentinel on success +and exits with code 1 on failure. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Ensure local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.a2a.facade import A2aLocalFacade # noqa: E402 +from cleveragents.a2a.models import ( # noqa: E402 + A2aErrorDetail, + A2aRequest, + A2aResponse, +) + +# Non-standard field names that must NOT appear in wire output +_BANNED_REQUEST_FIELDS = {"a2a_version", "request_id", "operation", "auth"} +_BANNED_RESPONSE_FIELDS = {"a2a_version", "request_id", "status", "data", "timing_ms"} + +# Required JSON-RPC 2.0 field names +_REQUIRED_REQUEST_FIELDS = {"jsonrpc", "id", "method", "params"} +_REQUIRED_SUCCESS_RESPONSE_FIELDS = {"jsonrpc", "id", "result"} +_REQUIRED_ERROR_RESPONSE_FIELDS = {"jsonrpc", "id", "error"} + + +def request_wire_format() -> None: + """Verify A2aRequest serialises to JSON-RPC 2.0 wire format.""" + req = A2aRequest(method="_cleveragents/plan/status", params={"plan_id": "P1"}) + wire = req.model_dump() + + # Check required fields + for field in _REQUIRED_REQUEST_FIELDS: + if field not in wire: + print( + f"FAIL: missing required field '{field}' in request wire format", + file=sys.stderr, + ) + print(f" Got fields: {list(wire.keys())}", file=sys.stderr) + sys.exit(1) + + # Check jsonrpc value + if wire["jsonrpc"] != "2.0": + print( + f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'", + file=sys.stderr, + ) + sys.exit(1) + + # Check method value + if wire["method"] != "_cleveragents/plan/status": + print(f"FAIL: method mismatch: {wire['method']}", file=sys.stderr) + sys.exit(1) + + # Check banned fields are absent + for field in _BANNED_REQUEST_FIELDS: + if field in wire: + print( + f"FAIL: non-standard field '{field}' present in request wire format", + file=sys.stderr, + ) + sys.exit(1) + + print("a2a-request-wire-format-ok") + + +def response_success_wire_format() -> None: + """Verify A2aResponse (success) serialises to JSON-RPC 2.0 wire format.""" + resp = A2aResponse(id="REQ-001", result={"plan_id": "P1", "phase": "strategize"}) + wire = resp.model_dump(exclude_none=True) + + # Check required fields + for field in _REQUIRED_SUCCESS_RESPONSE_FIELDS: + if field not in wire: + print( + f"FAIL: missing required field '{field}' in success response wire format", + file=sys.stderr, + ) + print(f" Got fields: {list(wire.keys())}", file=sys.stderr) + sys.exit(1) + + # Check jsonrpc value + if wire["jsonrpc"] != "2.0": + print( + f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'", + file=sys.stderr, + ) + sys.exit(1) + + # Check id value + if wire["id"] != "REQ-001": + print(f"FAIL: id mismatch: {wire['id']}", file=sys.stderr) + sys.exit(1) + + # Check result contains expected data + if "plan_id" not in wire["result"]: + print(f"FAIL: result missing plan_id: {wire['result']}", file=sys.stderr) + sys.exit(1) + + # Check banned fields are absent + for field in _BANNED_RESPONSE_FIELDS: + if field in wire: + print( + f"FAIL: non-standard field '{field}' present in success response wire format", + file=sys.stderr, + ) + sys.exit(1) + + # Check error is absent in success response + if "error" in wire: + print( + f"FAIL: 'error' field present in success response: {wire['error']}", + file=sys.stderr, + ) + sys.exit(1) + + print("a2a-response-success-wire-format-ok") + + +def response_error_wire_format() -> None: + """Verify A2aResponse (error) serialises to JSON-RPC 2.0 wire format.""" + resp = A2aResponse( + id="REQ-002", + error=A2aErrorDetail(code="NOT_FOUND", message="Plan not found"), + ) + wire = resp.model_dump(exclude_none=True) + + # Check required fields + for field in _REQUIRED_ERROR_RESPONSE_FIELDS: + if field not in wire: + print( + f"FAIL: missing required field '{field}' in error response wire format", + file=sys.stderr, + ) + print(f" Got fields: {list(wire.keys())}", file=sys.stderr) + sys.exit(1) + + # Check jsonrpc value + if wire["jsonrpc"] != "2.0": + print( + f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'", + file=sys.stderr, + ) + sys.exit(1) + + # Check error structure + error = wire["error"] + if error.get("code") != "NOT_FOUND": + print(f"FAIL: error code mismatch: {error.get('code')}", file=sys.stderr) + sys.exit(1) + + # Check banned fields are absent + for field in _BANNED_RESPONSE_FIELDS: + if field in wire: + print( + f"FAIL: non-standard field '{field}' present in error response wire format", + file=sys.stderr, + ) + sys.exit(1) + + # Check result is absent in error response + if "result" in wire: + print( + f"FAIL: 'result' field present in error response: {wire['result']}", + file=sys.stderr, + ) + sys.exit(1) + + print("a2a-response-error-wire-format-ok") + + +def facade_dispatch_jsonrpc() -> None: + """Verify end-to-end: facade.dispatch() returns JSON-RPC 2.0 compliant response.""" + facade = A2aLocalFacade() + req = A2aRequest(method="_cleveragents/health/check", id="test-req-001", params={}) + resp = facade.dispatch(req) + + # Check jsonrpc field + if resp.jsonrpc != "2.0": + print( + f"FAIL: response jsonrpc should be '2.0', got '{resp.jsonrpc}'", + file=sys.stderr, + ) + sys.exit(1) + + # Check id matches request + if resp.id != "test-req-001": + print( + f"FAIL: response id should be 'test-req-001', got '{resp.id}'", + file=sys.stderr, + ) + sys.exit(1) + + # Check result is set (success path) + if resp.result is None: + print( + f"FAIL: response result should be set, got None. Error: {resp.error}", + file=sys.stderr, + ) + sys.exit(1) + + # Check error is None (success path) + if resp.error is not None: + print( + f"FAIL: response error should be None, got: {resp.error}", + file=sys.stderr, + ) + sys.exit(1) + + # Verify no non-standard fields on the model + wire = resp.model_dump(exclude_none=True) + for field in _BANNED_RESPONSE_FIELDS: + if field in wire: + print( + f"FAIL: non-standard field '{field}' in facade response wire format", + file=sys.stderr, + ) + sys.exit(1) + + print("a2a-facade-dispatch-jsonrpc-ok") + + +def request_deserialise() -> None: + """Verify A2aRequest can be constructed from a JSON-RPC 2.0 dict.""" + payload = { + "jsonrpc": "2.0", + "id": "inbound-42", + "method": "message/send", + "params": {"content": "hello"}, + } + req = A2aRequest.model_validate(payload) + + if req.method != "message/send": + print(f"FAIL: method mismatch: {req.method}", file=sys.stderr) + sys.exit(1) + if req.id != "inbound-42": + print(f"FAIL: id mismatch: {req.id}", file=sys.stderr) + sys.exit(1) + if req.jsonrpc != "2.0": + print(f"FAIL: jsonrpc mismatch: {req.jsonrpc}", file=sys.stderr) + sys.exit(1) + if req.params.get("content") != "hello": + print(f"FAIL: params mismatch: {req.params}", file=sys.stderr) + sys.exit(1) + + print("a2a-request-deserialise-ok") + + +def response_deserialise() -> None: + """Verify A2aResponse can be constructed from a JSON-RPC 2.0 dict.""" + # Success response + success_payload = { + "jsonrpc": "2.0", + "id": "resp-42", + "result": {"status": "accepted"}, + } + resp = A2aResponse.model_validate(success_payload) + if resp.result is None or resp.result.get("status") != "accepted": + print(f"FAIL: result mismatch: {resp.result}", file=sys.stderr) + sys.exit(1) + if resp.error is not None: + print(f"FAIL: error should be None: {resp.error}", file=sys.stderr) + sys.exit(1) + + # Error response + error_payload = { + "jsonrpc": "2.0", + "id": "resp-43", + "error": {"code": "NOT_FOUND", "message": "Plan not found"}, + } + err_resp = A2aResponse.model_validate(error_payload) + if err_resp.error is None: + print("FAIL: error should be set", file=sys.stderr) + sys.exit(1) + if err_resp.result is not None: + print(f"FAIL: result should be None: {err_resp.result}", file=sys.stderr) + sys.exit(1) + + print("a2a-response-deserialise-ok") + + +def request_rejects_old_fields() -> None: + """Verify A2aRequest no longer has old non-standard field names.""" + req = A2aRequest(method="_cleveragents/plan/status", params={"plan_id": "P1"}) + + # Verify old attributes don't exist on the model + old_attrs = ["a2a_version", "request_id", "operation", "auth"] + for attr in old_attrs: + if hasattr(req, attr): + print( + f"FAIL: A2aRequest still has old attribute '{attr}'", + file=sys.stderr, + ) + sys.exit(1) + + # Verify new attributes exist + new_attrs = ["jsonrpc", "id", "method", "params"] + for attr in new_attrs: + if not hasattr(req, attr): + print( + f"FAIL: A2aRequest missing new attribute '{attr}'", + file=sys.stderr, + ) + sys.exit(1) + + print("a2a-request-rejects-old-fields-ok") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +_COMMANDS = { + "request-wire-format": request_wire_format, + "response-success-wire-format": response_success_wire_format, + "response-error-wire-format": response_error_wire_format, + "facade-dispatch-jsonrpc": facade_dispatch_jsonrpc, + "request-deserialise": request_deserialise, + "response-deserialise": response_deserialise, + "request-rejects-old-fields": request_rejects_old_fields, +} + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + print(f"Commands: {', '.join(_COMMANDS)}", file=sys.stderr) + sys.exit(1) + _COMMANDS[sys.argv[1]]() diff --git a/src/cleveragents/a2a/cli_bootstrap.py b/src/cleveragents/a2a/cli_bootstrap.py index 4f4e8add..77ec1318 100644 --- a/src/cleveragents/a2a/cli_bootstrap.py +++ b/src/cleveragents/a2a/cli_bootstrap.py @@ -12,7 +12,7 @@ Usage:: facade = get_facade() response = facade.dispatch( - A2aRequest(operation="session.create", params={"actor": "stub"}) + A2aRequest(method="session.create", params={"actor": "stub"}) ) """ diff --git a/src/cleveragents/a2a/facade.py b/src/cleveragents/a2a/facade.py index 4ac6c41c..472ec19e 100644 --- a/src/cleveragents/a2a/facade.py +++ b/src/cleveragents/a2a/facade.py @@ -34,7 +34,6 @@ from cleveragents.a2a.models import ( A2aErrorDetail, A2aRequest, A2aResponse, - A2aVersion, ) if TYPE_CHECKING: @@ -162,8 +161,8 @@ class A2aLocalFacade: def dispatch(self, request: A2aRequest) -> A2aResponse: """Route an :class:`A2aRequest` to the appropriate handler. - Returns an :class:`A2aResponse` with ``status='ok'`` on success or - ``status='error'`` when the operation fails. Domain exceptions + Returns an :class:`A2aResponse` with ``result`` set on success or + ``error`` set when the operation fails. Domain exceptions are mapped to A2A error codes via :func:`map_domain_error`. """ if not isinstance(request, A2aRequest): @@ -171,20 +170,17 @@ class A2aLocalFacade: start = time.monotonic() try: - data = self._route_operation(request.operation, request.params) + data = self._route_operation(request.method, request.params) elapsed = (time.monotonic() - start) * 1000.0 logger.info( "a2a.local.dispatch", - operation=request.operation, - request_id=request.request_id, + method=request.method, + request_id=request.id, timing_ms=round(elapsed, 2), ) return A2aResponse( - a2a_version=A2aVersion.CURRENT, - request_id=request.request_id, - status="ok", - data=data, - timing_ms=round(elapsed, 2), + id=request.id, + result=data, ) except A2aOperationNotFoundError: raise @@ -193,20 +189,17 @@ class A2aLocalFacade: code, message = map_domain_error(exc) logger.error( "a2a.local.dispatch.error", - operation=request.operation, - request_id=request.request_id, + method=request.method, + request_id=request.id, error_code=code, error=message, ) return A2aResponse( - a2a_version=A2aVersion.CURRENT, - request_id=request.request_id, - status="error", + id=request.id, error=A2aErrorDetail( code=code, message=message, ), - timing_ms=round(elapsed, 2), ) def register_service(self, name: str, service: Any) -> None: @@ -236,7 +229,7 @@ class A2aLocalFacade: handler = self._handlers().get(operation) if handler is None: raise A2aOperationNotFoundError( - message=f"Unknown A2A operation: {operation}", + message=f"Unknown A2A method: {operation}", operation=operation, ) return handler(params) diff --git a/src/cleveragents/a2a/models.py b/src/cleveragents/a2a/models.py index 729f765a..f799b33d 100644 --- a/src/cleveragents/a2a/models.py +++ b/src/cleveragents/a2a/models.py @@ -3,6 +3,11 @@ Pydantic v2 models for the A2A wire format. In local mode these are used purely as validated data containers — no serialization to JSON actually occurs over a network. + +Wire format follows JSON-RPC 2.0 specification: + Request: {"jsonrpc": "2.0", "id": ..., "method": ..., "params": {...}} + Response: {"jsonrpc": "2.0", "id": ..., "result": {...}} (success) + {"jsonrpc": "2.0", "id": ..., "error": {...}} (failure) """ from __future__ import annotations @@ -10,19 +15,26 @@ from __future__ import annotations from datetime import UTC, datetime from typing import Any -from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from ulid import ULID # --------------------------------------------------------------------------- # Version constants # --------------------------------------------------------------------------- +JSONRPC_VERSION: str = "2.0" + class A2aVersion: - """A2A protocol version constants.""" + """A2A protocol version constants. - CURRENT: str = "1.0" - SUPPORTED: tuple[str, ...] = ("1.0",) + Kept for backward compatibility — the wire format now uses the + JSON-RPC 2.0 ``jsonrpc`` field with value ``"2.0"`` rather than + a proprietary ``a2a_version`` field. + """ + + CURRENT: str = JSONRPC_VERSION + SUPPORTED: tuple[str, ...] = (JSONRPC_VERSION,) # --------------------------------------------------------------------------- @@ -46,7 +58,11 @@ def _iso_now_factory() -> str: class A2aErrorDetail(BaseModel): - """Structured error payload inside an :class:`A2aResponse`.""" + """Structured error payload inside an :class:`A2aResponse`. + + Matches the JSON-RPC 2.0 error object structure: + ``{"code": ..., "message": ..., "data": {...}}`` + """ model_config = ConfigDict(strict=False) @@ -63,49 +79,84 @@ class A2aErrorDetail(BaseModel): class A2aRequest(BaseModel): - """Inbound A2A operation envelope.""" + """Inbound A2A operation envelope — JSON-RPC 2.0 compliant. + + Wire format:: + + { + "jsonrpc": "2.0", + "id": "01HXRCF1...", + "method": "_cleveragents/plan/status", + "params": {"plan_id": "..."} + } + """ model_config = ConfigDict(strict=False) - a2a_version: str = A2aVersion.CURRENT - request_id: str = "" - operation: str + jsonrpc: str = Field(default=JSONRPC_VERSION) + id: str = Field(default="") + method: str params: dict[str, Any] = {} - auth: dict[str, Any] | None = None - @field_validator("operation") + @field_validator("jsonrpc") @classmethod - def _operation_non_empty(cls, value: str) -> str: + def _jsonrpc_must_be_2_0(cls, value: str) -> str: + if value != JSONRPC_VERSION: + raise ValueError(f"jsonrpc must be '2.0', got {value!r}") + return value + + @field_validator("method") + @classmethod + def _method_non_empty(cls, value: str) -> str: if not value or not value.strip(): - raise ValueError("operation must not be empty") + raise ValueError("method must not be empty") return value @model_validator(mode="after") - def _default_request_id(self) -> A2aRequest: - if not self.request_id: - self.request_id = _ulid_factory() + def _default_id(self) -> A2aRequest: + if not self.id: + self.id = _ulid_factory() return self class A2aResponse(BaseModel): - """Outbound A2A result envelope.""" + """Outbound A2A result envelope — JSON-RPC 2.0 compliant. + + Success wire format:: + + {"jsonrpc": "2.0", "id": "...", "result": {...}} + + Error wire format:: + + {"jsonrpc": "2.0", "id": "...", "error": {"code": ..., "message": ..., + "data": {...}}} + """ model_config = ConfigDict(strict=False) - a2a_version: str = A2aVersion.CURRENT - request_id: str - status: str - data: dict[str, Any] = {} + jsonrpc: str = Field(default=JSONRPC_VERSION) + id: str + result: dict[str, Any] | None = None error: A2aErrorDetail | None = None - timing_ms: float | None = None - @field_validator("status") + @field_validator("jsonrpc") @classmethod - def _status_must_be_valid(cls, value: str) -> str: - if value not in ("ok", "error"): - raise ValueError("status must be 'ok' or 'error'") + def _jsonrpc_must_be_2_0(cls, value: str) -> str: + if value != JSONRPC_VERSION: + raise ValueError(f"jsonrpc must be '2.0', got {value!r}") return value + @model_validator(mode="after") + def _result_xor_error(self) -> A2aResponse: + """Exactly one of result or error must be set.""" + if self.result is None and self.error is None: + raise ValueError("A2aResponse must have either 'result' or 'error'") + if self.result is not None and self.error is not None: + raise ValueError( + "A2aResponse must not have both 'result' and 'error'" + ) + return self + class A2aEvent(BaseModel): """Server-sent event envelope for plan progress and streaming.""" @@ -135,6 +186,7 @@ class A2aEvent(BaseModel): __all__ = [ + "JSONRPC_VERSION", "A2aErrorDetail", "A2aEvent", "A2aRequest", diff --git a/src/cleveragents/a2a/transport.py b/src/cleveragents/a2a/transport.py index cc6ef38a..08c7a246 100644 --- a/src/cleveragents/a2a/transport.py +++ b/src/cleveragents/a2a/transport.py @@ -30,13 +30,13 @@ class A2aHttpTransport: if not isinstance(request, A2aRequest): raise TypeError("request must be an A2aRequest instance") logger.warning( - "%s (operation=%s)", + "%s (method=%s)", _SERVER_MODE_MSG, - request.operation, + request.method, ) raise A2aNotAvailableError( _SERVER_MODE_MSG, - details={"operation": request.operation}, + details={"method": request.method}, ) def connect(self, base_url: str) -> None: diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index 034c33a2..88e269eb 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -72,7 +72,7 @@ def _notify_facade(operation: str, params: dict[str, Any]) -> None: from cleveragents.a2a.cli_bootstrap import get_facade facade = get_facade() - request = A2aRequest(operation=operation, params=params) + request = A2aRequest(method=operation, params=params) facade.dispatch(request) except Exception: pass diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index d742c382..b2955c06 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -98,11 +98,11 @@ def _facade_dispatch(operation: str, params: dict[str, Any]) -> dict[str, Any]: from cleveragents.a2a.cli_bootstrap import get_facade facade = get_facade() - request = A2aRequest(operation=operation, params=params) + request = A2aRequest(method=operation, params=params) response = facade.dispatch(request) - if response.status == "error" and response.error is not None: + if response.error is not None: raise RuntimeError(response.error.message) - return dict(response.data) + return dict(response.result or {}) # ---------------------------------------------------------------------------