Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/google/adk/a2a/converters/event_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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,
),
)
Expand Down
5 changes: 4 additions & 1 deletion src/google/adk/a2a/converters/to_adk_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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()
Expand All @@ -199,7 +201,7 @@ def _create_event(
),
content=(
genai_types.Content(
role="model",
role=role,
parts=output_parts,
)
if output_parts
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions src/google/adk/a2a/converters/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
138 changes: 138 additions & 0 deletions tests/unittests/a2a/converters/test_event_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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"
Expand All @@ -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
55 changes: 55 additions & 0 deletions tests/unittests/a2a/converters/test_to_adk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading