Skip to content

Commit f22ca7d

Browse files
committed
fix: respect A2A Message.role in inbound event conversion (#5186)
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.
1 parent b0715d7 commit f22ca7d

File tree

6 files changed

+261
-4
lines changed

6 files changed

+261
-4
lines changed

src/google/adk/a2a/converters/event_converter.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
from google.adk.platform import uuid as platform_uuid
3838
from google.genai import types as genai_types
3939

40+
from .utils import a2a_role_to_genai_role
41+
4042
from ...agents.invocation_context import InvocationContext
4143
from ...events.event import Event
4244
from ...flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME
@@ -238,7 +240,13 @@ def convert_a2a_task_to_event(
238240
):
239241
message = a2a_task.status.message
240242
elif a2a_task.history:
241-
message = a2a_task.history[-1]
243+
# Only pick agent-role messages from history; a trailing user
244+
# message should not be misattributed as agent output.
245+
agent_messages = [
246+
m for m in a2a_task.history if m.role == Role.agent
247+
]
248+
if agent_messages:
249+
message = agent_messages[-1]
242250

243251
# Convert message if available
244252
if message:
@@ -292,6 +300,8 @@ def convert_a2a_message_to_event(
292300
if a2a_message is None:
293301
raise ValueError("A2A message cannot be None")
294302

303+
genai_role = a2a_role_to_genai_role(a2a_message.role)
304+
295305
if not a2a_message.parts:
296306
logger.warning(
297307
"A2A message has no parts, creating event with empty content"
@@ -304,7 +314,7 @@ def convert_a2a_message_to_event(
304314
),
305315
author=author or "a2a agent",
306316
branch=invocation_context.branch if invocation_context else None,
307-
content=genai_types.Content(role="model", parts=[]),
317+
content=genai_types.Content(role=genai_role, parts=[]),
308318
)
309319

310320
try:
@@ -358,7 +368,7 @@ def convert_a2a_message_to_event(
358368
if long_running_tool_ids
359369
else None,
360370
content=genai_types.Content(
361-
role="model",
371+
role=genai_role,
362372
parts=output_parts,
363373
),
364374
)

src/google/adk/a2a/converters/to_adk_event.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from .part_converter import A2APartToGenAIPartConverter
4040
from .part_converter import convert_a2a_part_to_genai_part
4141
from .utils import _get_adk_metadata_key
42+
from .utils import a2a_role_to_genai_role
4243

4344
# Logger
4445
logger = logging.getLogger("google_adk." + __name__)
@@ -177,6 +178,7 @@ def _create_event(
177178
actions: Optional[EventActions] = None,
178179
long_running_function_ids: Optional[set[str]] = None,
179180
partial: bool = False,
181+
role: str = "model",
180182
) -> Optional[Event]:
181183
"""Creates an ADK event from parts and metadata."""
182184
event_actions = actions or EventActions()
@@ -199,7 +201,7 @@ def _create_event(
199201
),
200202
content=(
201203
genai_types.Content(
202-
role="model",
204+
role=role,
203205
parts=output_parts,
204206
)
205207
if output_parts
@@ -380,6 +382,7 @@ def convert_a2a_message_to_event(
380382
invocation_context,
381383
author,
382384
_extract_event_actions(a2a_message.metadata),
385+
role=a2a_role_to_genai_role(a2a_message.role),
383386
)
384387

385388
except Exception as e:

src/google/adk/a2a/converters/utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,37 @@
1414

1515
from __future__ import annotations
1616

17+
from typing import Union
18+
19+
from a2a.types import Role
20+
1721
ADK_METADATA_KEY_PREFIX = "adk_"
1822
ADK_CONTEXT_ID_PREFIX = "ADK"
1923
ADK_CONTEXT_ID_SEPARATOR = "/"
2024

25+
_A2A_ROLE_TO_GENAI_ROLE = {
26+
Role.agent: "model",
27+
Role.user: "user",
28+
}
29+
30+
31+
def a2a_role_to_genai_role(role: Union[Role, str]) -> str:
32+
"""Maps an A2A Role to the corresponding GenAI content role.
33+
34+
Args:
35+
role: An A2A Role enum value or its string equivalent.
36+
37+
Returns:
38+
``"model"`` for ``Role.agent``, ``"user"`` for ``Role.user``.
39+
Falls back to ``"model"`` for unrecognised values.
40+
"""
41+
if isinstance(role, str):
42+
try:
43+
role = Role(role)
44+
except ValueError:
45+
return "model"
46+
return _A2A_ROLE_TO_GENAI_ROLE.get(role, "model")
47+
2148

2249
def _get_adk_metadata_key(key: str) -> str:
2350
"""Gets the A2A event metadata key for the given key.

tests/unittests/a2a/converters/test_event_converter.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@ def test_convert_a2a_task_to_event_with_history_message(self):
697697

698698
# Create mock message and task
699699
mock_message = Mock(spec=Message)
700+
mock_message.role = Role.agent
700701
mock_task = Mock(spec=Task)
701702
mock_task.artifacts = None
702703
mock_task.status = None
@@ -799,6 +800,7 @@ def test_convert_a2a_message_to_event_success(self):
799800
mock_convert_part = Mock(return_value=mock_genai_part)
800801

801802
mock_message = Mock(spec=Message, parts=[mock_a2a_part])
803+
mock_message.role = Role.agent
802804

803805
result = convert_a2a_message_to_event(
804806
mock_message,
@@ -828,6 +830,7 @@ def test_convert_a2a_message_to_event_with_multiple_parts_returned(self):
828830
mock_convert_part = Mock(return_value=[mock_genai_part1, mock_genai_part2])
829831

830832
mock_message = Mock(spec=Message, parts=[mock_a2a_part])
833+
mock_message.role = Role.agent
831834

832835
# Act
833836
result = convert_a2a_message_to_event(
@@ -850,6 +853,7 @@ def test_convert_a2a_message_to_event_with_long_running_tools(self):
850853

851854
# Create mock parts and message
852855
mock_message = Mock(spec=Message, parts=[Mock()])
856+
mock_message.role = Role.agent
853857

854858
# Mock the part conversion to return None to simulate long-running tool detection logic
855859
mock_convert_part = Mock(return_value=None)
@@ -876,6 +880,7 @@ def test_convert_a2a_message_to_event_empty_parts(self):
876880
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
877881

878882
mock_message = Mock(spec=Message, parts=[])
883+
mock_message.role = Role.agent
879884

880885
result = convert_a2a_message_to_event(
881886
mock_message, "test-author", self.mock_invocation_context
@@ -903,6 +908,7 @@ def test_convert_a2a_message_to_event_part_conversion_fails(self):
903908
mock_convert_part = Mock(return_value=None)
904909

905910
mock_message = Mock(spec=Message, parts=[mock_a2a_part])
911+
mock_message.role = Role.agent
906912

907913
result = convert_a2a_message_to_event(
908914
mock_message,
@@ -935,6 +941,7 @@ def test_convert_a2a_message_to_event_part_conversion_exception(self):
935941
)
936942

937943
mock_message = Mock(spec=Message, parts=[mock_a2a_part1, mock_a2a_part2])
944+
mock_message.role = Role.agent
938945

939946
result = convert_a2a_message_to_event(
940947
mock_message,
@@ -956,6 +963,7 @@ def test_convert_a2a_message_to_event_missing_tool_id(self):
956963

957964
# Create mock parts and message
958965
mock_message = Mock(spec=Message, parts=[Mock()])
966+
mock_message.role = Role.agent
959967

960968
# Mock the part conversion to return None
961969
mock_convert_part = Mock(return_value=None)
@@ -980,6 +988,7 @@ def test_convert_a2a_message_to_event_default_author(self, mock_uuid):
980988
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
981989

982990
mock_message = Mock(spec=Message, parts=[])
991+
mock_message.role = Role.agent
983992

984993
# Mock UUID generation
985994
mock_uuid.return_value = "generated-uuid"
@@ -990,3 +999,132 @@ def test_convert_a2a_message_to_event_default_author(self, mock_uuid):
990999
assert result.author == "a2a agent"
9911000
assert result.branch is None
9921001
assert result.invocation_id == "generated-uuid"
1002+
1003+
1004+
class TestRoleMappingRegression:
1005+
"""Regression tests for issue #5186: role mapping in A2A→ADK conversion."""
1006+
1007+
def setup_method(self):
1008+
"""Set up test fixtures."""
1009+
self.mock_invocation_context = Mock(spec=InvocationContext)
1010+
self.mock_invocation_context.invocation_id = "test-invocation-id"
1011+
self.mock_invocation_context.branch = "test-branch"
1012+
1013+
def test_user_role_message_maps_to_user_content_role(self):
1014+
"""A2A Role.user must produce content.role='user', not 'model'."""
1015+
from a2a.types import Part
1016+
from a2a.types import TextPart
1017+
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
1018+
1019+
message = Message(
1020+
message_id="msg-1",
1021+
role=Role.user,
1022+
parts=[Part(root=TextPart(text="user says hi"))],
1023+
)
1024+
1025+
event = convert_a2a_message_to_event(
1026+
message, "test-author", self.mock_invocation_context
1027+
)
1028+
1029+
assert event.content.role == "user"
1030+
1031+
def test_agent_role_message_maps_to_model_content_role(self):
1032+
"""A2A Role.agent must produce content.role='model'."""
1033+
from a2a.types import Part
1034+
from a2a.types import TextPart
1035+
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
1036+
1037+
message = Message(
1038+
message_id="msg-1",
1039+
role=Role.agent,
1040+
parts=[Part(root=TextPart(text="agent reply"))],
1041+
)
1042+
1043+
event = convert_a2a_message_to_event(
1044+
message, "test-author", self.mock_invocation_context
1045+
)
1046+
1047+
assert event.content.role == "model"
1048+
1049+
def test_empty_parts_user_message_preserves_user_role(self):
1050+
"""Even with empty parts, Role.user must map to content.role='user'."""
1051+
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
1052+
1053+
message = Message(message_id="msg-1", role=Role.user, parts=[])
1054+
1055+
event = convert_a2a_message_to_event(
1056+
message, "test-author", self.mock_invocation_context
1057+
)
1058+
1059+
assert event.content.role == "user"
1060+
1061+
def test_task_history_fallback_skips_trailing_user_message(self):
1062+
"""History fallback must not return a user-role trailing message."""
1063+
from a2a.types import Part
1064+
from a2a.types import TaskStatus
1065+
from a2a.types import TextPart
1066+
1067+
agent_msg = Message(
1068+
message_id="m1",
1069+
role=Role.agent,
1070+
parts=[Part(root=TextPart(text="agent reply"))],
1071+
)
1072+
user_msg = Message(
1073+
message_id="m2",
1074+
role=Role.user,
1075+
parts=[Part(root=TextPart(text="follow-up question"))],
1076+
)
1077+
1078+
task = Task(
1079+
id="task-1",
1080+
status=TaskStatus(
1081+
state=TaskState.submitted, timestamp="2024-01-01T00:00:00Z"
1082+
),
1083+
context_id="ctx-1",
1084+
history=[agent_msg, user_msg],
1085+
)
1086+
1087+
with patch(
1088+
"google.adk.a2a.converters.event_converter.convert_a2a_message_to_event"
1089+
) as mock_convert:
1090+
mock_event = Mock(spec=Event)
1091+
mock_convert.return_value = mock_event
1092+
1093+
convert_a2a_task_to_event(
1094+
task, "test-author", self.mock_invocation_context
1095+
)
1096+
1097+
# Must be called with the agent message, not the trailing user message
1098+
mock_convert.assert_called_once()
1099+
called_message = mock_convert.call_args[0][0]
1100+
assert called_message.role == Role.agent
1101+
assert called_message.message_id == "m1"
1102+
1103+
def test_task_history_fallback_only_user_messages_creates_minimal_event(self):
1104+
"""History with only user messages must produce a minimal event."""
1105+
from a2a.types import Part
1106+
from a2a.types import TaskStatus
1107+
from a2a.types import TextPart
1108+
1109+
user_msg = Message(
1110+
message_id="m1",
1111+
role=Role.user,
1112+
parts=[Part(root=TextPart(text="question"))],
1113+
)
1114+
1115+
task = Task(
1116+
id="task-1",
1117+
status=TaskStatus(
1118+
state=TaskState.submitted, timestamp="2024-01-01T00:00:00Z"
1119+
),
1120+
context_id="ctx-1",
1121+
history=[user_msg],
1122+
)
1123+
1124+
result = convert_a2a_task_to_event(
1125+
task, "test-author", self.mock_invocation_context
1126+
)
1127+
1128+
# No agent message to convert → minimal event (no content)
1129+
assert result.author == "test-author"
1130+
assert result.content is None

tests/unittests/a2a/converters/test_to_adk.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,58 @@ def test_convert_a2a_artifact_update_to_event_none(self):
418418
"""Test convert_a2a_artifact_update_to_event with None."""
419419
with pytest.raises(ValueError, match="A2A artifact update cannot be None"):
420420
convert_a2a_artifact_update_to_event(None)
421+
422+
423+
class TestToAdkRoleMappingRegression:
424+
"""Regression tests for issue #5186: role mapping in to_adk_event."""
425+
426+
def setup_method(self):
427+
self.mock_context = Mock(spec=InvocationContext)
428+
self.mock_context.invocation_id = "test-invocation"
429+
self.mock_context.branch = "test-branch"
430+
431+
def test_user_role_message_maps_to_user_content_role(self):
432+
"""A2A Role.user must produce content.role='user', not 'model'."""
433+
from a2a.types import Role
434+
435+
a2a_part = Mock(spec=A2APart)
436+
a2a_part.root = Mock(spec=TextPart)
437+
a2a_part.root.metadata = {}
438+
message = Message(
439+
message_id="msg-1",
440+
role=Role.user,
441+
parts=[a2a_part],
442+
)
443+
444+
mock_genai_part = genai_types.Part.from_text(text="user says hi")
445+
event = convert_a2a_message_to_event(
446+
message,
447+
author="test-author",
448+
invocation_context=self.mock_context,
449+
part_converter=Mock(return_value=[mock_genai_part]),
450+
)
451+
452+
assert event.content.role == "user"
453+
454+
def test_agent_role_message_maps_to_model_content_role(self):
455+
"""A2A Role.agent must produce content.role='model'."""
456+
from a2a.types import Role
457+
458+
a2a_part = Mock(spec=A2APart)
459+
a2a_part.root = Mock(spec=TextPart)
460+
a2a_part.root.metadata = {}
461+
message = Message(
462+
message_id="msg-1",
463+
role=Role.agent,
464+
parts=[a2a_part],
465+
)
466+
467+
mock_genai_part = genai_types.Part.from_text(text="agent reply")
468+
event = convert_a2a_message_to_event(
469+
message,
470+
author="test-author",
471+
invocation_context=self.mock_context,
472+
part_converter=Mock(return_value=[mock_genai_part]),
473+
)
474+
475+
assert event.content.role == "model"

0 commit comments

Comments
 (0)