From f22ca7d2857068945762328993c19c8ef386b1af Mon Sep 17 00:00:00 2001 From: Giulio Leone Date: Thu, 9 Apr 2026 03:50:26 +0200 Subject: [PATCH] fix: respect A2A Message.role in inbound event conversion (#5186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convert_a2a_message_to_event() hard-coded role='model' on the GenAI Content it produced, ignoring the A2A Message.role field. This caused Role.user messages to be misattributed as model output. Additionally, the task-history fallback in convert_a2a_task_to_event() blindly took history[-1] without checking Message.role, so a trailing user message could be restored as agent output. Changes: - Add a2a_role_to_genai_role() helper in utils.py (Role.agent→'model', Role.user→'user'). - event_converter.py: use the helper instead of hard-coded 'model' in convert_a2a_message_to_event(); filter history fallback to agent-role messages only. - to_adk_event.py: add role parameter to _create_event(); pass mapped role from convert_a2a_message_to_event(). - Regression tests for role restoration and history fallback. --- .../adk/a2a/converters/event_converter.py | 16 +- src/google/adk/a2a/converters/to_adk_event.py | 5 +- src/google/adk/a2a/converters/utils.py | 27 ++++ .../a2a/converters/test_event_converter.py | 138 ++++++++++++++++++ tests/unittests/a2a/converters/test_to_adk.py | 55 +++++++ tests/unittests/a2a/converters/test_utils.py | 24 +++ 6 files changed, 261 insertions(+), 4 deletions(-) diff --git a/src/google/adk/a2a/converters/event_converter.py b/src/google/adk/a2a/converters/event_converter.py index e6a890941f..7aaa5573a1 100644 --- a/src/google/adk/a2a/converters/event_converter.py +++ b/src/google/adk/a2a/converters/event_converter.py @@ -37,6 +37,8 @@ from google.adk.platform import uuid as platform_uuid from google.genai import types as genai_types +from .utils import a2a_role_to_genai_role + from ...agents.invocation_context import InvocationContext from ...events.event import Event from ...flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME @@ -238,7 +240,13 @@ def convert_a2a_task_to_event( ): message = a2a_task.status.message elif a2a_task.history: - message = a2a_task.history[-1] + # Only pick agent-role messages from history; a trailing user + # message should not be misattributed as agent output. + agent_messages = [ + m for m in a2a_task.history if m.role == Role.agent + ] + if agent_messages: + message = agent_messages[-1] # Convert message if available if message: @@ -292,6 +300,8 @@ def convert_a2a_message_to_event( if a2a_message is None: raise ValueError("A2A message cannot be None") + genai_role = a2a_role_to_genai_role(a2a_message.role) + if not a2a_message.parts: logger.warning( "A2A message has no parts, creating event with empty content" @@ -304,7 +314,7 @@ def convert_a2a_message_to_event( ), author=author or "a2a agent", branch=invocation_context.branch if invocation_context else None, - content=genai_types.Content(role="model", parts=[]), + content=genai_types.Content(role=genai_role, parts=[]), ) try: @@ -358,7 +368,7 @@ def convert_a2a_message_to_event( if long_running_tool_ids else None, content=genai_types.Content( - role="model", + role=genai_role, parts=output_parts, ), ) diff --git a/src/google/adk/a2a/converters/to_adk_event.py b/src/google/adk/a2a/converters/to_adk_event.py index 26ae95e1b4..7ac5d61c92 100644 --- a/src/google/adk/a2a/converters/to_adk_event.py +++ b/src/google/adk/a2a/converters/to_adk_event.py @@ -39,6 +39,7 @@ from .part_converter import A2APartToGenAIPartConverter from .part_converter import convert_a2a_part_to_genai_part from .utils import _get_adk_metadata_key +from .utils import a2a_role_to_genai_role # Logger logger = logging.getLogger("google_adk." + __name__) @@ -177,6 +178,7 @@ def _create_event( actions: Optional[EventActions] = None, long_running_function_ids: Optional[set[str]] = None, partial: bool = False, + role: str = "model", ) -> Optional[Event]: """Creates an ADK event from parts and metadata.""" event_actions = actions or EventActions() @@ -199,7 +201,7 @@ def _create_event( ), content=( genai_types.Content( - role="model", + role=role, parts=output_parts, ) if output_parts @@ -380,6 +382,7 @@ def convert_a2a_message_to_event( invocation_context, author, _extract_event_actions(a2a_message.metadata), + role=a2a_role_to_genai_role(a2a_message.role), ) except Exception as e: diff --git a/src/google/adk/a2a/converters/utils.py b/src/google/adk/a2a/converters/utils.py index 00111f835d..7989ba5636 100644 --- a/src/google/adk/a2a/converters/utils.py +++ b/src/google/adk/a2a/converters/utils.py @@ -14,10 +14,37 @@ from __future__ import annotations +from typing import Union + +from a2a.types import Role + ADK_METADATA_KEY_PREFIX = "adk_" ADK_CONTEXT_ID_PREFIX = "ADK" ADK_CONTEXT_ID_SEPARATOR = "/" +_A2A_ROLE_TO_GENAI_ROLE = { + Role.agent: "model", + Role.user: "user", +} + + +def a2a_role_to_genai_role(role: Union[Role, str]) -> str: + """Maps an A2A Role to the corresponding GenAI content role. + + Args: + role: An A2A Role enum value or its string equivalent. + + Returns: + ``"model"`` for ``Role.agent``, ``"user"`` for ``Role.user``. + Falls back to ``"model"`` for unrecognised values. + """ + if isinstance(role, str): + try: + role = Role(role) + except ValueError: + return "model" + return _A2A_ROLE_TO_GENAI_ROLE.get(role, "model") + def _get_adk_metadata_key(key: str) -> str: """Gets the A2A event metadata key for the given key. diff --git a/tests/unittests/a2a/converters/test_event_converter.py b/tests/unittests/a2a/converters/test_event_converter.py index 61f8c3aca6..6be48cc620 100644 --- a/tests/unittests/a2a/converters/test_event_converter.py +++ b/tests/unittests/a2a/converters/test_event_converter.py @@ -697,6 +697,7 @@ def test_convert_a2a_task_to_event_with_history_message(self): # Create mock message and task mock_message = Mock(spec=Message) + mock_message.role = Role.agent mock_task = Mock(spec=Task) mock_task.artifacts = None mock_task.status = None @@ -799,6 +800,7 @@ def test_convert_a2a_message_to_event_success(self): mock_convert_part = Mock(return_value=mock_genai_part) mock_message = Mock(spec=Message, parts=[mock_a2a_part]) + mock_message.role = Role.agent result = convert_a2a_message_to_event( mock_message, @@ -828,6 +830,7 @@ def test_convert_a2a_message_to_event_with_multiple_parts_returned(self): mock_convert_part = Mock(return_value=[mock_genai_part1, mock_genai_part2]) mock_message = Mock(spec=Message, parts=[mock_a2a_part]) + mock_message.role = Role.agent # Act result = convert_a2a_message_to_event( @@ -850,6 +853,7 @@ def test_convert_a2a_message_to_event_with_long_running_tools(self): # Create mock parts and message mock_message = Mock(spec=Message, parts=[Mock()]) + mock_message.role = Role.agent # Mock the part conversion to return None to simulate long-running tool detection logic mock_convert_part = Mock(return_value=None) @@ -876,6 +880,7 @@ def test_convert_a2a_message_to_event_empty_parts(self): from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event mock_message = Mock(spec=Message, parts=[]) + mock_message.role = Role.agent result = convert_a2a_message_to_event( mock_message, "test-author", self.mock_invocation_context @@ -903,6 +908,7 @@ def test_convert_a2a_message_to_event_part_conversion_fails(self): mock_convert_part = Mock(return_value=None) mock_message = Mock(spec=Message, parts=[mock_a2a_part]) + mock_message.role = Role.agent result = convert_a2a_message_to_event( mock_message, @@ -935,6 +941,7 @@ def test_convert_a2a_message_to_event_part_conversion_exception(self): ) mock_message = Mock(spec=Message, parts=[mock_a2a_part1, mock_a2a_part2]) + mock_message.role = Role.agent result = convert_a2a_message_to_event( mock_message, @@ -956,6 +963,7 @@ def test_convert_a2a_message_to_event_missing_tool_id(self): # Create mock parts and message mock_message = Mock(spec=Message, parts=[Mock()]) + mock_message.role = Role.agent # Mock the part conversion to return None mock_convert_part = Mock(return_value=None) @@ -980,6 +988,7 @@ def test_convert_a2a_message_to_event_default_author(self, mock_uuid): from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event mock_message = Mock(spec=Message, parts=[]) + mock_message.role = Role.agent # Mock UUID generation mock_uuid.return_value = "generated-uuid" @@ -990,3 +999,132 @@ def test_convert_a2a_message_to_event_default_author(self, mock_uuid): assert result.author == "a2a agent" assert result.branch is None assert result.invocation_id == "generated-uuid" + + +class TestRoleMappingRegression: + """Regression tests for issue #5186: role mapping in A2A→ADK conversion.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_invocation_context = Mock(spec=InvocationContext) + self.mock_invocation_context.invocation_id = "test-invocation-id" + self.mock_invocation_context.branch = "test-branch" + + def test_user_role_message_maps_to_user_content_role(self): + """A2A Role.user must produce content.role='user', not 'model'.""" + from a2a.types import Part + from a2a.types import TextPart + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + + message = Message( + message_id="msg-1", + role=Role.user, + parts=[Part(root=TextPart(text="user says hi"))], + ) + + event = convert_a2a_message_to_event( + message, "test-author", self.mock_invocation_context + ) + + assert event.content.role == "user" + + def test_agent_role_message_maps_to_model_content_role(self): + """A2A Role.agent must produce content.role='model'.""" + from a2a.types import Part + from a2a.types import TextPart + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + + message = Message( + message_id="msg-1", + role=Role.agent, + parts=[Part(root=TextPart(text="agent reply"))], + ) + + event = convert_a2a_message_to_event( + message, "test-author", self.mock_invocation_context + ) + + assert event.content.role == "model" + + def test_empty_parts_user_message_preserves_user_role(self): + """Even with empty parts, Role.user must map to content.role='user'.""" + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + + message = Message(message_id="msg-1", role=Role.user, parts=[]) + + event = convert_a2a_message_to_event( + message, "test-author", self.mock_invocation_context + ) + + assert event.content.role == "user" + + def test_task_history_fallback_skips_trailing_user_message(self): + """History fallback must not return a user-role trailing message.""" + from a2a.types import Part + from a2a.types import TaskStatus + from a2a.types import TextPart + + agent_msg = Message( + message_id="m1", + role=Role.agent, + parts=[Part(root=TextPart(text="agent reply"))], + ) + user_msg = Message( + message_id="m2", + role=Role.user, + parts=[Part(root=TextPart(text="follow-up question"))], + ) + + task = Task( + id="task-1", + status=TaskStatus( + state=TaskState.submitted, timestamp="2024-01-01T00:00:00Z" + ), + context_id="ctx-1", + history=[agent_msg, user_msg], + ) + + with patch( + "google.adk.a2a.converters.event_converter.convert_a2a_message_to_event" + ) as mock_convert: + mock_event = Mock(spec=Event) + mock_convert.return_value = mock_event + + convert_a2a_task_to_event( + task, "test-author", self.mock_invocation_context + ) + + # Must be called with the agent message, not the trailing user message + mock_convert.assert_called_once() + called_message = mock_convert.call_args[0][0] + assert called_message.role == Role.agent + assert called_message.message_id == "m1" + + def test_task_history_fallback_only_user_messages_creates_minimal_event(self): + """History with only user messages must produce a minimal event.""" + from a2a.types import Part + from a2a.types import TaskStatus + from a2a.types import TextPart + + user_msg = Message( + message_id="m1", + role=Role.user, + parts=[Part(root=TextPart(text="question"))], + ) + + task = Task( + id="task-1", + status=TaskStatus( + state=TaskState.submitted, timestamp="2024-01-01T00:00:00Z" + ), + context_id="ctx-1", + history=[user_msg], + ) + + result = convert_a2a_task_to_event( + task, "test-author", self.mock_invocation_context + ) + + # No agent message to convert → minimal event (no content) + assert result.author == "test-author" + assert result.content is None diff --git a/tests/unittests/a2a/converters/test_to_adk.py b/tests/unittests/a2a/converters/test_to_adk.py index 12eaf2a75a..568b7a625c 100644 --- a/tests/unittests/a2a/converters/test_to_adk.py +++ b/tests/unittests/a2a/converters/test_to_adk.py @@ -418,3 +418,58 @@ def test_convert_a2a_artifact_update_to_event_none(self): """Test convert_a2a_artifact_update_to_event with None.""" with pytest.raises(ValueError, match="A2A artifact update cannot be None"): convert_a2a_artifact_update_to_event(None) + + +class TestToAdkRoleMappingRegression: + """Regression tests for issue #5186: role mapping in to_adk_event.""" + + def setup_method(self): + self.mock_context = Mock(spec=InvocationContext) + self.mock_context.invocation_id = "test-invocation" + self.mock_context.branch = "test-branch" + + def test_user_role_message_maps_to_user_content_role(self): + """A2A Role.user must produce content.role='user', not 'model'.""" + from a2a.types import Role + + a2a_part = Mock(spec=A2APart) + a2a_part.root = Mock(spec=TextPart) + a2a_part.root.metadata = {} + message = Message( + message_id="msg-1", + role=Role.user, + parts=[a2a_part], + ) + + mock_genai_part = genai_types.Part.from_text(text="user says hi") + event = convert_a2a_message_to_event( + message, + author="test-author", + invocation_context=self.mock_context, + part_converter=Mock(return_value=[mock_genai_part]), + ) + + assert event.content.role == "user" + + def test_agent_role_message_maps_to_model_content_role(self): + """A2A Role.agent must produce content.role='model'.""" + from a2a.types import Role + + a2a_part = Mock(spec=A2APart) + a2a_part.root = Mock(spec=TextPart) + a2a_part.root.metadata = {} + message = Message( + message_id="msg-1", + role=Role.agent, + parts=[a2a_part], + ) + + mock_genai_part = genai_types.Part.from_text(text="agent reply") + event = convert_a2a_message_to_event( + message, + author="test-author", + invocation_context=self.mock_context, + part_converter=Mock(return_value=[mock_genai_part]), + ) + + assert event.content.role == "model" diff --git a/tests/unittests/a2a/converters/test_utils.py b/tests/unittests/a2a/converters/test_utils.py index bfbd25aae6..8bdda202b3 100644 --- a/tests/unittests/a2a/converters/test_utils.py +++ b/tests/unittests/a2a/converters/test_utils.py @@ -15,6 +15,7 @@ from google.adk.a2a.converters.utils import _from_a2a_context_id from google.adk.a2a.converters.utils import _get_adk_metadata_key from google.adk.a2a.converters.utils import _to_a2a_context_id +from google.adk.a2a.converters.utils import a2a_role_to_genai_role from google.adk.a2a.converters.utils import ADK_CONTEXT_ID_PREFIX from google.adk.a2a.converters.utils import ADK_METADATA_KEY_PREFIX import pytest @@ -202,3 +203,26 @@ def test_from_a2a_context_id_special_characters(self): assert app_name == "test-app@2024" assert user_id == "user_123" assert session_id == "session-456" + + +class TestA2ARoleToGenaiRole: + """Tests for a2a_role_to_genai_role helper.""" + + def test_agent_role_maps_to_model(self): + from a2a.types import Role + + assert a2a_role_to_genai_role(Role.agent) == "model" + + def test_user_role_maps_to_user(self): + from a2a.types import Role + + assert a2a_role_to_genai_role(Role.user) == "user" + + def test_string_agent_maps_to_model(self): + assert a2a_role_to_genai_role("agent") == "model" + + def test_string_user_maps_to_user(self): + assert a2a_role_to_genai_role("user") == "user" + + def test_unknown_string_falls_back_to_model(self): + assert a2a_role_to_genai_role("unknown") == "model"