Skip to content

Commit 968845f

Browse files
giulio-leonecopybara-github
authored andcommitted
fix: respect A2A Message.role in inbound event conversion
Merge #5224 Fixes #5186 PiperOrigin-RevId: 952167313
1 parent dab351b commit 968845f

2 files changed

Lines changed: 134 additions & 12 deletions

File tree

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,13 @@ def convert_a2a_task_to_event(
234234
):
235235
message = a2a_task.status.message
236236
elif a2a_task.history:
237-
message = a2a_task.history[-1]
237+
# Only pick agent-role messages from history; a trailing user
238+
# message should not be misattributed as agent output.
239+
agent_messages = [
240+
m for m in a2a_task.history if m.role == _compat.ROLE_AGENT
241+
]
242+
if agent_messages:
243+
message = agent_messages[-1]
238244

239245
# Convert message if available
240246
if message:
@@ -288,6 +294,8 @@ def convert_a2a_message_to_event(
288294
if a2a_message is None:
289295
raise ValueError("A2A message cannot be None")
290296

297+
genai_role = _compat.role_to_str(a2a_message.role)
298+
291299
if not a2a_message.parts:
292300
logger.warning(
293301
"A2A message has no parts, creating event with empty content"
@@ -300,7 +308,7 @@ def convert_a2a_message_to_event(
300308
),
301309
author=author or "a2a agent",
302310
branch=invocation_context.branch if invocation_context else None,
303-
content=genai_types.Content(role="model", parts=[]),
311+
content=genai_types.Content(role=genai_role, parts=[]),
304312
)
305313

306314
try:
@@ -355,7 +363,7 @@ def convert_a2a_message_to_event(
355363
if long_running_tool_ids
356364
else None,
357365
content=genai_types.Content(
358-
role="model",
366+
role=genai_role,
359367
parts=output_parts,
360368
),
361369
)

tests/unittests/a2a/converters/test_event_converter.py

Lines changed: 123 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from google.adk.a2a.converters.event_converter import _process_long_running_tool
3030
from google.adk.a2a.converters.event_converter import _serialize_metadata_value
3131
from google.adk.a2a.converters.event_converter import ARTIFACT_ID_SEPARATOR
32+
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
3233
from google.adk.a2a.converters.event_converter import convert_a2a_task_to_event
3334
from google.adk.a2a.converters.event_converter import convert_event_to_a2a_events
3435
from google.adk.a2a.converters.part_converter import convert_genai_part_to_a2a_part
@@ -766,6 +767,7 @@ def test_convert_a2a_task_to_event_with_history_message(self):
766767

767768
# Create mock message and task
768769
mock_message = Mock(spec=Message)
770+
mock_message.role = _compat.ROLE_AGENT
769771
mock_task = Mock(spec=Task)
770772
mock_task.artifacts = None
771773
mock_task.status = None
@@ -859,7 +861,6 @@ def test_convert_a2a_task_to_event_message_conversion_error(self):
859861

860862
def test_convert_a2a_message_to_event_success(self):
861863
"""Test successful conversion of A2A message to event."""
862-
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
863864

864865
# Use a real A2A part (production reads its metadata); the part_converter
865866
# callback is still mocked to return a canned genai Part.
@@ -868,6 +869,7 @@ def test_convert_a2a_message_to_event_success(self):
868869
mock_convert_part = Mock(return_value=mock_genai_part)
869870

870871
mock_message = Mock(spec=Message, parts=[mock_a2a_part])
872+
mock_message.role = _compat.ROLE_AGENT
871873

872874
result = convert_a2a_message_to_event(
873875
mock_message,
@@ -887,7 +889,6 @@ def test_convert_a2a_message_to_event_success(self):
887889

888890
def test_convert_a2a_message_to_event_with_multiple_parts_returned(self):
889891
"""Test message to event conversion when part_converter returns multiple parts."""
890-
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
891892

892893
# Arrange
893894
mock_a2a_part = _compat.make_text_part("part 1")
@@ -896,6 +897,7 @@ def test_convert_a2a_message_to_event_with_multiple_parts_returned(self):
896897
mock_convert_part = Mock(return_value=[mock_genai_part1, mock_genai_part2])
897898

898899
mock_message = Mock(spec=Message, parts=[mock_a2a_part])
900+
mock_message.role = _compat.ROLE_AGENT
899901

900902
# Act
901903
result = convert_a2a_message_to_event(
@@ -914,10 +916,10 @@ def test_convert_a2a_message_to_event_with_multiple_parts_returned(self):
914916

915917
def test_convert_a2a_message_to_event_with_long_running_tools(self):
916918
"""Test conversion with long-running tools by mocking the entire flow."""
917-
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
918919

919920
# Create mock parts and message
920921
mock_message = Mock(spec=Message, parts=[Mock()])
922+
mock_message.role = _compat.ROLE_AGENT
921923

922924
# Mock the part conversion to return None to simulate long-running tool detection logic
923925
mock_convert_part = Mock(return_value=None)
@@ -941,9 +943,9 @@ def test_convert_a2a_message_to_event_with_long_running_tools(self):
941943

942944
def test_convert_a2a_message_to_event_empty_parts(self):
943945
"""Test conversion with empty parts list."""
944-
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
945946

946947
mock_message = Mock(spec=Message, parts=[])
948+
mock_message.role = _compat.ROLE_AGENT
947949

948950
result = convert_a2a_message_to_event(
949951
mock_message, "test-author", self.mock_invocation_context
@@ -957,20 +959,19 @@ def test_convert_a2a_message_to_event_empty_parts(self):
957959

958960
def test_convert_a2a_message_to_event_none_message(self):
959961
"""Test converting None message raises ValueError."""
960-
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
961962

962963
with pytest.raises(ValueError, match="A2A message cannot be None"):
963964
convert_a2a_message_to_event(None)
964965

965966
def test_convert_a2a_message_to_event_part_conversion_fails(self):
966967
"""Test handling when part conversion returns None."""
967-
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
968968

969969
# Setup mock to return None (conversion failure)
970970
mock_a2a_part = Mock()
971971
mock_convert_part = Mock(return_value=None)
972972

973973
mock_message = Mock(spec=Message, parts=[mock_a2a_part])
974+
mock_message.role = _compat.ROLE_AGENT
974975

975976
result = convert_a2a_message_to_event(
976977
mock_message,
@@ -987,7 +988,6 @@ def test_convert_a2a_message_to_event_part_conversion_fails(self):
987988

988989
def test_convert_a2a_message_to_event_part_conversion_exception(self):
989990
"""Test handling when part conversion raises exception."""
990-
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
991991

992992
# Setup mock to raise exception. The A2A parts are real (production
993993
# reads their metadata); the converter callback drives the behavior.
@@ -1003,6 +1003,7 @@ def test_convert_a2a_message_to_event_part_conversion_exception(self):
10031003
)
10041004

10051005
mock_message = Mock(spec=Message, parts=[mock_a2a_part1, mock_a2a_part2])
1006+
mock_message.role = _compat.ROLE_AGENT
10061007

10071008
result = convert_a2a_message_to_event(
10081009
mock_message,
@@ -1020,10 +1021,10 @@ def test_convert_a2a_message_to_event_part_conversion_exception(self):
10201021

10211022
def test_convert_a2a_message_to_event_missing_tool_id(self):
10221023
"""Test handling of message conversion when part conversion fails."""
1023-
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
10241024

10251025
# Create mock parts and message
10261026
mock_message = Mock(spec=Message, parts=[Mock()])
1027+
mock_message.role = _compat.ROLE_AGENT
10271028

10281029
# Mock the part conversion to return None
10291030
mock_convert_part = Mock(return_value=None)
@@ -1045,9 +1046,9 @@ def test_convert_a2a_message_to_event_missing_tool_id(self):
10451046
@patch("google.adk.a2a.converters.event_converter.platform_uuid.new_uuid")
10461047
def test_convert_a2a_message_to_event_default_author(self, mock_uuid):
10471048
"""Test conversion with default author and no invocation context."""
1048-
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
10491049

10501050
mock_message = Mock(spec=Message, parts=[])
1051+
mock_message.role = _compat.ROLE_AGENT
10511052

10521053
# Mock UUID generation
10531054
mock_uuid.return_value = "generated-uuid"
@@ -1058,3 +1059,116 @@ def test_convert_a2a_message_to_event_default_author(self, mock_uuid):
10581059
assert result.author == "a2a agent"
10591060
assert result.branch is None
10601061
assert result.invocation_id == "generated-uuid"
1062+
1063+
1064+
class TestRoleMappingRegression:
1065+
"""Regression tests for issue #5186: role mapping in A2A→ADK conversion."""
1066+
1067+
def setup_method(self):
1068+
"""Set up test fixtures."""
1069+
self.mock_invocation_context = Mock(spec=InvocationContext)
1070+
self.mock_invocation_context.invocation_id = "test-invocation-id"
1071+
self.mock_invocation_context.branch = "test-branch"
1072+
1073+
def test_user_role_message_maps_to_user_content_role(self):
1074+
"""A2A Role.user must produce content.role='user', not 'model'."""
1075+
message = _compat.make_message(
1076+
message_id="msg-1",
1077+
role=_compat.ROLE_USER,
1078+
parts=[_compat.make_text_part("user says hi")],
1079+
)
1080+
1081+
event = convert_a2a_message_to_event(
1082+
message, "test-author", self.mock_invocation_context
1083+
)
1084+
1085+
assert event.content.role == "user"
1086+
1087+
def test_agent_role_message_maps_to_model_content_role(self):
1088+
"""A2A Role.agent must produce content.role='model'."""
1089+
message = _compat.make_message(
1090+
message_id="msg-1",
1091+
role=_compat.ROLE_AGENT,
1092+
parts=[_compat.make_text_part("agent reply")],
1093+
)
1094+
1095+
event = convert_a2a_message_to_event(
1096+
message, "test-author", self.mock_invocation_context
1097+
)
1098+
1099+
assert event.content.role == "model"
1100+
1101+
def test_empty_parts_user_message_preserves_user_role(self):
1102+
"""Even with empty parts, Role.user must map to content.role='user'."""
1103+
message = _compat.make_message(
1104+
message_id="msg-1",
1105+
role=_compat.ROLE_USER,
1106+
parts=[],
1107+
)
1108+
1109+
event = convert_a2a_message_to_event(
1110+
message, "test-author", self.mock_invocation_context
1111+
)
1112+
1113+
assert event.content.role == "user"
1114+
1115+
def test_task_history_fallback_skips_trailing_user_message(self):
1116+
"""History fallback must not return a user-role trailing message."""
1117+
agent_msg = _compat.make_message(
1118+
message_id="m1",
1119+
role=_compat.ROLE_AGENT,
1120+
parts=[_compat.make_text_part("agent reply")],
1121+
)
1122+
user_msg = _compat.make_message(
1123+
message_id="m2",
1124+
role=_compat.ROLE_USER,
1125+
parts=[_compat.make_text_part("follow-up question")],
1126+
)
1127+
1128+
status = _compat.make_task_status(_compat.TS_SUBMITTED)
1129+
task = _compat.make_task(
1130+
id="task-1",
1131+
status=status,
1132+
context_id="ctx-1",
1133+
history=[agent_msg, user_msg],
1134+
)
1135+
1136+
with patch(
1137+
"google.adk.a2a.converters.event_converter.convert_a2a_message_to_event"
1138+
) as mock_convert:
1139+
mock_event = Mock(spec=Event)
1140+
mock_convert.return_value = mock_event
1141+
1142+
convert_a2a_task_to_event(
1143+
task, "test-author", self.mock_invocation_context
1144+
)
1145+
1146+
# Must be called with the agent message, not the trailing user message
1147+
mock_convert.assert_called_once()
1148+
called_message = mock_convert.call_args[0][0]
1149+
assert called_message.role == _compat.ROLE_AGENT
1150+
assert called_message.message_id == "m1"
1151+
1152+
def test_task_history_fallback_only_user_messages_creates_minimal_event(self):
1153+
"""History with only user messages must produce a minimal event."""
1154+
user_msg = _compat.make_message(
1155+
message_id="m1",
1156+
role=_compat.ROLE_USER,
1157+
parts=[_compat.make_text_part("question")],
1158+
)
1159+
1160+
status = _compat.make_task_status(_compat.TS_SUBMITTED)
1161+
task = _compat.make_task(
1162+
id="task-1",
1163+
status=status,
1164+
context_id="ctx-1",
1165+
history=[user_msg],
1166+
)
1167+
1168+
result = convert_a2a_task_to_event(
1169+
task, "test-author", self.mock_invocation_context
1170+
)
1171+
1172+
# No agent message to convert → minimal event (no content)
1173+
assert result.author == "test-author"
1174+
assert result.content is None

0 commit comments

Comments
 (0)