diff --git a/src/a2a/compat/v0_3/rest_transport.py b/src/a2a/compat/v0_3/rest_transport.py index bcaed2949..d5955c8c9 100644 --- a/src/a2a/compat/v0_3/rest_transport.py +++ b/src/a2a/compat/v0_3/rest_transport.py @@ -139,7 +139,7 @@ async def send_message_streaming( async for event in self._send_stream_request( 'POST', - '/v1/message:stream', + '/v1/message:stream?alt=sse', context=context, json=MessageToDict(req_proto, preserving_proto_field_name=True), ): @@ -291,7 +291,7 @@ async def subscribe( try: async for event in self._send_stream_request( subscribe_method, - f'/v1/tasks/{request.id}:subscribe', + f'/v1/tasks/{request.id}:subscribe?alt=sse', context=context, ): yield event diff --git a/src/a2a/compat/v0_3/types.py b/src/a2a/compat/v0_3/types.py index 918a06b5e..fc2666eb6 100644 --- a/src/a2a/compat/v0_3/types.py +++ b/src/a2a/compat/v0_3/types.py @@ -6,7 +6,7 @@ from enum import Enum from typing import Any, Literal -from pydantic import Field, RootModel +from pydantic import AliasChoices, Field, RootModel from a2a._base import A2ABaseModel @@ -1458,7 +1458,7 @@ class Message(A2ABaseModel): """ Optional metadata for extensions. The key is an extension-specific identifier. """ - parts: list[Part] + parts: list[Part] = Field(validation_alias=AliasChoices('parts', 'content')) """ An array of content parts that form the message body. A message can be composed of multiple parts of different types (e.g., text and files). diff --git a/tests/compat/v0_3/test_conversions.py b/tests/compat/v0_3/test_conversions.py index 6e1200177..15ccd313d 100644 --- a/tests/compat/v0_3/test_conversions.py +++ b/tests/compat/v0_3/test_conversions.py @@ -203,6 +203,27 @@ def test_message_conversion(): assert v03_restored == v03_msg +def test_message_content_alias(): + """Test that Message accepts 'content' as alias for 'parts' (v0.3 compatibility).""" + # Test with 'content' key (legacy v0.3 wire format) + legacy_json = { + 'messageId': 'msg-123', + 'role': 'user', + 'content': [{'kind': 'text', 'text': 'hello'}], + } + msg = types_v03.Message.model_validate(legacy_json) + assert msg.parts == [types_v03.Part(root=types_v03.TextPart(text='hello'))] + + # Test with 'parts' key (modern format) + modern_json = { + 'messageId': 'msg-456', + 'role': 'agent', + 'parts': [{'kind': 'text', 'text': 'response'}], + } + msg2 = types_v03.Message.model_validate(modern_json) + assert msg2.parts == [types_v03.Part(root=types_v03.TextPart(text='response'))] + + def test_message_conversion_minimal(): v03_msg = types_v03.Message( message_id='m1', diff --git a/tests/compat/v0_3/test_rest_transport.py b/tests/compat/v0_3/test_rest_transport.py index 2bea70f42..28e0412d1 100644 --- a/tests/compat/v0_3/test_rest_transport.py +++ b/tests/compat/v0_3/test_rest_transport.py @@ -233,6 +233,35 @@ async def mock_send_stream_request(*args, **kwargs): assert events[1] == StreamResponse(message=Message(message_id='msg-123')) +@pytest.mark.asyncio +async def test_compat_rest_transport_stream_url_has_alt_sse_param(transport): + """Test that streaming endpoints include ?alt=sse parameter.""" + captured_paths = [] + + async def mock_send_stream_request(method, path, context=None, json=None): + captured_paths.append(path) + task = Task(id='task-123') + task.status.message.role = Role.ROLE_AGENT + yield StreamResponse(task=task) + + transport._send_stream_request = mock_send_stream_request + + # Test send_message_streaming + req = SendMessageRequest( + message=Message(message_id='msg-1', role=Role.ROLE_USER) + ) + _ = [event async for event in transport.send_message_streaming(req)] + + assert captured_paths[-1] == '/v1/message:stream?alt=sse' + + # Test subscribe + captured_paths.clear() + sub_req = SubscribeToTaskRequest(id='task-123') + _ = [event async for event in transport.subscribe(sub_req)] + + assert captured_paths[-1] == '/v1/tasks/task-123:subscribe?alt=sse' + + def create_405_error(): mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 405