From 7e55b681d61e5d76c5ba3adf387102509496075f Mon Sep 17 00:00:00 2001 From: kuangmi-bit Date: Thu, 6 Aug 2026 12:17:45 +0800 Subject: [PATCH 1/3] fix(server): accept trailing-slash JSON-RPC endpoint; enqueue Task in TCK SUT Two changes that together let the 1.0 TCK exercise the JSON-RPC SUT: 1. create_jsonrpc_routes now registers both the exact rpc_url and its trailing-slash variant. HTTP clients (httpx in particular) normalize an empty request path to a trailing slash, so POST /a2a/jsonrpc/ was previously 404 even though /a2a/jsonrpc worked. This is a protocol compatibility fix: the spec does not mandate one spelling over the other, and a 404 on the trailing-slash form breaks any client that does not strip it. 2. tck/sut_agent.py now enqueues the Task itself (via new_task_from_user_message) before emitting TaskStatusUpdateEvents. The SDK's active-task machinery requires this ordering (InvalidAgentResponseError otherwise), and the 1.0 TCK CORE-SEND tests assert it. Verified against a2a-tck 1.0.0.alpha2 (jsonrpc, must level): 53 failed -> 6 failed before this change, with the remaining failures being SUT feature gaps (artifacts) and one SDK error-code mapping gap, not transport issues. --- src/a2a/server/routes/jsonrpc_routes.py | 7 ++++++- tck/sut_agent.py | 8 ++++++++ tests/server/routes/test_jsonrpc_routes.py | 12 +++++++++--- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/a2a/server/routes/jsonrpc_routes.py b/src/a2a/server/routes/jsonrpc_routes.py index a94d513ae..f411d5eba 100644 --- a/src/a2a/server/routes/jsonrpc_routes.py +++ b/src/a2a/server/routes/jsonrpc_routes.py @@ -64,5 +64,10 @@ def create_jsonrpc_routes( path=rpc_url, endpoint=dispatcher.handle_requests, methods=['POST'], - ) + ), + Route( + path=f'{rpc_url}/', + endpoint=dispatcher.handle_requests, + methods=['POST'], + ), ] diff --git a/tck/sut_agent.py b/tck/sut_agent.py index 0ca3a1450..176c8cfc1 100644 --- a/tck/sut_agent.py +++ b/tck/sut_agent.py @@ -14,6 +14,7 @@ import a2a.types.a2a_pb2_grpc as a2a_grpc from a2a.compat.v0_3.grpc_handler import CompatGrpcHandler +from a2a.helpers.proto_helpers import new_task_from_user_message from a2a.server.agent_execution.agent_executor import AgentExecutor from a2a.server.agent_execution.context import RequestContext from a2a.server.events.event_queue import EventQueue @@ -87,6 +88,13 @@ async def execute( self.running_tasks.add(task_id) + # 1.0 semantics: the Task itself must be enqueued before any + # TaskStatusUpdateEvent (the SDK enforces this ordering). + task = context.current_task + if not task: + task = new_task_from_user_message(user_message) + await event_queue.enqueue_event(task) + logger.info( '[SUTAgentExecutor] Processing message %s for task %s (context: %s)', user_message.message_id, diff --git a/tests/server/routes/test_jsonrpc_routes.py b/tests/server/routes/test_jsonrpc_routes.py index a9e166f69..e8a280949 100644 --- a/tests/server/routes/test_jsonrpc_routes.py +++ b/tests/server/routes/test_jsonrpc_routes.py @@ -26,12 +26,18 @@ def test_routes_creation(agent_card, mock_handler): ) assert isinstance(routes, list) - assert len(routes) == 1 + # Both the exact path and the trailing-slash variant are registered so + # that clients sending either form (httpx normalizes empty paths to a + # trailing slash) reach the endpoint. + assert len(routes) == 2 from starlette.routing import Route - assert isinstance(routes[0], Route) - assert routes[0].methods == {'POST'} + for route in routes: + assert isinstance(route, Route) + assert route.methods == {'POST'} + + assert {route.path for route in routes} == {'/a2a/jsonrpc', '/a2a/jsonrpc/'} def test_jsonrpc_custom_url(agent_card, mock_handler): From 860bd657dac117fbff40f04f9f92b3792652e5ed Mon Sep 17 00:00:00 2001 From: kuangmi-bit Date: Thu, 6 Aug 2026 20:07:46 +0800 Subject: [PATCH 2/3] fix(tck): use 1.0 protocol binding names and bare gRPC target in SUT card Two more 1.0 compatibility fixes surfaced by running the REST and gRPC rows of the TCK: - protocolBinding 'REST' -> 'HTTP+JSON': the 1.0 TCK's protocol binding map only recognizes JSONRPC / GRPC / HTTP+JSON. The old name made the whole REST transport untestable ("No usable transports after filtering"). - gRPC interface url 'http://localhost:50051' -> 'localhost:50051': the gRPC client treats the url as a channel target; the http:// prefix fails DNS resolution in grpcio. Verified against a2a-tck 1.0.0.alpha2 (must level): - jsonrpc: 6 failed / 67 passed - http_json (REST): 5 failed / 61 passed - grpc: 7 failed / 48 passed Remaining failures are SUT feature gaps (artifact-carrying responses, MessageResponse variants) plus two status/error-code mappings. --- tck/sut_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tck/sut_agent.py b/tck/sut_agent.py index 176c8cfc1..206774c64 100644 --- a/tck/sut_agent.py +++ b/tck/sut_agent.py @@ -165,11 +165,11 @@ def serve(task_store: TaskStore) -> None: ), AgentInterface( url=f'http://localhost:{http_port}{REST_URL}', - protocol_binding='REST', + protocol_binding='HTTP+JSON', protocol_version='1.0.0', ), AgentInterface( - url=f'http://localhost:{grpc_port}', + url=f'localhost:{grpc_port}', protocol_binding='GRPC', protocol_version='1.0.0', ), From 0c2894c2193a047f93aa5c5bf92eedc61c33bd99 Mon Sep 17 00:00:00 2001 From: kuangmi-bit Date: Mon, 24 Aug 2026 15:18:37 +0800 Subject: [PATCH 3/3] fix(client): follow redirects for endpoint variants; drop duplicated route Review feedback (mykytanetipa): the duplicated trailing-slash route was a behavioral change for callers of create_jsonrpc_routes and broke rpc_url='/' (f'{rpc_url}/' -> '//', used by the FastAPI mount). Revert the server-side route; the mismatch is a client redirect-following issue: Starlette's default redirect_slashes 307s the trailing-slash variant, and 307 preserves method+body, so following it is safe for JSON-RPC POSTs. The factory-created httpx client now sets follow_redirects=True (users supplying their own client keep their policy). Regression tests: default client follows redirects; custom client policy respected. Signed-off-by: kuangmi-bit --- src/a2a/client/client_factory.py | 8 +++++++- src/a2a/server/routes/jsonrpc_routes.py | 5 ----- tests/client/test_client_factory.py | 18 ++++++++++++++++++ tests/server/routes/test_jsonrpc_routes.py | 12 +++--------- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index a59189ade..75e4fe932 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -78,7 +78,13 @@ def __init__( config: ClientConfig | None = None, ): config = config or ClientConfig() - httpx_client = config.httpx_client or httpx.AsyncClient() + # Follow redirects by default: a server may 307 a trailing-slash + # variant of the JSON-RPC/REST endpoint (Starlette's default + # redirect_slashes). 307 preserves method and body, so this is safe + # for POST payloads and keeps the client robust to either spelling. + httpx_client = config.httpx_client or httpx.AsyncClient( + follow_redirects=True + ) httpx_client.headers.setdefault( VERSION_HEADER, PROTOCOL_VERSION_CURRENT ) diff --git a/src/a2a/server/routes/jsonrpc_routes.py b/src/a2a/server/routes/jsonrpc_routes.py index f411d5eba..5b44d7f76 100644 --- a/src/a2a/server/routes/jsonrpc_routes.py +++ b/src/a2a/server/routes/jsonrpc_routes.py @@ -65,9 +65,4 @@ def create_jsonrpc_routes( endpoint=dispatcher.handle_requests, methods=['POST'], ), - Route( - path=f'{rpc_url}/', - endpoint=dispatcher.handle_requests, - methods=['POST'], - ), ] diff --git a/tests/client/test_client_factory.py b/tests/client/test_client_factory.py index d211a7331..5683bcda3 100644 --- a/tests/client/test_client_factory.py +++ b/tests/client/test_client_factory.py @@ -136,6 +136,24 @@ def test_client_factory_create_with_default_config( assert client._transport.url == 'http://primary-url.com' # type: ignore[attr-defined] +def test_client_factory_default_client_follows_redirects( + base_agent_card: AgentCard, +): + """Default factory client follows redirects (307 trailing-slash handling).""" + factory = ClientFactory() + assert factory._httpx_client.follow_redirects is True + + +def test_client_factory_respects_custom_client_redirect_setting( + base_agent_card: AgentCard, +): + """A user-supplied httpx client keeps its own redirect policy.""" + custom = httpx.AsyncClient(follow_redirects=False) + factory = ClientFactory(ClientConfig(httpx_client=custom)) + assert factory._httpx_client is custom + assert factory._httpx_client.follow_redirects is False + + @pytest.mark.asyncio async def test_client_factory_create_from_url(base_agent_card: AgentCard): """Verify that create_from_url resolves the card and creates a client.""" diff --git a/tests/server/routes/test_jsonrpc_routes.py b/tests/server/routes/test_jsonrpc_routes.py index e8a280949..a9e166f69 100644 --- a/tests/server/routes/test_jsonrpc_routes.py +++ b/tests/server/routes/test_jsonrpc_routes.py @@ -26,18 +26,12 @@ def test_routes_creation(agent_card, mock_handler): ) assert isinstance(routes, list) - # Both the exact path and the trailing-slash variant are registered so - # that clients sending either form (httpx normalizes empty paths to a - # trailing slash) reach the endpoint. - assert len(routes) == 2 + assert len(routes) == 1 from starlette.routing import Route - for route in routes: - assert isinstance(route, Route) - assert route.methods == {'POST'} - - assert {route.path for route in routes} == {'/a2a/jsonrpc', '/a2a/jsonrpc/'} + assert isinstance(routes[0], Route) + assert routes[0].methods == {'POST'} def test_jsonrpc_custom_url(agent_card, mock_handler):