diff --git a/src/google/adk/a2a/converters/to_adk_event.py b/src/google/adk/a2a/converters/to_adk_event.py index e03f8a597c3..b0e1a1ec826 100644 --- a/src/google/adk/a2a/converters/to_adk_event.py +++ b/src/google/adk/a2a/converters/to_adk_event.py @@ -507,9 +507,10 @@ def convert_a2a_task_to_event( ) if not metadata_fields: metadata_fields = _extract_all_metadata_fields(artifact.metadata) - output_parts, _ = _convert_a2a_parts_to_adk_parts( + output_parts, ids = _convert_a2a_parts_to_adk_parts( artifact_parts, part_converter ) + long_running_function_ids.update(ids) if status_message and ( a2a_task.status.state == _compat.TS_INPUT_REQUIRED or a2a_task.status.state == _compat.TS_AUTH_REQUIRED @@ -576,7 +577,7 @@ def convert_a2a_message_to_event( raise ValueError("A2A message cannot be None") try: - output_parts, _ = _convert_a2a_parts_to_adk_parts( + output_parts, long_running_function_ids = _convert_a2a_parts_to_adk_parts( a2a_message.parts, part_converter ) content_role = _a2a_role_to_content_role(getattr(a2a_message, "role", None)) @@ -586,6 +587,7 @@ def convert_a2a_message_to_event( invocation_context, author, _extract_event_actions(a2a_message.metadata), + long_running_function_ids, content_role=content_role, **metadata_fields, ) @@ -675,7 +677,7 @@ def convert_a2a_artifact_update_to_event( raise ValueError("A2A artifact update cannot be None") try: - output_parts, _ = _convert_a2a_parts_to_adk_parts( + output_parts, long_running_function_ids = _convert_a2a_parts_to_adk_parts( a2a_artifact_update.artifact.parts, part_converter ) metadata_fields = _extract_all_metadata_fields( @@ -686,6 +688,7 @@ def convert_a2a_artifact_update_to_event( invocation_context, author, _extract_event_actions(a2a_artifact_update.artifact.metadata), + long_running_function_ids, partial=not a2a_artifact_update.last_chunk, **metadata_fields, ) diff --git a/tests/unittests/a2a/converters/test_to_adk.py b/tests/unittests/a2a/converters/test_to_adk.py index c1508fc6c0e..ef8dc850203 100644 --- a/tests/unittests/a2a/converters/test_to_adk.py +++ b/tests/unittests/a2a/converters/test_to_adk.py @@ -62,6 +62,64 @@ def _make_a2a_part_for_test(metadata=None): return m +# One wire input per inbound converter, built from a shared A2A message. +_LONG_RUNNING_INBOUND_CONVERTERS = { + "task": ( + lambda message: _compat.make_task( + id="task-1", + context_id="context-1", + kind="task", + status=_compat.make_task_status( + _compat.TS_INPUT_REQUIRED, timestamp="now", message=message + ), + ), + convert_a2a_task_to_event, + ), + "status_update": ( + lambda message: _compat.make_task_status_update_event( + task_id="task-1", + context_id="context-1", + final=False, + status=_compat.make_task_status( + _compat.TS_INPUT_REQUIRED, timestamp="now", message=message + ), + ), + convert_a2a_status_update_to_event, + ), + "task_artifact": ( + lambda message: _compat.make_task( + id="task-1", + context_id="context-1", + kind="task", + status=_compat.make_task_status( + _compat.TS_INPUT_REQUIRED, timestamp="now" + ), + artifacts=[ + _compat.make_artifact( + artifact_id="art-1", parts=list(message.parts) + ) + ], + ), + convert_a2a_task_to_event, + ), + "message": (lambda message: message, convert_a2a_message_to_event), + "artifact_update": ( + lambda message: TaskArtifactUpdateEvent( + task_id="task-1", + context_id="context-1", + artifact=_compat.make_artifact( + artifact_id="art-1", + artifact_type="message", + parts=list(message.parts), + ), + append=True, + last_chunk=True, + ), + convert_a2a_artifact_update_to_event, + ), +} + + class TestToAdk: """Test suite for to_adk functions.""" @@ -770,6 +828,83 @@ def test_convert_a2a_message_to_event_agent_role(self) -> None: assert event.content.role == "model" + @pytest.mark.parametrize( + "converter_key", list(_LONG_RUNNING_INBOUND_CONVERTERS) + ) + def test_long_running_tool_ids_survive_every_inbound_converter( + self, converter_key + ): + """Every inbound converter must surface the ids it recovers.""" + build_input, convert = _LONG_RUNNING_INBOUND_CONVERTERS[converter_key] + a2a_part = _make_a2a_part_for_test({ + _get_adk_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY): True + }) + message = Message( + message_id="m1", role=_compat.ROLE_AGENT, parts=[a2a_part] + ) + mock_part_converter = Mock( + return_value=[ + genai_types.Part( + function_call=genai_types.FunctionCall( + name="wait_for_human_approval", args={}, id="call-1" + ) + ) + ] + ) + + event = convert( + build_input(message), + author="test-author", + invocation_context=self.mock_context, + part_converter=mock_part_converter, + ) + + assert event is not None + assert event.long_running_tool_ids == {"call-1"} + + def test_input_required_task_keeps_its_own_long_running_call(self): + """A pending call must not be replaced by a synthesised one. + + `_create_mock_function_call_for_required_user_input` synthesises a call + under a fresh uuid only when no ids survived. Dropping a real id here + would hand the caller an id that answers nothing. + """ + a2a_part = _make_a2a_part_for_test({ + _get_adk_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY): True + }) + task = _compat.make_task( + id="task-1", + context_id="context-1", + kind="task", + status=_compat.make_task_status( + _compat.TS_INPUT_REQUIRED, timestamp="now" + ), + artifacts=[ + _compat.make_artifact(artifact_id="art-1", parts=[a2a_part]) + ], + ) + mock_part_converter = Mock( + return_value=[ + genai_types.Part( + function_call=genai_types.FunctionCall( + name="wait_for_human_approval", args={}, id="call-1" + ) + ) + ] + ) + + event = convert_a2a_task_to_event( + task, + author="test-author", + invocation_context=self.mock_context, + part_converter=mock_part_converter, + ) + + assert event.long_running_tool_ids == {"call-1"} + assert event.content.parts[0].function_call.name == ( + "wait_for_human_approval" + ) + class TestExtractGenaiMetadata: