From a71dbdf9e24a44f18b23be082ef9cb14d9b03692 Mon Sep 17 00:00:00 2001 From: "Xiang (Sean) Zhou" Date: Wed, 25 Jun 2025 15:31:42 -0700 Subject: [PATCH] chore: Enhance a2a event converter a. fix function call long running id matching logic b. fix error code conversion logic c. add input required and auth required status conversion logic d. add a2a Task/Message to ADK event converter f. set task id and context id from input argument PiperOrigin-RevId: 775860563 --- .../adk/a2a/converters/event_converter.py | 288 +++++++- .../a2a/converters/test_event_converter.py | 699 +++++++++++++++++- 2 files changed, 917 insertions(+), 70 deletions(-) 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"