forked from molecule-ai/molecule-core
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>
85 lines
3.2 KiB
Python
85 lines
3.2 KiB
Python
"""Build the Starlette routes for a workspace from its (card, adapter
|
|
state) pair.
|
|
|
|
Pairs with PR #2756, which decoupled ``/.well-known/agent-card.json`` from
|
|
``adapter.setup()`` failure. main.py was the only consumer and was
|
|
``# pragma: no cover`` — so the wiring (card-route mounted unconditionally,
|
|
JSON-RPC route swapped between DefaultRequestHandler and the
|
|
not-configured handler based on ``adapter_ready``) had no pytest coverage.
|
|
|
|
A future refactor that re-couples the two would silently bypass PR #2756
|
|
and shipped the original "stuck booting forever" UX again. That gap is
|
|
what closes here: extract the route-assembly into a pure function whose
|
|
behaviour is unit-testable with Starlette's TestClient, and have main.py
|
|
call it. Issue molecule-core#2761.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from starlette.routing import Route
|
|
|
|
from not_configured_handler import make_not_configured_handler
|
|
|
|
# Heavy a2a-sdk imports are lazy: deferred to inside build_routes so
|
|
# tests that exercise only the not-configured branch (no executor) don't
|
|
# need a2a.server.request_handlers / routes stubbed in their conftest.
|
|
# Production boot pays the import cost once, on workspace startup.
|
|
|
|
|
|
def build_routes(
|
|
agent_card: Any,
|
|
executor: Any | None,
|
|
adapter_error: str | None,
|
|
) -> list:
|
|
"""Return the list of Starlette routes for this workspace.
|
|
|
|
Always mounts ``/.well-known/agent-card.json`` from ``agent_card``.
|
|
|
|
JSON-RPC route at ``/`` swaps based on adapter state:
|
|
|
|
* ``executor`` is non-None → ``DefaultRequestHandler`` with the
|
|
executor (production happy-path).
|
|
* ``executor`` is None → ``not_configured_handler`` returning JSON-RPC
|
|
``-32603`` with ``adapter_error`` in ``error.data``. The
|
|
workspace stays REACHABLE (operator can introspect, deprovision,
|
|
redeploy with corrected env) instead of crash-looping invisibly.
|
|
|
|
The two branches are mutually exclusive — caller passes one or the
|
|
other, never both. Test coverage at ``tests/test_boot_routes.py``
|
|
pins the contract.
|
|
"""
|
|
from a2a.server.routes import create_agent_card_routes
|
|
|
|
routes: list = []
|
|
routes.extend(create_agent_card_routes(agent_card))
|
|
|
|
if executor is not None:
|
|
from a2a.server.request_handlers import DefaultRequestHandler
|
|
from a2a.server.routes import create_jsonrpc_routes
|
|
from a2a.server.tasks import InMemoryTaskStore
|
|
|
|
handler = DefaultRequestHandler(
|
|
agent_executor=executor,
|
|
task_store=InMemoryTaskStore(),
|
|
agent_card=agent_card,
|
|
)
|
|
# enable_v0_3_compat=True is the JSON-RPC wire-compat path: clients
|
|
# using v0.3-shaped payloads (`"role": "user"` lowercase + camelCase
|
|
# Pydantic field names) can talk to us without re-deploying.
|
|
# Outbound payloads must also use v0.3 shape — see main.py's
|
|
# original comment block for the full a2a-sdk 1.x migration note.
|
|
routes.extend(
|
|
create_jsonrpc_routes(
|
|
request_handler=handler,
|
|
rpc_url="/",
|
|
enable_v0_3_compat=True,
|
|
)
|
|
)
|
|
else:
|
|
routes.append(
|
|
Route("/", make_not_configured_handler(adapter_error), methods=["POST"])
|
|
)
|
|
|
|
return routes
|