From f9b865dfdd07515d97ffa47d8e2b491e62357411 Mon Sep 17 00:00:00 2001 From: Yatsuiii Date: Wed, 2 Sep 2026 12:37:20 +0530 Subject: [PATCH 1/2] fix(a2a): return long_running_tool_ids from the message and artifact update converters _convert_a2a_parts_to_adk_parts recovers long-running function call ids from the is_long_running part marker for all four inbound converters. convert_a2a_message_to_event and convert_a2a_artifact_update_to_event discarded that result and never passed it to _create_event, so the returned Event had long_running_tool_ids=None, while convert_a2a_task_to_event and convert_a2a_status_update_to_event populate it from the same marker. Both are defaults on A2aRemoteAgentConfig, so on the new integration extension path a long-running call arrived looking complete and _should_pause_invocation did not pause. Adds coverage across all four converters. Fixes #6988 --- src/google/adk/a2a/converters/to_adk_event.py | 6 +- tests/unittests/a2a/converters/test_to_adk.py | 76 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/google/adk/a2a/converters/to_adk_event.py b/src/google/adk/a2a/converters/to_adk_event.py index e03f8a597c3..15a67ca2466 100644 --- a/src/google/adk/a2a/converters/to_adk_event.py +++ b/src/google/adk/a2a/converters/to_adk_event.py @@ -576,7 +576,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 +586,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 +676,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 +687,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..cd6aeecaf4e 100644 --- a/tests/unittests/a2a/converters/test_to_adk.py +++ b/tests/unittests/a2a/converters/test_to_adk.py @@ -62,6 +62,48 @@ 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, + ), + "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 +812,40 @@ 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"} + class TestExtractGenaiMetadata: From 2a32cc9f69963bd27525256ea60fb2ab66b3cb70 Mon Sep 17 00:00:00 2001 From: Yatsuiii Date: Fri, 4 Sep 2026 13:40:10 +0530 Subject: [PATCH 2/2] fix(a2a): keep long-running ids on the task artifact branch convert_a2a_task_to_event has two branches. The status branch already merged the ids recovered by _convert_a2a_parts_to_adk_parts; the artifact branch discarded them, so a task carrying the long-running call in an artifact still lost it. That also reached _create_mock_function_call_for_required_user_input, which synthesises a call under a fresh uuid only when no ids survived. An input-required task with a genuine pending call was handed a fabricated id rather than its own. The parametrised task case never covered this: its fixture builds a task with a status message and no artifacts, so it took the status branch that was already correct. Adds a task_artifact case that routes through the artifact branch, and a test pinning that a real call is not replaced. --- src/google/adk/a2a/converters/to_adk_event.py | 3 +- tests/unittests/a2a/converters/test_to_adk.py | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/google/adk/a2a/converters/to_adk_event.py b/src/google/adk/a2a/converters/to_adk_event.py index 15a67ca2466..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 diff --git a/tests/unittests/a2a/converters/test_to_adk.py b/tests/unittests/a2a/converters/test_to_adk.py index cd6aeecaf4e..ef8dc850203 100644 --- a/tests/unittests/a2a/converters/test_to_adk.py +++ b/tests/unittests/a2a/converters/test_to_adk.py @@ -86,6 +86,22 @@ def _make_a2a_part_for_test(metadata=None): ), 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( @@ -846,6 +862,49 @@ def test_long_running_tool_ids_survive_every_inbound_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: