From 767057d5e141b0502e6db1e66ceb531de6ad2486 Mon Sep 17 00:00:00 2001 From: "Brian O'Kelley (via Claude Code)" Date: Fri, 24 Apr 2026 03:01:30 +0000 Subject: [PATCH] test(a2a): 1.0-client-pinned-to-0.3 reverse e2e + subclasser migration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships two ready items from issue #263 (A2A 1.0 follow-ups): 1. Adds `test_v10_client_pinned_to_v03_against_dual_server` and `test_v10_client_pinned_to_unadvertised_version_raises` to `tests/integration/test_a2a_wire_compat.py`. The existing suite covered 0.3 client → 1.0 server (hand-crafted JSON-RPC). These cover the reverse — our 1.0 Python client with `force_a2a_version="0.3"` routed through `_filter_card_to_version` and `ClientFactory` transport negotiation against the same dual-advertising server fixture. Purely additive; reuses the proven uvicorn contextmanager. 2. Adds `MIGRATION_v4_0_to_v4_1.md` documenting the three subclasser-facing breakages that landed in PR #261 without a dedicated migration note: `ServerError` → `A2AError`, `get_agent_info()` dropping `adcp_version` / `protocols_supported`, and the `a2a.types` Pydantic → protobuf shift. Mirrors the `MIGRATION_v3_to_v4.md` pattern (named migration files, not CHANGELOG edits — release-please owns CHANGELOG.md). Items 1, 2, 3, 5 of #263 remain open pending cross-repo docs alignment (wrapper policy, webhook envelope) or a maintainer call on whether flipping REJECTED → FAILED classification is acceptable. Author signaled revisiting once a2a-sdk 1.0.1 ships upstream. Refs #263 Session: https://claude.ai/code/${CLAUDE_CODE_REMOTE_SESSION_ID} --- MIGRATION_v4_0_to_v4_1.md | 132 ++++++++++++++++++++++ tests/integration/test_a2a_wire_compat.py | 65 +++++++++++ 2 files changed, 197 insertions(+) create mode 100644 MIGRATION_v4_0_to_v4_1.md diff --git a/MIGRATION_v4_0_to_v4_1.md b/MIGRATION_v4_0_to_v4_1.md new file mode 100644 index 00000000..c5d8d524 --- /dev/null +++ b/MIGRATION_v4_0_to_v4_1.md @@ -0,0 +1,132 @@ +# Migrating from v4.0 to v4.1 + +4.1 migrates the A2A stack from `a2a-sdk>=0.3,<1.0` to `a2a-sdk>=1.0.1,<2.0`. +The 1.0 Python SDK is a protobuf-typed rewrite at a new A2A protocol wire +version; the server keeps a 0.3 compat route (`enable_v0_3_compat=True`) +so external A2A peers on the 0.3 wire continue to interoperate without +any buyer-side change. + +**Most users need to do nothing.** The migration is wire-preserving: a +0.3-era peer hitting our 4.1 server sees `"state": "completed"` and +`"kind": "task"` as before. The items below apply only if you: + +* subclass `ADCPHandler` with `from a2a.types import …` annotations, or +* catch `a2a.utils.errors.ServerError`, or +* read `adcp_version` / `protocols_supported` off the return of + `ADCPClient.get_agent_info()`. + +Update your dependency pin: + +```toml +# pyproject.toml +[project] +dependencies = [ + "adcp>=4.1.0,<5", +] +``` + +## Breaking changes for subclassers + +### `a2a.utils.errors.ServerError` → `A2AError` + +The 1.0 SDK removes the `ServerError` wrapper class. Errors now inherit +directly from `A2AError` subclasses (`InternalError`, `InvalidParamsError`, +etc.) and are returned as proto messages rather than raised through the +`ServerError` envelope. + +**Before (v4.0):** + +```python +from a2a.utils.errors import ServerError + +try: + result = await handler.handle(request) +except ServerError as exc: + log.error("handler failed", exc_info=exc) +``` + +**After (v4.1):** + +```python +from a2a.utils.errors import A2AError + +try: + result = await handler.handle(request) +except A2AError as exc: + log.error("handler failed", exc_info=exc) +``` + +If you need a transitional shim while rolling out the upgrade across a +multi-package deploy: + +```python +try: + from a2a.utils.errors import A2AError as _A2AError_or_ServerError +except ImportError: # a2a-sdk < 1.0 + from a2a.utils.errors import ServerError as _A2AError_or_ServerError +``` + +### `get_agent_info()` no longer returns `adcp_version` / `protocols_supported` + +The 1.0 proto `AgentCard` has no duck-typeable `extensions` map. The +0.3-era code path that surfaced `extensions.adcp.adcp_version` and +`extensions.adcp.protocols_supported` on +`ADCPClient.get_agent_info()` now returns a card-shape dict without +those two keys. + +**Before (v4.0):** + +```python +info = await client.get_agent_info() +adcp_version = info["adcp_version"] # raises KeyError in 4.1 +protocols = info["protocols_supported"] # raises KeyError in 4.1 +``` + +**After (v4.1):** dispatch the `get_adcp_capabilities` skill to get the +live declaration from the peer: + +```python +capabilities = await client.fetch_capabilities() +adcp_version = capabilities.adcp.major_versions +protocols = capabilities.supported_protocols +``` + +`get_agent_info()` still returns `name`, `description`, `version`, +`protocol`, `tools`, and a new `a2a_protocol_versions` list (e.g. +`["0.3", "1.0"]`) that lets callers confirm which A2A wire versions the +peer advertises before pinning. + +### `a2a-sdk` type import paths shifted + +If you import a2a types directly for annotations or construction, the +module layout changed from Pydantic models under `a2a.types` to proto +messages under the same namespace. Most names are the same (`Task`, +`Message`, `Part`, `Artifact`, `TaskStatus`, `Role`, `TaskState`) but +their construction forms differ: + +* `Part(root=DataPart(data={...}))` → `Part(data=)` +* `TaskState.completed` (Pydantic string enum) → + `TaskState.TASK_STATE_COMPLETED` (protobuf int enum) +* `Role.user` → `Role.ROLE_USER` + +**If you build A2A messages in application code,** use +`adcp.webhooks.create_a2a_webhook_payload` (which handles the proto +construction internally) or consult `src/adcp/protocols/a2a.py`'s +`_make_data_part` / `_make_text_part` helpers as reference. + +**If you only read `.data` off a Part in handler code,** the receiver +side has not changed — `ADCPHandler` still hands you a plain dict. + +## Why 4.1 and not 5.0 + +See PR #261 for the full discussion. Short version: A2A adopter +population is near-zero at the time of 4.1 (adoption is deferred behind +pluggable TaskStore / push-notif / middleware work that isn't merged), +and 4.0 shipped less than 48 hours earlier. Semver-strict would be 5.0, +but the pragmatic blast radius is small enough that a second major +release 48h later was judged worse DX than disclosing the +subclasser-facing breakages here. + +If you subclass `ADCPHandler` or catch `ServerError` and hit a surprise +on upgrade, please file a GitHub issue so the 5.0 release notes carry +the real-world impact inventory. diff --git a/tests/integration/test_a2a_wire_compat.py b/tests/integration/test_a2a_wire_compat.py index 71c3e484..653246e6 100644 --- a/tests/integration/test_a2a_wire_compat.py +++ b/tests/integration/test_a2a_wire_compat.py @@ -29,8 +29,11 @@ import pytest import uvicorn +from adcp.protocols.a2a import A2AAdapter from adcp.server import ADCPHandler from adcp.server.a2a_server import create_a2a_server +from adcp.types.core import AgentConfig, Protocol, TaskStatus +from adcp.validation.client_hooks import ValidationHookConfig pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), @@ -192,3 +195,65 @@ async def test_unknown_method_returns_method_not_found(): # JSON-RPC 2.0 reserves -32601 for Method Not Found; the a2a-sdk # uses this code for unknown method names. assert body["error"].get("code") == -32601, body + + +@pytest.mark.asyncio +async def test_v10_client_pinned_to_v03_against_dual_server(): + """Reverse-direction guard: our 1.0-based Python client, pinned to + ``force_a2a_version="0.3"``, must successfully round-trip against + the dual-advertising server's 0.3 JSON-RPC interface. + + Existing ``test_v03_message_send_gets_v03_task_response`` covers + 0.3 client → 1.0 server by hand-crafting the JSON-RPC envelope. + This test covers the other direction: a production-shape ADCP + caller picking the 0.3 transport via the public ``force_a2a_version`` + pin, exercising :func:`_filter_card_to_version` and + :class:`~a2a.client.ClientFactory` transport negotiation end-to-end. + + Proves: + + - The card filter keeps only the 0.3 ``AgentInterface`` when a + dual-advertising peer is pinned. + - ``ClientFactory`` selects a 0.3-compatible transport and + ``_send_and_aggregate`` drains the resulting ``StreamResponse``. + - ``a2a_protocol_versions`` still reports both advertised versions + (the filter is applied after the card is cached, not before). + """ + async with _running_server() as base_url: + config = AgentConfig(id="wire-compat-test", agent_uri=base_url, protocol=Protocol.A2A) + adapter = A2AAdapter(config, force_a2a_version="0.3") + # Skip schema validation — the ``_EchoHandler`` response is a + # minimal ``{"products": []}`` stub. Validation noise here would + # distract from the wire-compat assertion this test exists for. + adapter.configure_validation(ValidationHookConfig(requests="off", responses="off")) + + try: + result = await adapter.get_products({"buying_mode": "wholesale"}) + finally: + await adapter.close() + + assert result.success, f"expected success, got status={result.status!r} error={result.error!r}" + assert result.status == TaskStatus.COMPLETED, result.status + assert result.data == {"products": []}, result.data + # Card cache retains the unfiltered advertisement so callers can + # still probe what the peer supports before pinning. + assert adapter.a2a_protocol_versions == ["0.3", "1.0"], adapter.a2a_protocol_versions + + +@pytest.mark.asyncio +async def test_v10_client_pinned_to_unadvertised_version_raises(): + """``force_a2a_version`` must fail loudly when the peer advertises + no matching interface, rather than falling through to a silent + transport-negotiation error deep in the a2a-sdk. Guards the + user-visible error path in :meth:`A2AAdapter._get_a2a_client`.""" + from adcp.exceptions import ADCPConnectionError + + async with _running_server() as base_url: + config = AgentConfig(id="wire-compat-test", agent_uri=base_url, protocol=Protocol.A2A) + adapter = A2AAdapter(config, force_a2a_version="9.9") + + try: + with pytest.raises(ADCPConnectionError, match="does not advertise"): + await adapter.get_products({"buying_mode": "wholesale"}) + finally: + await adapter.close()