Files
temp/robot/helper_a2a_jsonrpc_wire_format.py
freemo f5d244cd37 fix(a2a): change A2aErrorDetail.code to int and map error constants to JSON-RPC 2.0 integer codes
Per JSON-RPC 2.0 specification (Section 5.1), error codes must be integers.
This commit fixes the protocol compliance defect where A2aErrorDetail.code
was typed as str and error constants were string literals.

Changes:
- src/cleveragents/a2a/models.py: Change A2aErrorDetail.code from str to int;
  update field_validator to only validate 'message' (code no longer needs
  non-empty string check; Pydantic enforces int type)
- src/cleveragents/a2a/errors.py: Change all error code constants from string
  literals to JSON-RPC 2.0 integer codes per docs/reference/a2a.md taxonomy:
    NOT_FOUND = -32001, AUTH_ERROR = -32002, FORBIDDEN = -32003,
    INVALID_STATE = -32004, PLAN_ERROR = -32008, CONFIGURATION_ERROR = -32009,
    VALIDATION_ERROR = -32602, INTERNAL_ERROR = -32603
  Update map_domain_error() return type from tuple[str, str] to tuple[int, str]
- features/steps/a2a_facade_steps.py: Update A2aErrorDetail construction to
  map symbolic string names to integer codes via _CODE_MAP
- features/steps/a2a_facade_wiring_steps.py: Update error code assertion to
  map symbolic names to integers for comparison
- features/steps/a2a_facade_coverage_boost_steps.py: Same as above
- features/steps/a2a_jsonrpc_wire_format_steps.py: Update all A2aErrorDetail
  constructions and JSON-RPC dict payloads to use integer codes
- robot/helper_a2a_facade_wiring.py: Update wired_error_mapping() to compare
  against integer codes
- robot/helper_a2a_jsonrpc_wire_format.py: Update response_error_wire_format()
  to use integer code -32001 instead of string 'NOT_FOUND'

Wire format now produces {"code": -32001, ...} instead of {"code": "NOT_FOUND", ...},
making it compliant with JSON-RPC 2.0 and interoperable with standards-conformant clients.

ISSUES CLOSED: #2746
2026-04-05 17:53:56 +00:00

339 lines
11 KiB
Python

"""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=-32001, 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") != -32001:
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": -32001, "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]]()