diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 8aa8c3923..3915b781b 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -15,6 +15,7 @@ AGrpc aio aiomysql AIP +AJSON alg amannn aproject diff --git a/src/a2a/server/routes/rest_dispatcher.py b/src/a2a/server/routes/rest_dispatcher.py index 0791bf060..af8a4229a 100644 --- a/src/a2a/server/routes/rest_dispatcher.py +++ b/src/a2a/server/routes/rest_dispatcher.py @@ -36,7 +36,8 @@ from sse_starlette.event import ServerSentEvent from sse_starlette.sse import EventSourceResponse from starlette.requests import Request - from starlette.responses import JSONResponse, Response + from starlette.responses import JSONResponse as A2AJSONResponse + from starlette.responses import Response _package_starlette_installed = True else: @@ -46,12 +47,17 @@ from starlette.requests import Request from starlette.responses import JSONResponse, Response + class A2AJSONResponse(JSONResponse): + """JSONResponse labelled with the A2A media type.""" + + media_type = constants.A2A_JSON_MEDIA_TYPE + _package_starlette_installed = True except ImportError: EventSourceResponse = Any ServerSentEvent = Any Request = Any - JSONResponse = Any + A2AJSONResponse = Any Response = Any _package_starlette_installed = False @@ -172,7 +178,7 @@ async def _handler( return a2a_pb2.SendMessageResponse(message=task_or_message) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return A2AJSONResponse(content=MessageToDict(response)) @rest_stream_error_handler async def on_message_send_stream( @@ -210,7 +216,7 @@ async def _handler(context: ServerCallContext) -> a2a_pb2.Task: raise TaskNotFoundError response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return A2AJSONResponse(content=MessageToDict(response)) @rest_stream_error_handler async def on_subscribe_to_task( @@ -246,7 +252,7 @@ async def _handler(context: ServerCallContext) -> a2a_pb2.Task: raise TaskNotFoundError response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return A2AJSONResponse(content=MessageToDict(response)) @rest_error_handler async def get_push_notification(self, request: Request) -> Response: @@ -268,7 +274,7 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return A2AJSONResponse(content=MessageToDict(response)) @rest_error_handler async def delete_push_notification(self, request: Request) -> Response: @@ -286,7 +292,7 @@ async def _handler(context: ServerCallContext) -> None: ) await self._handle_non_streaming(request, _handler) - return JSONResponse(content={}) + return A2AJSONResponse(content={}) @rest_error_handler async def set_push_notification(self, request: Request) -> Response: @@ -305,7 +311,7 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return A2AJSONResponse(content=MessageToDict(response)) @rest_error_handler async def list_push_notifications(self, request: Request) -> Response: @@ -323,7 +329,7 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return A2AJSONResponse(content=MessageToDict(response)) @rest_error_handler async def list_tasks(self, request: Request) -> Response: @@ -338,7 +344,7 @@ async def _handler( return await self.request_handler.on_list_tasks(params, context) response = await self._handle_non_streaming(request, _handler) - return JSONResponse( + return A2AJSONResponse( content=serialize_list_tasks_response( response, params.include_artifacts ) @@ -360,4 +366,4 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return A2AJSONResponse(content=MessageToDict(response)) diff --git a/src/a2a/utils/constants.py b/src/a2a/utils/constants.py index 5497d8a24..3fd9b1431 100644 --- a/src/a2a/utils/constants.py +++ b/src/a2a/utils/constants.py @@ -11,6 +11,9 @@ MAX_LIST_TASKS_PAGE_SIZE = 100 """Maximum page size for the `tasks/list` method.""" +A2A_JSON_MEDIA_TYPE = 'application/a2a+json' +"""Media type for HTTP+JSON binding payloads.""" + class TransportProtocol(str, Enum): """Transport protocol string constants.""" diff --git a/tests/server/routes/test_rest_routes.py b/tests/server/routes/test_rest_routes.py index 7d37762c4..8a52bf22c 100644 --- a/tests/server/routes/test_rest_routes.py +++ b/tests/server/routes/test_rest_routes.py @@ -92,3 +92,27 @@ def test_rest_list_tasks(agent_card, mock_handler): response = client.get('/tasks', headers={'A2A-Version': '1.0'}) assert response.status_code == 200 assert mock_handler.on_list_tasks.called + + +def test_send_message_response_uses_the_a2a_media_type( + agent_card, mock_handler +): + """HTTP+JSON responses are labelled application/a2a+json.""" + mock_handler.on_message_send.return_value = Task(id='123') + client = TestClient(Starlette(routes=create_rest_routes(mock_handler))) + + response = client.post( + '/message:send', json={}, headers={'A2A-Version': '1.0'} + ) + + assert response.headers['content-type'].startswith('application/a2a+json') + + +def test_list_tasks_response_uses_the_a2a_media_type(agent_card, mock_handler): + """The list path serializes separately, so it is asserted separately.""" + mock_handler.on_list_tasks.return_value = ListTasksResponse() + client = TestClient(Starlette(routes=create_rest_routes(mock_handler))) + + response = client.get('/tasks', headers={'A2A-Version': '1.0'}) + + assert response.headers['content-type'].startswith('application/a2a+json')