diff --git a/src/a2a/compat/v0_3/proto_utils.py b/src/a2a/compat/v0_3/proto_utils.py index d7ac19085..b0c3f158f 100644 --- a/src/a2a/compat/v0_3/proto_utils.py +++ b/src/a2a/compat/v0_3/proto_utils.py @@ -307,9 +307,11 @@ def task_status_update_event( @classmethod def message_send_configuration( cls, config: types.MessageSendConfiguration | None - ) -> a2a_pb2.SendMessageConfiguration: + ) -> a2a_pb2.SendMessageConfiguration | None: + # An empty configuration reads as blocking=false, so send none and + # leave the server on its blocking default. if not config: - return a2a_pb2.SendMessageConfiguration() + return None return a2a_pb2.SendMessageConfiguration( accepted_output_modes=config.accepted_output_modes, push_notification=cls.push_notification_config( diff --git a/tests/compat/v0_3/test_grpc_transport.py b/tests/compat/v0_3/test_grpc_transport.py index 402a57000..51c443fc4 100644 --- a/tests/compat/v0_3/test_grpc_transport.py +++ b/tests/compat/v0_3/test_grpc_transport.py @@ -41,6 +41,34 @@ async def test_compat_grpc_transport_send_message_response_msg_parsing(): assert response.message.message_id == 'msg-123' +@pytest.mark.asyncio +async def test_compat_grpc_transport_send_message_without_configuration(): + """A request without a configuration must not send one. + + The v0.3 `blocking` field has no presence, so an empty configuration is + read by the server as `blocking: false` and the call returns before the + task finishes. Without a configuration the server blocks by default, as + it does for the v0.3 JSON-RPC transport. + """ + transport = CompatGrpcTransport( + channel=AsyncMock(spec=Channel), agent_card=None + ) + mock_stub = MagicMock() + mock_stub.SendMessage = AsyncMock( + return_value=a2a_v0_3_pb2.SendMessageResponse() + ) + transport.stub = mock_stub + + await transport.send_message( + SendMessageRequest( + message=Message(message_id='msg-1', role=Role.ROLE_USER) + ) + ) + + sent = mock_stub.SendMessage.call_args.args[0] + assert not sent.HasField('configuration') + + def test_compat_grpc_transport_mirrors_extension_metadata(): """Compat gRPC client must also emit the legacy x-a2a-extensions metadata so that v0.3 servers (which only know that name) understand the request.""" diff --git a/tests/compat/v0_3/test_rest_transport.py b/tests/compat/v0_3/test_rest_transport.py index 7ad838d9f..7f0ca2369 100644 --- a/tests/compat/v0_3/test_rest_transport.py +++ b/tests/compat/v0_3/test_rest_transport.py @@ -19,6 +19,7 @@ ListTasksRequest, Message, Role, + SendMessageConfiguration, SendMessageRequest, SendMessageResponse, StreamResponse, @@ -574,6 +575,56 @@ async def mock_send_request(*args, **kwargs): assert response == SendMessageResponse() +@pytest.mark.asyncio +async def test_compat_rest_transport_send_message_without_configuration( + transport, +): + """A request without a configuration must not send one. + + The v0.3 `blocking` field has no presence, so an empty configuration is + read by the server as `blocking: false` and the call returns before the + task finishes. Without a configuration the server blocks by default, as + it does for the v0.3 JSON-RPC transport. + """ + sent: dict = {} + + async def mock_execute_request(method, path, context=None, json=None): + sent.update(json) + return {} + + transport._execute_request = mock_execute_request + + req = SendMessageRequest( + message=Message(message_id='msg-1', role=Role.ROLE_USER) + ) + await transport.send_message(req) + + assert 'configuration' not in sent + + +@pytest.mark.asyncio +async def test_compat_rest_transport_send_message_with_configuration( + transport, +): + sent: dict = {} + + async def mock_execute_request(method, path, context=None, json=None): + sent.update(json) + return {} + + transport._execute_request = mock_execute_request + + req = SendMessageRequest( + message=Message(message_id='msg-1', role=Role.ROLE_USER), + configuration=SendMessageConfiguration( + accepted_output_modes=['text/plain'] + ), + ) + await transport.send_message(req) + + assert sent['configuration']['blocking'] is True + + @pytest.mark.asyncio async def test_compat_rest_transport_get_task_no_history(transport): async def mock_execute_request(method, path, context=None, params=None):