diff --git a/src/google/adk/a2a/converters/event_converter.py b/src/google/adk/a2a/converters/event_converter.py index 5594c0e6..25183f6b 100644 --- a/src/google/adk/a2a/converters/event_converter.py +++ b/src/google/adk/a2a/converters/event_converter.py @@ -14,7 +14,8 @@ from __future__ import annotations -import datetime +from datetime import datetime +from datetime import timezone import logging from typing import Any from typing import Dict @@ -26,18 +27,24 @@ from a2a.server.events import Event as A2AEvent from a2a.types import Artifact from a2a.types import DataPart from a2a.types import Message +from a2a.types import Part as A2APart from a2a.types import Role +from a2a.types import Task from a2a.types import TaskArtifactUpdateEvent from a2a.types import TaskState from a2a.types import TaskStatus from a2a.types import TaskStatusUpdateEvent from a2a.types import TextPart +from google.genai import types as genai_types from ...agents.invocation_context import InvocationContext from ...events.event import Event +from ...flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME from ...utils.feature_decorator import working_in_progress +from .part_converter import A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY from .part_converter import A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL from .part_converter import A2A_DATA_PART_METADATA_TYPE_KEY +from .part_converter import convert_a2a_part_to_genai_part from .part_converter import convert_genai_part_to_a2a_part from .utils import _get_adk_metadata_key @@ -143,6 +150,8 @@ def _convert_artifact_to_a2a_events( invocation_context: InvocationContext, filename: str, version: int, + task_id: Optional[str] = None, + context_id: Optional[str] = None, ) -> TaskArtifactUpdateEvent: """Converts a new artifact version to an A2A TaskArtifactUpdateEvent. @@ -151,6 +160,7 @@ def _convert_artifact_to_a2a_events( invocation_context: The invocation context. filename: The name of the artifact file. version: The version number of the artifact. + task_id: Optional task ID to use for generated events. If not provided, new UUIDs will be generated. Returns: A TaskArtifactUpdateEvent representing the artifact update. @@ -186,9 +196,9 @@ def _convert_artifact_to_a2a_events( ) return TaskArtifactUpdateEvent( - taskId=str(uuid.uuid4()), + taskId=task_id, append=False, - contextId=invocation_context.session.id, + contextId=context_id, lastChunk=True, artifact=Artifact( artifactId=artifact_id, @@ -210,7 +220,7 @@ def _convert_artifact_to_a2a_events( raise RuntimeError(f"Artifact conversion failed: {e}") from e -def _process_long_running_tool(a2a_part, event: Event) -> None: +def _process_long_running_tool(a2a_part: A2APart, event: Event) -> None: """Processes long-running tool metadata for an A2A part. Args: @@ -220,18 +230,173 @@ def _process_long_running_tool(a2a_part, event: Event) -> None: if ( isinstance(a2a_part.root, DataPart) and event.long_running_tool_ids + and a2a_part.root.metadata and a2a_part.root.metadata.get( _get_adk_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY) ) == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - and a2a_part.root.metadata.get("id") in event.long_running_tool_ids + and a2a_part.root.data.get("id") in event.long_running_tool_ids ): - a2a_part.root.metadata[_get_adk_metadata_key("is_long_running")] = True + a2a_part.root.metadata[ + _get_adk_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) + ] = True @working_in_progress -def convert_event_to_a2a_status_message( - event: Event, invocation_context: InvocationContext +def convert_a2a_task_to_event( + a2a_task: Task, + author: Optional[str] = None, + invocation_context: Optional[InvocationContext] = None, +) -> Event: + """Converts an A2A task to an ADK event. + + Args: + a2a_task: The A2A task to convert. Must not be None. + author: The author of the event. Defaults to "a2a agent" if not provided. + invocation_context: The invocation context containing session information. + If provided, the branch will be set from the context. + + Returns: + An ADK Event object representing the converted task. + + Raises: + ValueError: If a2a_task is None. + RuntimeError: If conversion of the underlying message fails. + """ + if a2a_task is None: + raise ValueError("A2A task cannot be None") + + try: + # Extract message from task status or history + message = None + if a2a_task.status and a2a_task.status.message: + message = a2a_task.status.message + elif a2a_task.history: + message = a2a_task.history[-1] + + # Convert message if available + if message: + try: + return convert_a2a_message_to_event(message, author, invocation_context) + except Exception as e: + logger.error("Failed to convert A2A task message to event: %s", e) + raise RuntimeError(f"Failed to convert task message: {e}") from e + + # Create minimal event if no message is available + return Event( + invocation_id=( + invocation_context.invocation_id + if invocation_context + else str(uuid.uuid4()) + ), + author=author or "a2a agent", + branch=invocation_context.branch if invocation_context else None, + ) + + except Exception as e: + logger.error("Failed to convert A2A task to event: %s", e) + raise + + +@working_in_progress +def convert_a2a_message_to_event( + a2a_message: Message, + author: Optional[str] = None, + invocation_context: Optional[InvocationContext] = None, +) -> Event: + """Converts an A2A message to an ADK event. + + Args: + a2a_message: The A2A message to convert. Must not be None. + author: The author of the event. Defaults to "a2a agent" if not provided. + invocation_context: The invocation context containing session information. + If provided, the branch will be set from the context. + + Returns: + An ADK Event object with converted content and long-running tool metadata. + + Raises: + ValueError: If a2a_message is None. + RuntimeError: If conversion of message parts fails. + """ + if a2a_message is None: + raise ValueError("A2A message cannot be None") + + if not a2a_message.parts: + logger.warning( + "A2A message has no parts, creating event with empty content" + ) + return Event( + invocation_id=( + invocation_context.invocation_id + if invocation_context + else str(uuid.uuid4()) + ), + author=author or "a2a agent", + branch=invocation_context.branch if invocation_context else None, + content=genai_types.Content(role="model", parts=[]), + ) + + try: + parts = [] + long_running_tool_ids = set() + + for a2a_part in a2a_message.parts: + try: + part = convert_a2a_part_to_genai_part(a2a_part) + if part is None: + logger.warning("Failed to convert A2A part, skipping: %s", a2a_part) + continue + + # Check for long-running tools + if ( + a2a_part.root.metadata + and a2a_part.root.metadata.get( + _get_adk_metadata_key( + A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY + ) + ) + is True + ): + long_running_tool_ids.add(part.function_call.id) + + parts.append(part) + + except Exception as e: + logger.error("Failed to convert A2A part: %s, error: %s", a2a_part, e) + # Continue processing other parts instead of failing completely + continue + + if not parts: + logger.warning( + "No parts could be converted from A2A message %s", a2a_message + ) + + return Event( + invocation_id=( + invocation_context.invocation_id + if invocation_context + else str(uuid.uuid4()) + ), + author=author or "a2a agent", + branch=invocation_context.branch if invocation_context else None, + long_running_tool_ids=long_running_tool_ids + if long_running_tool_ids + else None, + content=genai_types.Content( + role="model", + parts=parts, + ), + ) + + except Exception as e: + logger.error("Failed to convert A2A message to event: %s", e) + raise RuntimeError(f"Failed to convert message: {e}") from e + + +@working_in_progress +def convert_event_to_a2a_message( + event: Event, invocation_context: InvocationContext, role: Role = Role.agent ) -> Optional[Message]: """Converts an ADK event to an A2A message. @@ -262,9 +427,7 @@ def convert_event_to_a2a_status_message( _process_long_running_tool(a2a_part, event) if a2a_parts: - return Message( - messageId=str(uuid.uuid4()), role=Role.agent, parts=a2a_parts - ) + return Message(messageId=str(uuid.uuid4()), role=role, parts=a2a_parts) except Exception as e: logger.error("Failed to convert event to status message: %s", e) @@ -274,38 +437,57 @@ def convert_event_to_a2a_status_message( def _create_error_status_event( - event: Event, invocation_context: InvocationContext + event: Event, + invocation_context: InvocationContext, + task_id: Optional[str] = None, + context_id: Optional[str] = None, ) -> TaskStatusUpdateEvent: """Creates a TaskStatusUpdateEvent for error scenarios. Args: event: The ADK event containing error information. invocation_context: The invocation context. + task_id: Optional task ID to use for generated events. + context_id: Optional Context ID to use for generated events. Returns: A TaskStatusUpdateEvent with FAILED state. """ error_message = getattr(event, "error_message", None) or DEFAULT_ERROR_MESSAGE + # Get context metadata and add error code + event_metadata = _get_context_metadata(event, invocation_context) + if event.error_code: + event_metadata[_get_adk_metadata_key("error_code")] = str(event.error_code) + return TaskStatusUpdateEvent( - taskId=str(uuid.uuid4()), - contextId=invocation_context.session.id, - final=False, - metadata=_get_context_metadata(event, invocation_context), + taskId=task_id, + contextId=context_id, + metadata=event_metadata, status=TaskStatus( state=TaskState.failed, message=Message( messageId=str(uuid.uuid4()), role=Role.agent, parts=[TextPart(text=error_message)], + metadata={ + _get_adk_metadata_key("error_code"): str(event.error_code) + } + if event.error_code + else {}, ), - timestamp=datetime.datetime.now().isoformat(), + timestamp=datetime.now(timezone.utc).isoformat(), ), + final=False, ) -def _create_running_status_event( - message: Message, invocation_context: InvocationContext, event: Event +def _create_status_update_event( + message: Message, + invocation_context: InvocationContext, + event: Event, + task_id: Optional[str] = None, + context_id: Optional[str] = None, ) -> TaskStatusUpdateEvent: """Creates a TaskStatusUpdateEvent for running scenarios. @@ -313,32 +495,70 @@ def _create_running_status_event( message: The A2A message to include. invocation_context: The invocation context. event: The ADK event. + task_id: Optional task ID to use for generated events. + context_id: Optional Context ID to use for generated events. + Returns: A TaskStatusUpdateEvent with RUNNING state. """ + status = TaskStatus( + state=TaskState.working, + message=message, + timestamp=datetime.now(timezone.utc).isoformat(), + ) + + if any( + part.root.metadata.get( + _get_adk_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY) + ) + == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + and part.root.metadata.get( + _get_adk_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) + ) + is True + and part.root.data.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME + for part in message.parts + if part.root.metadata + ): + status.state = TaskState.auth_required + elif any( + part.root.metadata.get( + _get_adk_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY) + ) + == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + and part.root.metadata.get( + _get_adk_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) + ) + is True + for part in message.parts + if part.root.metadata + ): + status.state = TaskState.input_required + return TaskStatusUpdateEvent( - taskId=str(uuid.uuid4()), - contextId=invocation_context.session.id, - final=False, - status=TaskStatus( - state=TaskState.working, - message=message, - timestamp=datetime.datetime.now().isoformat(), - ), + taskId=task_id, + contextId=context_id, + status=status, metadata=_get_context_metadata(event, invocation_context), + final=False, ) @working_in_progress def convert_event_to_a2a_events( - event: Event, invocation_context: InvocationContext + event: Event, + invocation_context: InvocationContext, + task_id: Optional[str] = None, + context_id: Optional[str] = None, ) -> List[A2AEvent]: """Converts a GenAI event to a list of A2A events. Args: event: The ADK event to convert. invocation_context: The invocation context. + task_id: Optional task ID to use for generated events. + context_id: Optional Context ID to use for generated events. Returns: A list of A2A events representing the converted ADK event. @@ -358,20 +578,22 @@ def convert_event_to_a2a_events( if event.actions.artifact_delta: for filename, version in event.actions.artifact_delta.items(): artifact_event = _convert_artifact_to_a2a_events( - event, invocation_context, filename, version + event, invocation_context, filename, version, task_id, context_id ) a2a_events.append(artifact_event) # Handle error scenarios if event.error_code: - error_event = _create_error_status_event(event, invocation_context) + error_event = _create_error_status_event( + event, invocation_context, task_id, context_id + ) a2a_events.append(error_event) # Handle regular message content - message = convert_event_to_a2a_status_message(event, invocation_context) + message = convert_event_to_a2a_message(event, invocation_context) if message: - running_event = _create_running_status_event( - message, invocation_context, event + running_event = _create_status_update_event( + message, invocation_context, event, task_id, context_id ) a2a_events.append(running_event) diff --git a/tests/unittests/a2a/converters/test_event_converter.py b/tests/unittests/a2a/converters/test_event_converter.py index 311ffc95..2ba8e26b 100644 --- a/tests/unittests/a2a/converters/test_event_converter.py +++ b/tests/unittests/a2a/converters/test_event_converter.py @@ -20,7 +20,7 @@ import pytest # Skip all tests in this module if Python version is less than 3.10 pytestmark = pytest.mark.skipif( - sys.version_info < (3, 10), reason="A2A tool requires Python 3.10+" + sys.version_info < (3, 10), reason="A2A requires Python 3.10+" ) # Import dependencies with version checking @@ -28,20 +28,21 @@ try: from a2a.types import DataPart from a2a.types import Message from a2a.types import Role + from a2a.types import Task from a2a.types import TaskArtifactUpdateEvent from a2a.types import TaskState from a2a.types import TaskStatusUpdateEvent from google.adk.a2a.converters.event_converter import _convert_artifact_to_a2a_events from google.adk.a2a.converters.event_converter import _create_artifact_id from google.adk.a2a.converters.event_converter import _create_error_status_event - from google.adk.a2a.converters.event_converter import _create_running_status_event + from google.adk.a2a.converters.event_converter import _create_status_update_event from google.adk.a2a.converters.event_converter import _get_adk_metadata_key from google.adk.a2a.converters.event_converter import _get_context_metadata from google.adk.a2a.converters.event_converter import _process_long_running_tool from google.adk.a2a.converters.event_converter import _serialize_metadata_value from google.adk.a2a.converters.event_converter import ARTIFACT_ID_SEPARATOR from google.adk.a2a.converters.event_converter import convert_event_to_a2a_events - from google.adk.a2a.converters.event_converter import convert_event_to_a2a_status_message + from google.adk.a2a.converters.event_converter import convert_event_to_a2a_message from google.adk.a2a.converters.event_converter import DEFAULT_ERROR_MESSAGE from google.adk.a2a.converters.utils import ADK_METADATA_KEY_PREFIX from google.adk.agents.invocation_context import InvocationContext @@ -57,13 +58,14 @@ except ImportError as e: DataPart = DummyTypes() Message = DummyTypes() Role = DummyTypes() + Task = DummyTypes() TaskArtifactUpdateEvent = DummyTypes() TaskState = DummyTypes() TaskStatusUpdateEvent = DummyTypes() _convert_artifact_to_a2a_events = lambda *args: None _create_artifact_id = lambda *args: None _create_error_status_event = lambda *args: None - _create_running_status_event = lambda *args: None + _create_status_update_event = lambda *args: None _get_adk_metadata_key = lambda *args: None _get_context_metadata = lambda *args: None _process_long_running_tool = lambda *args: None @@ -71,7 +73,7 @@ except ImportError as e: ADK_METADATA_KEY_PREFIX = "adk_" ARTIFACT_ID_SEPARATOR = "_" convert_event_to_a2a_events = lambda *args: None - convert_event_to_a2a_status_message = lambda *args: None + convert_event_to_a2a_message = lambda *args: None DEFAULT_ERROR_MESSAGE = "error" InvocationContext = DummyTypes() Event = DummyTypes() @@ -233,6 +235,8 @@ class TestEventConverter: """Test successful artifact delta conversion.""" filename = "test.txt" version = 1 + task_id = "test-task-id" + context_id = "test-context-id" mock_artifact_part = Mock() # Create a proper Part that Pydantic will accept @@ -246,11 +250,17 @@ class TestEventConverter: mock_convert_part.return_value = mock_converted_part result = _convert_artifact_to_a2a_events( - self.mock_event, self.mock_invocation_context, filename, version + self.mock_event, + self.mock_invocation_context, + filename, + version, + task_id, + context_id, ) assert isinstance(result, TaskArtifactUpdateEvent) - assert result.contextId == self.mock_invocation_context.session.id + assert result.taskId == task_id + assert result.contextId == context_id assert result.append is False assert result.lastChunk is True @@ -265,7 +275,7 @@ class TestEventConverter: """Test artifact delta conversion with empty filename.""" with pytest.raises(ValueError) as exc_info: _convert_artifact_to_a2a_events( - self.mock_event, self.mock_invocation_context, "", 1 + self.mock_event, self.mock_invocation_context, "", 1, "", "" ) assert "Filename cannot be empty" in str(exc_info.value) @@ -273,7 +283,7 @@ class TestEventConverter: """Test artifact delta conversion with negative version.""" with pytest.raises(ValueError) as exc_info: _convert_artifact_to_a2a_events( - self.mock_event, self.mock_invocation_context, "test.txt", -1 + self.mock_event, self.mock_invocation_context, "test.txt", -1, "", "" ) assert "Version must be non-negative" in str(exc_info.value) @@ -293,7 +303,12 @@ class TestEventConverter: with pytest.raises(RuntimeError) as exc_info: _convert_artifact_to_a2a_events( - self.mock_event, self.mock_invocation_context, filename, version + self.mock_event, + self.mock_invocation_context, + filename, + version, + "", + "", ) assert "Failed to convert artifact part" in str(exc_info.value) @@ -302,6 +317,8 @@ class TestEventConverter: mock_a2a_part = Mock() mock_data_part = Mock(spec=DataPart) mock_data_part.metadata = {"adk_type": "function_call", "id": "tool-123"} + mock_data_part.data = Mock() + mock_data_part.data.get = Mock(return_value="tool-123") mock_a2a_part.root = mock_data_part self.mock_event.long_running_tool_ids = {"tool-123"} @@ -315,7 +332,11 @@ class TestEventConverter: "google.adk.a2a.converters.event_converter.A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL", "function_call", ), + patch( + "google.adk.a2a.converters.event_converter._get_adk_metadata_key" + ) as mock_get_key, ): + mock_get_key.side_effect = lambda key: f"adk_{key}" _process_long_running_tool(mock_a2a_part, self.mock_event) @@ -327,6 +348,8 @@ class TestEventConverter: mock_a2a_part = Mock() mock_data_part = Mock(spec=DataPart) mock_data_part.metadata = {"adk_type": "function_call", "id": "tool-456"} + mock_data_part.data = Mock() + mock_data_part.data.get = Mock(return_value="tool-456") mock_a2a_part.root = mock_data_part self.mock_event.long_running_tool_ids = {"tool-123"} # Different ID @@ -340,7 +363,11 @@ class TestEventConverter: "google.adk.a2a.converters.event_converter.A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL", "function_call", ), + patch( + "google.adk.a2a.converters.event_converter._get_adk_metadata_key" + ) as mock_get_key, ): + mock_get_key.side_effect = lambda key: f"adk_{key}" _process_long_running_tool(mock_a2a_part, self.mock_event) @@ -368,7 +395,7 @@ class TestEventConverter: mock_content.parts = [mock_part] self.mock_event.content = mock_content - result = convert_event_to_a2a_status_message( + result = convert_event_to_a2a_message( self.mock_event, self.mock_invocation_context ) @@ -382,7 +409,7 @@ class TestEventConverter: """Test event to message conversion with no content.""" self.mock_event.content = None - result = convert_event_to_a2a_status_message( + result = convert_event_to_a2a_message( self.mock_event, self.mock_invocation_context ) @@ -394,7 +421,7 @@ class TestEventConverter: mock_content.parts = [] self.mock_event.content = mock_content - result = convert_event_to_a2a_status_message( + result = convert_event_to_a2a_message( self.mock_event, self.mock_invocation_context ) @@ -403,17 +430,50 @@ class TestEventConverter: def test_convert_event_to_message_none_event(self): """Test event to message conversion with None event.""" with pytest.raises(ValueError) as exc_info: - convert_event_to_a2a_status_message(None, self.mock_invocation_context) + convert_event_to_a2a_message(None, self.mock_invocation_context) assert "Event cannot be None" in str(exc_info.value) def test_convert_event_to_message_none_context(self): """Test event to message conversion with None context.""" with pytest.raises(ValueError) as exc_info: - convert_event_to_a2a_status_message(self.mock_event, None) + convert_event_to_a2a_message(self.mock_event, None) assert "Invocation context cannot be None" in str(exc_info.value) + @patch( + "google.adk.a2a.converters.event_converter.convert_genai_part_to_a2a_part" + ) @patch("google.adk.a2a.converters.event_converter.uuid.uuid4") - @patch("google.adk.a2a.converters.event_converter.datetime.datetime") + def test_convert_event_to_message_with_custom_role( + self, mock_uuid, mock_convert_part + ): + """Test event to message conversion with custom role.""" + mock_uuid.return_value = "test-uuid" + + mock_part = Mock() + # Create a proper Part that Pydantic will accept + from a2a.types import Part + from a2a.types import TextPart + + text_part = TextPart(text="test message") + mock_a2a_part = Part(root=text_part) + mock_convert_part.return_value = mock_a2a_part + + mock_content = Mock() + mock_content.parts = [mock_part] + self.mock_event.content = mock_content + + result = convert_event_to_a2a_message( + self.mock_event, self.mock_invocation_context, role=Role.user + ) + + assert isinstance(result, Message) + assert result.messageId == "test-uuid" + assert result.role == Role.user + assert len(result.parts) == 1 + assert result.parts[0].root.text == "test message" + + @patch("google.adk.a2a.converters.event_converter.uuid.uuid4") + @patch("google.adk.a2a.converters.event_converter.datetime") def test_create_error_status_event(self, mock_datetime, mock_uuid): """Test creation of error status event.""" mock_uuid.return_value = "test-uuid" @@ -422,18 +482,21 @@ class TestEventConverter: ) self.mock_event.error_message = "Test error message" + task_id = "test-task-id" + context_id = "test-context-id" result = _create_error_status_event( - self.mock_event, self.mock_invocation_context + self.mock_event, self.mock_invocation_context, task_id, context_id ) assert isinstance(result, TaskStatusUpdateEvent) - assert result.contextId == self.mock_invocation_context.session.id + assert result.taskId == task_id + assert result.contextId == context_id assert result.status.state == TaskState.failed assert result.status.message.parts[0].root.text == "Test error message" @patch("google.adk.a2a.converters.event_converter.uuid.uuid4") - @patch("google.adk.a2a.converters.event_converter.datetime.datetime") + @patch("google.adk.a2a.converters.event_converter.datetime") def test_create_error_status_event_no_message(self, mock_datetime, mock_uuid): """Test creation of error status event without error message.""" mock_uuid.return_value = "test-uuid" @@ -441,13 +504,16 @@ class TestEventConverter: "2023-01-01T00:00:00" ) + task_id = "test-task-id" + context_id = "test-context-id" + result = _create_error_status_event( - self.mock_event, self.mock_invocation_context + self.mock_event, self.mock_invocation_context, task_id, context_id ) assert result.status.message.parts[0].root.text == DEFAULT_ERROR_MESSAGE - @patch("google.adk.a2a.converters.event_converter.datetime.datetime") + @patch("google.adk.a2a.converters.event_converter.datetime") def test_create_running_status_event(self, mock_datetime): """Test creation of running status event.""" mock_datetime.now.return_value.isoformat.return_value = ( @@ -455,13 +521,21 @@ class TestEventConverter: ) mock_message = Mock(spec=Message) + mock_message.parts = [] + task_id = "test-task-id" + context_id = "test-context-id" - result = _create_running_status_event( - mock_message, self.mock_invocation_context, self.mock_event + result = _create_status_update_event( + mock_message, + self.mock_invocation_context, + self.mock_event, + task_id, + context_id, ) assert isinstance(result, TaskStatusUpdateEvent) - assert result.contextId == self.mock_invocation_context.session.id + assert result.taskId == task_id + assert result.contextId == context_id assert result.status.state == TaskState.working assert result.status.message == mock_message @@ -469,11 +543,11 @@ class TestEventConverter: "google.adk.a2a.converters.event_converter._convert_artifact_to_a2a_events" ) @patch( - "google.adk.a2a.converters.event_converter.convert_event_to_a2a_status_message" + "google.adk.a2a.converters.event_converter.convert_event_to_a2a_message" ) @patch("google.adk.a2a.converters.event_converter._create_error_status_event") @patch( - "google.adk.a2a.converters.event_converter._create_running_status_event" + "google.adk.a2a.converters.event_converter._create_status_update_event" ) def test_convert_event_to_a2a_events_full_scenario( self, @@ -514,14 +588,14 @@ class TestEventConverter: # Verify artifact delta events assert mock_convert_artifact.call_count == 2 - # Verify error event + # Verify error event - now called with task_id and context_id parameters mock_create_error.assert_called_once_with( - self.mock_event, self.mock_invocation_context + self.mock_event, self.mock_invocation_context, None, None ) - # Verify running event + # Verify running event - now called with task_id and context_id parameters mock_create_running.assert_called_once_with( - mock_message, self.mock_invocation_context, self.mock_event + mock_message, self.mock_invocation_context, self.mock_event, None, None ) # Verify result contains all events @@ -552,7 +626,7 @@ class TestEventConverter: assert "Invocation context cannot be None" in str(exc_info.value) @patch( - "google.adk.a2a.converters.event_converter.convert_event_to_a2a_status_message" + "google.adk.a2a.converters.event_converter.convert_event_to_a2a_message" ) def test_convert_event_to_a2a_events_message_only(self, mock_convert_message): """Test event to A2A events conversion with message only.""" @@ -560,7 +634,7 @@ class TestEventConverter: mock_convert_message.return_value = mock_message with patch( - "google.adk.a2a.converters.event_converter._create_running_status_event" + "google.adk.a2a.converters.event_converter._create_status_update_event" ) as mock_create_running: mock_running_event = Mock() mock_create_running.return_value = mock_running_event @@ -571,15 +645,23 @@ class TestEventConverter: assert len(result) == 1 assert result[0] == mock_running_event + # Verify the function is called with task_id and context_id parameters + mock_create_running.assert_called_once_with( + mock_message, + self.mock_invocation_context, + self.mock_event, + None, + None, + ) @patch("google.adk.a2a.converters.event_converter.logger") def test_convert_event_to_a2a_events_exception_handling(self, mock_logger): - """Test exception handling in event to A2A events conversion.""" - # Make convert_event_to_a2a_status_message raise an exception + """Test exception handling in convert_event_to_a2a_events.""" + # Make convert_event_to_a2a_message raise an exception with patch( - "google.adk.a2a.converters.event_converter.convert_event_to_a2a_status_message" - ) as mock_convert: - mock_convert.side_effect = Exception("Conversion failed") + "google.adk.a2a.converters.event_converter.convert_event_to_a2a_message" + ) as mock_convert_message: + mock_convert_message.side_effect = Exception("Test exception") with pytest.raises(Exception): convert_event_to_a2a_events( @@ -587,3 +669,546 @@ class TestEventConverter: ) mock_logger.error.assert_called_once() + + def test_convert_event_to_a2a_events_with_task_id_and_context_id(self): + """Test event to A2A events conversion with specific task_id and context_id.""" + # Setup message + mock_message = Mock(spec=Message) + mock_message.parts = [] + + with patch( + "google.adk.a2a.converters.event_converter.convert_event_to_a2a_message" + ) as mock_convert_message: + mock_convert_message.return_value = mock_message + + with patch( + "google.adk.a2a.converters.event_converter._create_status_update_event" + ) as mock_create_running: + mock_running_event = Mock() + mock_create_running.return_value = mock_running_event + + task_id = "custom-task-id" + context_id = "custom-context-id" + + result = convert_event_to_a2a_events( + self.mock_event, self.mock_invocation_context, task_id, context_id + ) + + assert len(result) == 1 + assert result[0] == mock_running_event + + # Verify the function is called with the specific task_id and context_id + mock_create_running.assert_called_once_with( + mock_message, + self.mock_invocation_context, + self.mock_event, + task_id, + context_id, + ) + + def test_convert_event_to_a2a_events_with_artifacts_and_custom_ids(self): + """Test event to A2A events conversion with artifacts and custom IDs.""" + # Setup artifact delta + self.mock_event.actions.artifact_delta = {"file1.txt": 1} + + # Setup message + mock_message = Mock(spec=Message) + mock_message.parts = [] + + with patch( + "google.adk.a2a.converters.event_converter.convert_event_to_a2a_message" + ) as mock_convert_message: + mock_convert_message.return_value = mock_message + + with patch( + "google.adk.a2a.converters.event_converter._convert_artifact_to_a2a_events" + ) as mock_convert_artifact: + mock_artifact_event = Mock() + mock_convert_artifact.return_value = mock_artifact_event + + with patch( + "google.adk.a2a.converters.event_converter._create_status_update_event" + ) as mock_create_running: + mock_running_event = Mock() + mock_create_running.return_value = mock_running_event + + task_id = "custom-task-id" + context_id = "custom-context-id" + + result = convert_event_to_a2a_events( + self.mock_event, self.mock_invocation_context, task_id, context_id + ) + + assert len(result) == 2 # 1 artifact + 1 status + assert mock_artifact_event in result + assert mock_running_event in result + + # Verify artifact conversion is called with custom IDs + mock_convert_artifact.assert_called_once_with( + self.mock_event, + self.mock_invocation_context, + "file1.txt", + 1, + task_id, + context_id, + ) + + # Verify status update is called with custom IDs + mock_create_running.assert_called_once_with( + mock_message, + self.mock_invocation_context, + self.mock_event, + task_id, + context_id, + ) + + def test_create_status_update_event_with_auth_required_state(self): + """Test creation of status update event with auth_required state.""" + from a2a.types import DataPart + from a2a.types import Part + + # Create a mock message with a part that triggers auth_required state + mock_message = Mock(spec=Message) + mock_part = Mock() + mock_data_part = Mock(spec=DataPart) + mock_data_part.metadata = { + "adk_type": "function_call", + "adk_is_long_running": True, + } + mock_data_part.data = Mock() + mock_data_part.data.get = Mock(return_value="request_euc") + mock_part.root = mock_data_part + mock_message.parts = [mock_part] + + task_id = "test-task-id" + context_id = "test-context-id" + + with patch( + "google.adk.a2a.converters.event_converter.datetime" + ) as mock_datetime: + mock_datetime.now.return_value.isoformat.return_value = ( + "2023-01-01T00:00:00" + ) + + with ( + patch( + "google.adk.a2a.converters.event_converter.A2A_DATA_PART_METADATA_TYPE_KEY", + "type", + ), + patch( + "google.adk.a2a.converters.event_converter.A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL", + "function_call", + ), + patch( + "google.adk.a2a.converters.event_converter.A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY", + "is_long_running", + ), + patch( + "google.adk.a2a.converters.event_converter.REQUEST_EUC_FUNCTION_CALL_NAME", + "request_euc", + ), + patch( + "google.adk.a2a.converters.event_converter._get_adk_metadata_key" + ) as mock_get_key, + ): + mock_get_key.side_effect = lambda key: f"adk_{key}" + + result = _create_status_update_event( + mock_message, + self.mock_invocation_context, + self.mock_event, + task_id, + context_id, + ) + + assert isinstance(result, TaskStatusUpdateEvent) + assert result.taskId == task_id + assert result.contextId == context_id + assert result.status.state == TaskState.auth_required + + def test_create_status_update_event_with_input_required_state(self): + """Test creation of status update event with input_required state.""" + from a2a.types import DataPart + from a2a.types import Part + + # Create a mock message with a part that triggers input_required state + mock_message = Mock(spec=Message) + mock_part = Mock() + mock_data_part = Mock(spec=DataPart) + mock_data_part.metadata = { + "adk_type": "function_call", + "adk_is_long_running": True, + } + mock_data_part.data = Mock() + mock_data_part.data.get = Mock(return_value="some_other_function") + mock_part.root = mock_data_part + mock_message.parts = [mock_part] + + task_id = "test-task-id" + context_id = "test-context-id" + + with patch( + "google.adk.a2a.converters.event_converter.datetime" + ) as mock_datetime: + mock_datetime.now.return_value.isoformat.return_value = ( + "2023-01-01T00:00:00" + ) + + with ( + patch( + "google.adk.a2a.converters.event_converter.A2A_DATA_PART_METADATA_TYPE_KEY", + "type", + ), + patch( + "google.adk.a2a.converters.event_converter.A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL", + "function_call", + ), + patch( + "google.adk.a2a.converters.event_converter.A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY", + "is_long_running", + ), + patch( + "google.adk.a2a.converters.event_converter.REQUEST_EUC_FUNCTION_CALL_NAME", + "request_euc", + ), + patch( + "google.adk.a2a.converters.event_converter._get_adk_metadata_key" + ) as mock_get_key, + ): + mock_get_key.side_effect = lambda key: f"adk_{key}" + + result = _create_status_update_event( + mock_message, + self.mock_invocation_context, + self.mock_event, + task_id, + context_id, + ) + + assert isinstance(result, TaskStatusUpdateEvent) + assert result.taskId == task_id + assert result.contextId == context_id + assert result.status.state == TaskState.input_required + + +class TestA2AToEventConverters: + """Test suite for A2A to Event conversion functions.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_invocation_context = Mock(spec=InvocationContext) + self.mock_invocation_context.branch = "test-branch" + self.mock_invocation_context.invocation_id = "test-invocation-id" + + def test_convert_a2a_task_to_event_with_status_message(self): + """Test converting A2A task with status message.""" + from google.adk.a2a.converters.event_converter import convert_a2a_task_to_event + + # Create mock message and task + mock_message = Mock(spec=Message) + mock_status = Mock() + mock_status.message = mock_message + mock_task = Mock(spec=Task) + mock_task.status = mock_status + mock_task.history = [] + + # Mock the convert_a2a_message_to_event function + with patch( + "google.adk.a2a.converters.event_converter.convert_a2a_message_to_event" + ) as mock_convert_message: + mock_event = Mock(spec=Event) + mock_event.invocation_id = "test-invocation-id" + mock_convert_message.return_value = mock_event + + result = convert_a2a_task_to_event( + mock_task, "test-author", self.mock_invocation_context + ) + + # Verify the message converter was called with correct parameters + mock_convert_message.assert_called_once_with( + mock_message, "test-author", self.mock_invocation_context + ) + assert result == mock_event + assert result.invocation_id == "test-invocation-id" + + def test_convert_a2a_task_to_event_with_history_message(self): + """Test converting A2A task with history message when no status message.""" + from google.adk.a2a.converters.event_converter import convert_a2a_task_to_event + + # Create mock message and task + mock_message = Mock(spec=Message) + mock_task = Mock(spec=Task) + mock_task.status = None + mock_task.history = [mock_message] + + # Mock the convert_a2a_message_to_event function + with patch( + "google.adk.a2a.converters.event_converter.convert_a2a_message_to_event" + ) as mock_convert_message: + mock_event = Mock(spec=Event) + mock_event.invocation_id = "test-invocation-id" + mock_convert_message.return_value = mock_event + + result = convert_a2a_task_to_event(mock_task, "test-author") + + # Verify the message converter was called with correct parameters + mock_convert_message.assert_called_once_with( + mock_message, "test-author", None + ) + assert result == mock_event + + def test_convert_a2a_task_to_event_no_message(self): + """Test converting A2A task with no message.""" + from google.adk.a2a.converters.event_converter import convert_a2a_task_to_event + + # Create mock task with no message + mock_task = Mock(spec=Task) + mock_task.status = None + mock_task.history = [] + + result = convert_a2a_task_to_event( + mock_task, "test-author", self.mock_invocation_context + ) + + # Verify minimal event was created with correct invocation_id + assert result.author == "test-author" + assert result.branch == "test-branch" + assert result.invocation_id == "test-invocation-id" + + @patch("google.adk.a2a.converters.event_converter.uuid.uuid4") + def test_convert_a2a_task_to_event_default_author(self, mock_uuid): + """Test converting A2A task with default author and no invocation context.""" + from google.adk.a2a.converters.event_converter import convert_a2a_task_to_event + + # Create mock task with no message + mock_task = Mock(spec=Task) + mock_task.status = None + mock_task.history = [] + + # Mock UUID generation + mock_uuid.return_value = "generated-uuid" + + result = convert_a2a_task_to_event(mock_task) + + # Verify default author was used and UUID was generated for invocation_id + assert result.author == "a2a agent" + assert result.branch is None + assert result.invocation_id == "generated-uuid" + + def test_convert_a2a_task_to_event_none_task(self): + """Test converting None task raises ValueError.""" + from google.adk.a2a.converters.event_converter import convert_a2a_task_to_event + + with pytest.raises(ValueError, match="A2A task cannot be None"): + convert_a2a_task_to_event(None) + + def test_convert_a2a_task_to_event_message_conversion_error(self): + """Test error handling when message conversion fails.""" + from google.adk.a2a.converters.event_converter import convert_a2a_task_to_event + + # Create mock message and task + mock_message = Mock(spec=Message) + mock_status = Mock() + mock_status.message = mock_message + mock_task = Mock(spec=Task) + mock_task.status = mock_status + mock_task.history = [] + + # Mock the convert_a2a_message_to_event function to raise an exception + with patch( + "google.adk.a2a.converters.event_converter.convert_a2a_message_to_event" + ) as mock_convert_message: + mock_convert_message.side_effect = Exception("Conversion failed") + + with pytest.raises(RuntimeError, match="Failed to convert task message"): + convert_a2a_task_to_event(mock_task, "test-author") + + @patch( + "google.adk.a2a.converters.event_converter.convert_a2a_part_to_genai_part" + ) + def test_convert_a2a_message_to_event_success(self, mock_convert_part): + """Test successful conversion of A2A message to event.""" + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + from google.genai import types as genai_types + + # Create mock parts and message with valid genai Part + mock_a2a_part = Mock() + mock_genai_part = genai_types.Part(text="test content") + mock_convert_part.return_value = mock_genai_part + + mock_message = Mock(spec=Message) + mock_message.parts = [mock_a2a_part] + + result = convert_a2a_message_to_event( + mock_message, "test-author", self.mock_invocation_context + ) + + # Verify conversion was successful + assert result.author == "test-author" + assert result.branch == "test-branch" + assert result.invocation_id == "test-invocation-id" + assert result.content.role == "model" + assert len(result.content.parts) == 1 + assert result.content.parts[0].text == "test content" + mock_convert_part.assert_called_once_with(mock_a2a_part) + + @patch( + "google.adk.a2a.converters.event_converter.convert_a2a_part_to_genai_part" + ) + def test_convert_a2a_message_to_event_with_long_running_tools( + self, mock_convert_part + ): + """Test conversion with long-running tools by mocking the entire flow.""" + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + + # Create mock parts and message + mock_a2a_part = Mock() + mock_message = Mock(spec=Message) + mock_message.parts = [mock_a2a_part] + + # Mock the part conversion to return None to simulate long-running tool detection logic + mock_convert_part.return_value = None + + # Patch the long-running tool detection since the main logic is in the actual conversion + with patch( + "google.adk.a2a.converters.event_converter.logger" + ) as mock_logger: + result = convert_a2a_message_to_event( + mock_message, "test-author", self.mock_invocation_context + ) + + # Verify basic conversion worked + assert result.author == "test-author" + assert result.invocation_id == "test-invocation-id" + assert result.content.role == "model" + # Parts will be empty since conversion returned None, but that's expected for this test + + def test_convert_a2a_message_to_event_empty_parts(self): + """Test conversion with empty parts list.""" + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + + mock_message = Mock(spec=Message) + mock_message.parts = [] + + result = convert_a2a_message_to_event( + mock_message, "test-author", self.mock_invocation_context + ) + + # Verify event was created with empty parts + assert result.author == "test-author" + assert result.invocation_id == "test-invocation-id" + assert result.content.role == "model" + assert len(result.content.parts) == 0 + + def test_convert_a2a_message_to_event_none_message(self): + """Test converting None message raises ValueError.""" + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + + with pytest.raises(ValueError, match="A2A message cannot be None"): + convert_a2a_message_to_event(None) + + @patch( + "google.adk.a2a.converters.event_converter.convert_a2a_part_to_genai_part" + ) + def test_convert_a2a_message_to_event_part_conversion_fails( + self, mock_convert_part + ): + """Test handling when part conversion returns None.""" + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + + # Setup mock to return None (conversion failure) + mock_a2a_part = Mock() + mock_convert_part.return_value = None + + mock_message = Mock(spec=Message) + mock_message.parts = [mock_a2a_part] + + result = convert_a2a_message_to_event( + mock_message, "test-author", self.mock_invocation_context + ) + + # Verify event was created but with no parts + assert result.author == "test-author" + assert result.invocation_id == "test-invocation-id" + assert result.content.role == "model" + assert len(result.content.parts) == 0 + + @patch( + "google.adk.a2a.converters.event_converter.convert_a2a_part_to_genai_part" + ) + def test_convert_a2a_message_to_event_part_conversion_exception( + self, mock_convert_part + ): + """Test handling when part conversion raises exception.""" + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + from google.genai import types as genai_types + + # Setup mock to raise exception + mock_a2a_part1 = Mock() + mock_a2a_part2 = Mock() + mock_genai_part = genai_types.Part(text="successful conversion") + + mock_convert_part.side_effect = [ + Exception("Conversion failed"), # First part fails + mock_genai_part, # Second part succeeds + ] + + mock_message = Mock(spec=Message) + mock_message.parts = [mock_a2a_part1, mock_a2a_part2] + + result = convert_a2a_message_to_event( + mock_message, "test-author", self.mock_invocation_context + ) + + # Verify event was created with only the successfully converted part + assert result.author == "test-author" + assert result.invocation_id == "test-invocation-id" + assert result.content.role == "model" + assert len(result.content.parts) == 1 + assert result.content.parts[0].text == "successful conversion" + + @patch( + "google.adk.a2a.converters.event_converter.convert_a2a_part_to_genai_part" + ) + def test_convert_a2a_message_to_event_missing_tool_id( + self, mock_convert_part + ): + """Test handling of message conversion when part conversion fails.""" + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + + # Create mock parts and message + mock_a2a_part = Mock() + mock_message = Mock(spec=Message) + mock_message.parts = [mock_a2a_part] + + # Mock the part conversion to return None + mock_convert_part.return_value = None + + result = convert_a2a_message_to_event( + mock_message, "test-author", self.mock_invocation_context + ) + + # Verify basic conversion worked + assert result.author == "test-author" + assert result.invocation_id == "test-invocation-id" + assert result.content.role == "model" + # Parts will be empty since conversion returned None + assert len(result.content.parts) == 0 + + @patch("google.adk.a2a.converters.event_converter.uuid.uuid4") + def test_convert_a2a_message_to_event_default_author(self, mock_uuid): + """Test conversion with default author and no invocation context.""" + from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event + + mock_message = Mock(spec=Message) + mock_message.parts = [] + + # Mock UUID generation + mock_uuid.return_value = "generated-uuid" + + result = convert_a2a_message_to_event(mock_message) + + # Verify default author was used and UUID was generated for invocation_id + assert result.author == "a2a agent" + assert result.branch is None + assert result.invocation_id == "generated-uuid"