PR #2756's contract — card route always mounted regardless of adapter.setup() outcome — lived inline in main.py's `# pragma: no cover` boot sequence. A future refactor that re-coupled the two would have silently bypassed PR #2756 and shipped the original "stuck booting forever" UX again, with no pytest catching it. This change extracts route assembly into workspace/boot_routes.py's build_routes(card, executor, adapter_error) and pins the contract with 6 integration tests using Starlette's TestClient: - test_card_route_serves_200_when_adapter_ready: happy path - test_card_route_serves_200_when_adapter_failed: misconfigured boot, card still 200, skill stubs survive - test_jsonrpc_returns_503_when_no_executor: full -32603 envelope with the adapter_error in error.data - test_jsonrpc_returns_503_with_generic_when_no_error_string: fallback reason for the rare case main.py reaches this branch without one - test_card_route_does_not_depend_on_executor: direct PR #2756 regression guard — both branches MUST mount the card route - test_executor_present_does_not_mount_not_configured_handler: sanity that a healthy workspace doesn't return -32603 to every request Conftest stubs extended with a2a.server.routes / request_handlers classes so the tests work under the existing a2a-mock infra (pattern matches the AgentCard/AgentSkill stubs added for PR #2765). main.py now calls build_routes; the inline if/else is gone. Same production behaviour, cleaner shape, regression-proof. Heavy a2a-sdk imports inside build_routes() are lazy (deferred to the executor-only branch) so tests that only exercise the not-configured path don't pull DefaultRequestHandler / InMemoryTaskStore. card_helpers + boot_routes registered in TOP_LEVEL_MODULES (build drift gate would have caught the missing entry on the wheel-publish smoke). All 18 related tests pass (test_boot_routes.py: 6, test_card_helpers.py: 6, test_not_configured_handler.py: 6). Closes #2761 Pairs with: PR #2756 (decouple agent-card from setup), PR #2765 (defensive isolation of enrichment + transcript) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.9 KiB
Python
214 lines
7.9 KiB
Python
"""Integration tests for boot_routes.build_routes — pin the contract that
|
|
PR #2756's card-vs-setup decoupling depends on.
|
|
|
|
Why these matter (issue #2761): main.py is ``# pragma: no cover``. The
|
|
inline if/else that mounted ``DefaultRequestHandler`` vs the
|
|
not-configured handler had no pytest coverage; a future refactor that
|
|
re-coupled card and setup() would have shipped the original "stuck
|
|
booting forever" UX again. Extracting to ``boot_routes.build_routes``
|
|
+ these tests make the contract regression-proof.
|
|
|
|
Each test exercises a real Starlette TestClient against the routes —
|
|
no uvicorn, no socket, but every assertion is the same one canvas's
|
|
TranscriptHandler / a2a_proxy would make in production.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
# Make workspace/ importable in test isolation — same pattern as the
|
|
# adjacent tests (test_not_configured_handler.py, test_card_helpers.py).
|
|
WORKSPACE_DIR = Path(__file__).resolve().parents[1]
|
|
if str(WORKSPACE_DIR) not in sys.path:
|
|
sys.path.insert(0, str(WORKSPACE_DIR))
|
|
|
|
|
|
@pytest.fixture
|
|
def agent_card():
|
|
"""Build a minimal AgentCard the way main.py does at boot."""
|
|
from a2a.types import (
|
|
AgentCard,
|
|
AgentCapabilities,
|
|
AgentInterface,
|
|
AgentSkill,
|
|
)
|
|
|
|
return AgentCard(
|
|
name="test-agent",
|
|
description="test-agent",
|
|
version="0.0.0",
|
|
supported_interfaces=[
|
|
AgentInterface(protocol_binding="https://a2a.g/v1", url="http://test:8000")
|
|
],
|
|
capabilities=AgentCapabilities(streaming=True, push_notifications=False),
|
|
skills=[
|
|
AgentSkill(id="echo", name="echo", description="echo", tags=[], examples=[])
|
|
],
|
|
default_input_modes=["text/plain"],
|
|
default_output_modes=["text/plain"],
|
|
)
|
|
|
|
|
|
# ---- card route always mounted, regardless of adapter state -------------
|
|
|
|
|
|
def test_card_route_serves_200_when_adapter_ready(agent_card):
|
|
"""Adapter setup OK → card serves 200, the canonical happy path."""
|
|
from starlette.applications import Starlette
|
|
from starlette.testclient import TestClient
|
|
|
|
from boot_routes import build_routes
|
|
|
|
fake_executor = MagicMock()
|
|
app = Starlette(routes=build_routes(agent_card, fake_executor, None))
|
|
client = TestClient(app)
|
|
resp = client.get("/.well-known/agent-card.json")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["name"] == "test-agent"
|
|
|
|
|
|
def test_card_route_serves_200_when_adapter_failed(agent_card):
|
|
"""Adapter setup raised → card route is STILL mounted with the same
|
|
static skills. This is the entire point of PR #2756: a misconfigured
|
|
workspace stays REACHABLE so canvas can show the user a clear error
|
|
instead of silently looking dead."""
|
|
from starlette.applications import Starlette
|
|
from starlette.testclient import TestClient
|
|
|
|
from boot_routes import build_routes
|
|
|
|
app = Starlette(
|
|
routes=build_routes(
|
|
agent_card, executor=None, adapter_error="MISSING_API_KEY"
|
|
)
|
|
)
|
|
client = TestClient(app)
|
|
resp = client.get("/.well-known/agent-card.json")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["name"] == "test-agent"
|
|
# Skill stubs survive even though setup() didn't run.
|
|
assert any(s.get("id") == "echo" for s in body.get("skills", []))
|
|
|
|
|
|
# ---- JSON-RPC route swaps based on executor presence -------------------
|
|
|
|
|
|
def test_jsonrpc_returns_503_when_no_executor(agent_card):
|
|
"""The not-configured branch: POST / returns 503 with JSON-RPC -32603
|
|
and the adapter_error in error.data. This is what canvas sees when a
|
|
user tries to message a workspace whose setup() failed — turns a
|
|
"stuck silent" workspace into "agent not configured: <reason>"."""
|
|
from starlette.applications import Starlette
|
|
from starlette.testclient import TestClient
|
|
|
|
from boot_routes import build_routes
|
|
|
|
app = Starlette(
|
|
routes=build_routes(
|
|
agent_card,
|
|
executor=None,
|
|
adapter_error="RuntimeError: Neither OPENAI_API_KEY nor MINIMAX_API_KEY is set",
|
|
)
|
|
)
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/",
|
|
json={"jsonrpc": "2.0", "id": 42, "method": "message/send"},
|
|
)
|
|
assert resp.status_code == 503
|
|
body = resp.json()
|
|
assert body["jsonrpc"] == "2.0"
|
|
assert body["id"] == 42 # echoed
|
|
assert body["error"]["code"] == -32603
|
|
assert "MINIMAX_API_KEY" in body["error"]["data"]
|
|
|
|
|
|
def test_jsonrpc_returns_503_with_generic_when_no_error_string(agent_card):
|
|
"""Defensive: if main.py reached this branch without a captured
|
|
error string (shouldn't happen in practice but the helper is
|
|
defensive), the handler still returns -32603 with a generic
|
|
fallback so the operator gets a useful response shape."""
|
|
from starlette.applications import Starlette
|
|
from starlette.testclient import TestClient
|
|
|
|
from boot_routes import build_routes
|
|
|
|
app = Starlette(
|
|
routes=build_routes(agent_card, executor=None, adapter_error=None)
|
|
)
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/", json={"jsonrpc": "2.0", "id": 1, "method": "message/send"}
|
|
)
|
|
assert resp.status_code == 503
|
|
assert resp.json()["error"]["code"] == -32603
|
|
# Falls back to generic "adapter.setup() failed".
|
|
assert "setup() failed" in resp.json()["error"]["data"]
|
|
|
|
|
|
# ---- Specific regression: re-coupling card to setup would break this ---
|
|
|
|
|
|
def test_card_route_does_not_depend_on_executor(agent_card):
|
|
"""Direct regression test for PR #2756. If a future refactor moved
|
|
create_agent_card_routes into the executor-only branch, this test
|
|
would catch it: the card MUST be served from a code path that runs
|
|
even when executor is None."""
|
|
from boot_routes import build_routes
|
|
|
|
routes_with_executor = build_routes(agent_card, MagicMock(), None)
|
|
routes_without_executor = build_routes(agent_card, None, "err")
|
|
|
|
# Both branches mount /.well-known/agent-card.json. Find by path.
|
|
def has_card_route(routes):
|
|
for r in routes:
|
|
for attr in ("path", "path_format"):
|
|
p = getattr(r, attr, None)
|
|
if p and "agent-card.json" in p:
|
|
return True
|
|
return False
|
|
|
|
assert has_card_route(routes_with_executor), (
|
|
"card route MUST be mounted on the executor-present path"
|
|
)
|
|
assert has_card_route(routes_without_executor), (
|
|
"card route MUST be mounted on the executor-missing path "
|
|
"(this is the PR #2756 contract — re-coupling here breaks tenant readiness)"
|
|
)
|
|
|
|
|
|
def test_executor_present_does_not_mount_not_configured_handler(agent_card):
|
|
"""Sanity: when executor is present, the not-configured handler
|
|
must NOT be mounted at /. Otherwise a healthy workspace would
|
|
return -32603 to every JSON-RPC call.
|
|
|
|
We call POST / with a malformed JSON-RPC body and assert the
|
|
response is NOT the -32603 not-configured envelope. (The real
|
|
DefaultRequestHandler may return its own error for the malformed
|
|
payload, but it won't have ``data: "adapter.setup() failed"``.)"""
|
|
from starlette.applications import Starlette
|
|
from starlette.testclient import TestClient
|
|
|
|
from boot_routes import build_routes
|
|
|
|
fake_executor = MagicMock()
|
|
app = Starlette(routes=build_routes(agent_card, fake_executor, None))
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/", json={"jsonrpc": "2.0", "id": 1, "method": "message/send"}
|
|
)
|
|
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
# Whatever DefaultRequestHandler does, it isn't the not-configured
|
|
# envelope. The cheap discriminator: error.data won't say "setup() failed".
|
|
err = body.get("error") or {}
|
|
data = err.get("data") if isinstance(err, dict) else ""
|
|
assert "setup() failed" not in (data or ""), (
|
|
"executor-present branch must not mount the not-configured handler"
|
|
)
|