Skip to content

test: fill A2A adapter unit test gaps - #605

Open
AlexanderZ-Band wants to merge 5 commits into
mainfrom
feat/a2a-adapter-add-live-e2e-coverage-fill-unit-test-g-INT-1357
Open

test: fill A2A adapter unit test gaps#605
AlexanderZ-Band wants to merge 5 commits into
mainfrom
feat/a2a-adapter-add-live-e2e-coverage-fill-unit-test-g-INT-1357

Conversation

@AlexanderZ-Band

@AlexanderZ-Band AlexanderZ-Band commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Part 1 — unit test gaps:

  • Adds coverage for 10 undertested paths across the outbound A2AAdapter and A2AGatewayAdapter: peer-not-found resolution, peer-list pagination, artifact-overwrite reduction, TASK_STATE_AUTH_REQUIRED handling, and several gateway JSON-RPC route edge cases (malformed body, non-scalar request id, SendStreamingMessage, missing-id task methods, partial room-creation state).
  • Two of these gaps turned out to be real bugs, surfaced while writing their regression tests, and fixed alongside the new coverage:
    • A2AGatewayAdapter._execute_a2a: a REST failure while posting to Band left the remote A2A caller stuck on a WORKING task forever, since no terminal FAILED event was ever published on that path. Now calls pending.fail(...) before re-raising.
    • A2AAdapter.cleanup_all: an exception from the A2A client's close() skipped closing the owned httpx transport, leaking it on shutdown. Now closes the transport in a finally.

Part 2 — live E2E smokes:

  • Three bespoke baseline smokes under tests/e2e/baseline/smoke/adapters/ (A2A is a protocol bridge, not a matrix adapter, so these follow the test_parlant.py pattern):
    • test_a2a.py — a live A2AAdapter against a new scripted, non-Band A2A counterparty fixture (a2aServer.py, built on a2a-sdk's own server primitives — no LLM key needed). Covers both a canned-reply happy path and a scripted remote task failure.
    • test_a2a_gateway.py — the official a2a-sdk reference client (a2a.client.Client) driving a live A2AGatewayAdapter that exposes a real Anthropic-backed Band peer — validates the gateway's JSON-RPC server independent of our own A2AAdapter.
    • test_a2a_roundtrip.py — the full Band-to-Band shape: Band Agent A (A2AAdapter) → live gateway → Band Agent B (Anthropic) → reply relayed all the way back.
  • Also fixes a race surfaced while building these: GatewayServer.start() returned as soon as serve() was scheduled, before uvicorn was actually listening — a caller dialing in immediately after (a real A2A client, or these new smokes) could hit connection-refused. start() now waits for uvicorn's ready signal, bounded by a timeout, with a small regression test.
  • All three smokes were subsequently run live against a real platform + ANTHROPIC_API_KEY (see Part 3 — two of the three initially failed live and led to two more real bug fixes); all four tests (test_a2a.py has two) now pass live with zero reruns.

Part 3 — two more real bugs found running the smokes live:

  • A2AAdapter's owned httpx client had no read-timeout override, so httpx's default 5s read timeout fired on the ordinary multi-second gap between SSE events during a real remote turn (a live LLM call), not just a genuine hang — surfaced as a spurious A2AClientTimeoutError on any real turn slower than 5s. Fixed by leaving read unbounded while keeping connect/write/pool bounded, so a genuinely dead peer still fails promptly. Same fix applied to the raw a2a-sdk client built in test_a2a_gateway.py.
  • The A2A gateway leaked sse_starlette's process-global shutdown flag across instances. sse_starlette's background shutdown watcher polls whichever uvicorn.Server owns the process's SIGTERM slot and promotes its should_exit to the process-global AppStatus.should_exit. GatewayServer.stop() sets should_exit directly (never via a real signal) — so once any gateway in a process stops, every later GatewayServer's SSE streams in that same process get cancelled immediately ("ASGI callable returned without completing response.", an incomplete chunked read on the client side). This is exactly why test_a2a_roundtrip.py failed right after test_a2a_gateway.py passed in the same pytest session — each test builds its own GatewayServer. band.integrations.mcp.local_server had already hit and fixed the identical bug for itself (a real Windows CI hang); the A2A gateway now disables the same process-global switch directly, rather than depending on that other module happening to be imported first. Added a regression test with two real GatewayServer instances in one process (confirmed it reproduces the exact same failure signature with the fix reverted, and passes with it restored).

Closes INT-1357.

Test plan

  • uv run pytest tests/integrations/a2a/ tests/integrations/mcp/ -v --no-cov (89 passed)
  • uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/ -q (5288 passed, 123 skipped, 0 failed)
  • uv run ruff check . / uv run ruff format .
  • uv run pyrefly check (0 errors)
  • E2E_TESTS_ENABLED=true uv run pytest tests/e2e/baseline/smoke/adapters/test_a2a.py tests/e2e/baseline/smoke/adapters/test_a2a_gateway.py tests/e2e/baseline/smoke/adapters/test_a2a_roundtrip.py -v -s --no-cov against a live platform + ANTHROPIC_API_KEY — all 4 tests pass, zero reruns (117s total)
  • Verified the gateway shutdown-leak regression test actually catches the bug: reverted the fix, reran, got the identical live-failure signature (RemoteProtocolError: incomplete chunked read / ASGI callable returned without completing response.); restored the fix and confirmed it passes again

🤖 Generated with Claude Code

https://claude.ai/code/session_017iDgB8tuW6tPom4PFomMzr

Adds coverage for 10 undertested paths across the outbound A2AAdapter
and A2AGatewayAdapter: peer-not-found resolution, peer-list pagination,
artifact overwrite reduction, TASK_STATE_AUTH_REQUIRED handling, and
several gateway JSON-RPC route edge cases (malformed body, non-scalar
request id, SendStreamingMessage, missing-id task methods, partial
room-creation state).

Two of these gaps were real bugs surfaced while writing their
regression tests, fixed alongside the new coverage:
- A2AGatewayAdapter._execute_a2a: a REST failure while posting to Band
  left the remote A2A caller stuck on a WORKING task forever, since no
  terminal FAILED event was ever published on that path.
- A2AAdapter.cleanup_all: an exception from the A2A client's close()
  skipped closing the owned httpx transport, leaking it on shutdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iDgB8tuW6tPom4PFomMzr
@linear-code

linear-code Bot commented Sep 1, 2026

Copy link
Copy Markdown

INT-1357

Part 2 of INT-1357: three bespoke baseline smokes (A2A is a protocol
bridge, not a matrix adapter) covering the outbound A2AAdapter, the
A2AGatewayAdapter's JSON-RPC server, and a full Band-to-Band round trip
over real A2A wire traffic.

- a2aServer.py: a minimal, scripted A2A counterparty server built on
  a2a-sdk's own primitives (not Band) -- no LLM key needed.
- test_a2a.py: a live A2AAdapter against that fixture server, covering
  both a canned-reply happy path and a scripted remote task failure.
- test_a2a_gateway.py: the official a2a-sdk reference client driving a
  live A2AGatewayAdapter exposing a real Anthropic-backed Band peer --
  validates the gateway independent of our own A2AAdapter.
- test_a2a_roundtrip.py: Band Agent A (A2AAdapter) -> gateway -> Band
  Agent B (Anthropic) and back, the realistic Band-to-Band usage shape.

Manually verified the fixture server and the gateway's JSON-RPC surface
against real a2a-sdk clients (no Band platform credentials available in
this session); the three smokes themselves need a live platform + an
Anthropic key to actually run.

Also fixes a race surfaced while building these: GatewayServer.start()
returned as soon as serve() was scheduled, before uvicorn was actually
listening, so a caller dialing in immediately after (a real A2A client,
or these new smokes) could hit connection-refused. start() now waits
for uvicorn's ready signal, bounded by a timeout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iDgB8tuW6tPom4PFomMzr
@AlexanderZ-Band
AlexanderZ-Band requested a review from a team September 1, 2026 13:41
httpx.AsyncClient's default 5s read timeout fires on the normal
multi-second gap between SSE events during a live remote turn (an LLM
call, a tool loop), not just a genuine hang. Leave read unbounded while
keeping connect/write/pool bounded, so a dead peer still fails promptly.

Applies the same fix to the raw a2a-sdk client built in the
test_a2a_gateway.py live smoke, and adds a regression test asserting
the owned httpx client has no read timeout.
sse_starlette's shutdown watcher polls whichever uvicorn.Server owns the
process's SIGTERM slot and promotes its should_exit to the process-global
AppStatus.should_exit. GatewayServer.stop() sets should_exit directly (never
via a signal), so once any gateway in a process stops, every later
GatewayServer's SSE streams are cancelled immediately -- reproduced live as
the A2A round-trip E2E smoke failing right after the gateway smoke (each
running its own GatewayServer in the same pytest session) with "ASGI
callable returned without completing response." / an incomplete chunked
read on the client side.

band.integrations.mcp.local_server already disables sse_starlette's
automatic graceful drain for the identical bug; the A2A gateway now does
the same directly rather than depending on that module happening to be
imported first.

Adds a regression test that reproduces the real failure with two live
GatewayServer instances in one process (confirmed it fails with the fix
reverted, with the exact same error signature seen in the live E2E run).

@amit-gazal-band amit-gazal-band left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging three issues from review, focused on the SSE-timeout fix and the new gateway startup-wait logic.

Comment thread src/band/integrations/a2a/adapter.py Outdated
Comment thread src/band/integrations/a2a/gateway/server.py Outdated
Comment thread src/band/integrations/a2a/gateway/server.py Outdated
…artup wait

A2AAdapter's httpx client left its read timeout fully unbounded to survive
normal multi-second gaps between SSE events -- but that also meant a remote
peer that accepts the connection and then genuinely hangs would never time
out, since nothing else bounds an in-flight adapter cycle. Swap read=None
for a generous-but-finite bound (httpx resets it on every chunk, so it still
tolerates a slow live turn).

GatewayServer._wait_until_started only polled uvicorn's `started` flag and
never checked whether its serve task had already failed, so a fast startup
failure (e.g. a port already in use) busy-waited the full timeout and raised
a generic error instead of the real one -- a bug the correct version in
mcp/local_server.py already avoided, and a third copy in the A2A baseline
test fixture repeated. Extract the correct version into one shared
band.integrations.uvicorn_server.wait_until_started, used by all three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDYPGiaDSYNZnyC5mg18ch
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants