feat(testing): add SellerA2AClient for in-process A2A handler testing#694
Conversation
Refs #678. A2A sibling to SellerTestClient. Same call shape (`await client.invoke(skill, payload)`), same return type (ToolInvokeResult), but routes through the A2A executor + event-queue dispatch path instead of MCP's tool call. This eliminates the same boilerplate adopters currently rewrite in every A2A test file: ADCPAgentExecutor construction, RequestContext + SendMessageRequest + DataPart proto plumbing, EventQueueLegacy drain loop, terminal-Task projection back to a dict, plus separate handling of the structured (adcp_error in DataPart) vs. unstructured (FAILED Task without adcp_error) error paths. The harness drains the event queue with a bounded loop + per-event timeout so a buggy handler that never publishes a terminal event can't hang the test runner. Unstructured A2A failures (unknown skill, unparseable message) are synthesized into an INTERNAL_ERROR-coded AdcpErrorPayload so callers can assert on `result.adcp_error` uniformly across both failure modes. Explicitly out of scope for this PR (tracked as follow-ups on #678): - Push-notification capture sink for asserting on outbound signed webhooks - Intermediate-state observation (working/input_required transitions) - Task cancellation harness These depend on the TaskStore + push-notification + middleware hooks that are themselves deferred per the framework's A2A adoption roadmap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ReviewAll 13 CI jobs green. The boilerplate elimination matches
Solid
Non-blocking observations
Deferred follow-ups already in PR bodyThe three #678 follow-ups (push-notification capture, intermediate-state observer, cancel/resume harness) are correctly scoped out. None of them block this PR — the basic invoke path is the most-used surface and unlocks the largest swath of adopter test rewrites. LGTM modulo the follow-ups (which I'll file). The validation round-trip test is the only one I'd ask for in this PR specifically; the rest are issues for separate work. |
Per PR #694 review: prove the `validation=` parameter actually engages the validation hook chain end-to-end, not just that __init__ accepts it. The stub returns `creative_formats: []` while the spec requires `formats`. With validation off (the test default) this passes through; with SERVER_DEFAULT_VALIDATION the response-side validator rejects and surfaces a structured VALIDATION_ERROR with `side: response` in details. Asserting on that proves the harness threads validation through the A2A executor's event-queue → terminal-Task path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refs #678.
Why
`SellerTestClient` shipped in #666 killed the JSON-RPC envelope + `structuredContent` extraction boilerplate every MCP-adopting test file was rewriting by hand. Adopters who ship A2A in production today (salesagent, the v3 reference seller in this SDK) have been rewriting the exact same shape of boilerplate against the A2A executor — building proto Messages with DataParts, plumbing a SendMessageRequest into RequestContext, draining an EventQueueLegacy, projecting the terminal Task's first artifact back to a dict, and handling structured vs. unstructured failure modes separately.
`SellerA2AClient` collapses that into the same one-call shape as the MCP version.
What this PR ships
```python
from adcp.testing import SellerA2AClient
@pytest.fixture
def seller_a2a():
return SellerA2AClient(MySeller())
async def test_buy_not_found(seller_a2a):
result = await seller_a2a.invoke(
"update_media_buy", {"media_buy_id": "missing", ...}
)
assert not result.ok
assert result.adcp_error.code == "MEDIA_BUY_NOT_FOUND"
```
What this PR does NOT ship (deferred to follow-ups on #678)
These are the genuinely A2A-shaped concerns that motivated the original issue framing:
All three depend on the TaskStore + push-notification + middleware hooks that are themselves deferred per the framework's A2A roadmap. Shipping basic `invoke()` now buys adopters the boilerplate elimination they need today; the lifecycle harness arrives when the framework hooks do.
What was tested
🤖 Generated with Claude Code