forked from cleveragents/cleveragents-core
fix(a2a): rename A2aRequest/A2aResponse fields to comply with JSON-RPC 2.0 wire format
Rewrites the A2aRequest and A2aResponse Pydantic models to use the field names mandated by the JSON-RPC 2.0 specification, fixing a fundamental protocol compliance issue that prevented external A2A-compliant clients from communicating with the server. Changes: - A2aRequest: a2a_version→jsonrpc (fixed '2.0'), request_id→id, operation→method; auth field removed (not in JSON-RPC 2.0) - A2aResponse: a2a_version→jsonrpc, request_id→id, status+data→result (success path), timing_ms removed; added _result_xor_error validator enforcing mutual exclusion of result and error fields - A2aLocalFacade.dispatch(): updated to use request.method, request.id, result=data, error=A2aErrorDetail(...) - A2aHttpTransport.send(): updated to use request.method - CLI call sites (session.py, plan.py): updated A2aRequest(method=...) and response.result / response.error field access - All existing A2A Behave step files updated to new field names - New 35-scenario Behave feature (a2a_jsonrpc_wire_format.feature) covering serialisation, deserialisation, validation, and facade dispatch - New 7-test Robot Framework suite (a2a_jsonrpc_wire_format.robot) for end-to-end wire format verification ISSUES CLOSED: #1501
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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<status>[^"]+)"')
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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<status>[^"]+)"')
|
||||
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<key>[^"]+)" should equal "(?P<value>[^"]+)"'
|
||||
)
|
||||
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<key>[^"]+)" 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<key>[^"]+)"')
|
||||
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<key>[^"]+)"')
|
||||
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<msg>[^"]+)"')
|
||||
@@ -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<count>\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<count>\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)}"
|
||||
)
|
||||
|
||||
@@ -86,28 +86,30 @@ def step_value_error_raised(context: Context) -> None:
|
||||
@when(r'I dispatch operation "(?P<operation>[^"]+)" with params (?P<params_json>.+)')
|
||||
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<status>[^"]+)"')
|
||||
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<key>[^"]+)"')
|
||||
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<key>[^"]+)" equals "(?P<value>[^"]+)"')
|
||||
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<operation>[^"]+)"')
|
||||
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
|
||||
|
||||
@@ -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<method>[^"]+)" and params (?P<params_json>.+)'
|
||||
)
|
||||
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<method>[^"]+)" and id "(?P<req_id>[^"]+)"')
|
||||
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<key>[^"]+)" with value "(?P<value>[^"]+)"'
|
||||
)
|
||||
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<key>[^"]+)"')
|
||||
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<key>[^"]+)"')
|
||||
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<value>[^"]+)"')
|
||||
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<version>[^"]+)"')
|
||||
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<resp_id>[^"]+)" and result (?P<result_json>.+)'
|
||||
)
|
||||
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<resp_id>[^"]+)" and error code "(?P<code>[^"]+)"'
|
||||
)
|
||||
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<method>[^"]+)" and id "(?P<req_id>[^"]+)"'
|
||||
)
|
||||
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<value>[^"]+)"')
|
||||
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<value>[^"]+)"')
|
||||
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<resp_id>[^"]+)" and result (?P<result_json>.+)'
|
||||
)
|
||||
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<key>[^"]+)"')
|
||||
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<resp_id>[^"]+)" and error code "(?P<code>[^"]+)"'
|
||||
)
|
||||
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<method>[^"]+)" with params (?P<params_json>.+)'
|
||||
)
|
||||
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<method>[^"]+)" with id "(?P<req_id>[^"]+)" and params (?P<params_json>.+)'
|
||||
)
|
||||
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<value>[^"]+)"')
|
||||
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<value>[^"]+)"')
|
||||
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}'"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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]} <command>", file=sys.stderr)
|
||||
print(f"Commands: {', '.join(_COMMANDS)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
@@ -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"})
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user