chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
@@ -0,0 +1,13 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Round trip tests for ADK and A2A event converters."""
from __future__ import annotations
from typing import Dict
from unittest.mock import Mock
from a2a.types import TaskArtifactUpdateEvent
from a2a.types import TaskStatusUpdateEvent
from google.adk.a2a.converters.from_adk_event import convert_event_to_a2a_events
from google.adk.a2a.converters.from_adk_event import create_error_status_event
from google.adk.a2a.converters.to_adk_event import convert_a2a_artifact_update_to_event
from google.adk.a2a.converters.to_adk_event import convert_a2a_status_update_to_event
from google.adk.agents.invocation_context import InvocationContext
from google.adk.events.event import Event
from google.genai import types as genai_types
def test_round_trip_text_event():
original_event = Event(
invocation_id="test_invocation",
author="test_agent",
branch="main",
content=genai_types.Content(
role="model",
parts=[genai_types.Part.from_text(text="Hello world!")],
),
partial=False,
)
agents_artifacts: Dict[str, str] = {}
a2a_events = convert_event_to_a2a_events(
event=original_event,
agents_artifacts=agents_artifacts,
task_id="task1",
context_id="context1",
)
assert len(a2a_events) == 1
a2a_event = a2a_events[0]
assert isinstance(a2a_event, TaskArtifactUpdateEvent)
mock_context = Mock(
spec=InvocationContext, invocation_id="test_invocation", branch="main"
)
restored_event = convert_a2a_artifact_update_to_event(
a2a_artifact_update=a2a_event,
author="test_agent",
invocation_context=mock_context,
)
assert restored_event is not None
assert restored_event.author == original_event.author
assert restored_event.invocation_id == original_event.invocation_id
assert restored_event.branch == original_event.branch
assert restored_event.partial == original_event.partial
assert len(restored_event.content.parts) == len(original_event.content.parts)
assert (
restored_event.content.parts[0].text
== original_event.content.parts[0].text
)
def test_round_trip_error_status_event():
original_event = Event(
invocation_id="error_inv",
author="error_agent",
branch="main",
error_message="Test Error",
)
a2a_event = create_error_status_event(
event=original_event,
task_id="task2",
context_id="ctx2",
)
assert isinstance(a2a_event, TaskStatusUpdateEvent)
mock_context = Mock(
spec=InvocationContext, invocation_id="error_inv", branch="main"
)
restored_event = convert_a2a_status_update_to_event(
a2a_status_update=a2a_event,
author="error_agent",
invocation_context=mock_context,
)
assert restored_event is not None
assert restored_event.author == original_event.author
assert restored_event.invocation_id == original_event.invocation_id
assert restored_event.branch == original_event.branch
assert len(restored_event.content.parts) == 1
assert restored_event.content.parts[0].text == "Test Error"
def test_round_trip_function_call_event():
original_event = Event(
invocation_id="test_invocation",
author="test_agent",
branch="main",
content=genai_types.Content(
role="model",
parts=[
genai_types.Part.from_function_call(
name="my_function",
args={"arg1": "value1"},
)
],
),
partial=False,
)
agents_artifacts: Dict[str, str] = {}
a2a_events = convert_event_to_a2a_events(
event=original_event,
agents_artifacts=agents_artifacts,
task_id="task1",
context_id="context1",
)
assert len(a2a_events) == 1
a2a_event = a2a_events[0]
mock_context = Mock(
spec=InvocationContext, invocation_id="test_invocation", branch="main"
)
restored_event = convert_a2a_artifact_update_to_event(
a2a_artifact_update=a2a_event,
author="test_agent",
invocation_context=mock_context,
)
assert restored_event is not None
assert restored_event.author == original_event.author
assert restored_event.invocation_id == original_event.invocation_id
assert restored_event.branch == original_event.branch
assert len(restored_event.content.parts) == 1
assert restored_event.content.parts[0].function_call.name == "my_function"
assert restored_event.content.parts[0].function_call.args == {
"arg1": "value1"
}
def test_round_trip_function_response_event():
original_event = Event(
invocation_id="test_invocation",
author="test_agent",
branch="main",
content=genai_types.Content(
role="user",
parts=[
genai_types.Part.from_function_response(
name="my_function",
response={"result": "success"},
)
],
),
partial=False,
)
agents_artifacts: Dict[str, str] = {}
a2a_events = convert_event_to_a2a_events(
event=original_event,
agents_artifacts=agents_artifacts,
task_id="task1",
context_id="context1",
)
assert len(a2a_events) == 1
a2a_event = a2a_events[0]
mock_context = Mock(
spec=InvocationContext, invocation_id="test_invocation", branch="main"
)
restored_event = convert_a2a_artifact_update_to_event(
a2a_artifact_update=a2a_event,
author="test_agent",
invocation_context=mock_context,
)
assert restored_event is not None
assert restored_event.author == original_event.author
assert restored_event.invocation_id == original_event.invocation_id
assert restored_event.branch == original_event.branch
assert len(restored_event.content.parts) == 1
assert restored_event.content.parts[0].function_response.name == "my_function"
assert restored_event.content.parts[0].function_response.response == {
"result": "success"
}
@@ -0,0 +1,210 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from unittest.mock import Mock
from a2a.types import TaskArtifactUpdateEvent
from a2a.types import TaskStatusUpdateEvent
from google.adk.a2a import _compat
from google.adk.a2a.converters.from_adk_event import convert_event_to_a2a_events
from google.adk.events import event_actions
from google.adk.events.event import Event
from google.genai import types as genai_types
import pytest
class TestFromAdk:
"""Test suite for from_adk functions."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_event = Mock(spec=Event)
self.mock_event.id = "test-event-id"
self.mock_event.invocation_id = "test-invocation-id"
self.mock_event.author = "test-author"
self.mock_event.branch = None
self.mock_event.content = None
self.mock_event.error_code = None
self.mock_event.error_message = None
self.mock_event.grounding_metadata = None
self.mock_event.citation_metadata = None
self.mock_event.custom_metadata = None
self.mock_event.usage_metadata = None
self.mock_event.actions = None
self.mock_event.partial = True
self.mock_event.long_running_tool_ids = None
def test_convert_event_to_a2a_events_artifact_update(self):
"""Test conversion of event to TaskArtifactUpdateEvent."""
# Setup event with content
self.mock_event.content = genai_types.Content(
parts=[genai_types.Part(text="hello")], role="model"
)
self.mock_event.author = "agent-1"
agents_artifacts = {}
# Mock part converter to return a standard text part
mock_a2a_part = _compat.make_text_part("hello")
mock_convert_part = Mock(return_value=[mock_a2a_part])
result = convert_event_to_a2a_events(
self.mock_event,
agents_artifacts,
task_id="task-123",
context_id="context-456",
part_converter=mock_convert_part,
)
assert len(result) == 1
assert isinstance(result[0], TaskArtifactUpdateEvent)
assert result[0].task_id == "task-123"
assert result[0].context_id == "context-456"
assert result[0].artifact.parts == [mock_a2a_part]
assert "agent-1" in agents_artifacts # Artifact ID should be stored
def test_convert_event_to_a2a_events_error(self):
"""Test conversion of event with error to TaskStatusUpdateEvent."""
self.mock_event.error_code = "ERR001"
self.mock_event.error_message = "Something went wrong"
agents_artifacts = {}
result = convert_event_to_a2a_events(
self.mock_event,
agents_artifacts,
task_id="task-123",
context_id="context-456",
)
# Should not return any artifact events
assert len(result) == 0
def test_convert_event_to_a2a_events_none_event(self):
"""Test convert_event_to_a2a_events with None event."""
with pytest.raises(ValueError, match="Event cannot be None"):
convert_event_to_a2a_events(None, {})
def test_convert_event_to_a2a_events_none_artifacts(self):
"""Test convert_event_to_a2a_events with None agents_artifacts."""
with pytest.raises(ValueError, match="Agents artifacts cannot be None"):
convert_event_to_a2a_events(self.mock_event, None)
def test_convert_event_to_a2a_events_with_actions(self):
"""Test conversion of event with actions to TaskStatusUpdateEvent."""
self.mock_event.actions = event_actions.EventActions()
self.mock_event.actions.artifact_delta["image"] = 0
agents_artifacts = {}
result = convert_event_to_a2a_events(
self.mock_event,
agents_artifacts,
task_id="task-123",
context_id="context-456",
)
assert len(result) == 1
assert isinstance(result[0], TaskStatusUpdateEvent)
assert result[0].task_id == "task-123"
assert result[0].context_id == "context-456"
metadata = result[0].status.message.metadata
assert "adk_actions" in metadata
assert metadata["adk_actions"]["artifactDelta"] == {"image": 0}
class TestSerializeValue:
"""Tests for _serialize_value preserving JSON-native types."""
def setup_method(self) -> None:
from google.adk.a2a.converters.from_adk_event import _serialize_value
self.serialize = _serialize_value
def test_dict_preserved(self) -> None:
value = {"key": "val", "nested": {"a": 1}}
result = self.serialize(value)
assert result == value
assert isinstance(result, dict)
def test_list_preserved(self) -> None:
value = [1, "two", {"three": 3}]
result = self.serialize(value)
assert result == value
assert isinstance(result, list)
def test_int_preserved(self) -> None:
result = self.serialize(42)
assert result == 42
assert isinstance(result, int)
def test_float_preserved(self) -> None:
result = self.serialize(3.14)
assert result == 3.14
assert isinstance(result, float)
def test_bool_preserved(self) -> None:
assert self.serialize(True) is True
assert self.serialize(False) is False
def test_string_preserved(self) -> None:
assert self.serialize("hello") == "hello"
def test_none_returns_none(self) -> None:
assert self.serialize(None) is None
def test_non_json_type_stringified(self) -> None:
"""Non-JSON-native types should still be converted to str."""
from datetime import datetime
dt = datetime(2025, 1, 1)
result = self.serialize(dt)
assert isinstance(result, str)
def test_nested_non_json_value_in_dict_stringified(self) -> None:
"""A non-JSON-native value nested in a dict is stringified."""
from datetime import datetime
dt = datetime(2025, 1, 1)
value = {"when": dt, "count": 1, "label": "x"}
result = self.serialize(value)
assert result == {"when": str(dt), "count": 1, "label": "x"}
assert isinstance(result["when"], str)
assert isinstance(result["count"], int)
assert isinstance(result["label"], str)
def test_nested_non_json_value_in_list_stringified(self) -> None:
"""A non-JSON-native value nested in a list is stringified."""
from datetime import datetime
dt = datetime(2025, 1, 1)
value = [dt, 1, "x"]
result = self.serialize(value)
assert result == [str(dt), 1, "x"]
assert isinstance(result[0], str)
assert isinstance(result[1], int)
assert isinstance(result[2], str)
def test_non_string_dict_key_stringified(self) -> None:
"""Non-string dict keys are stringified so the result is JSON-encodable."""
from datetime import datetime
dt = datetime(2025, 1, 1)
value = {dt: "when", 1: "one", "label": "x"}
result = self.serialize(value)
assert result == {str(dt): "when", "1": "one", "label": "x"}
assert all(isinstance(k, str) for k in result)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,459 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import Mock
from a2a.server.agent_execution import RequestContext
from google.adk.a2a import _compat
from google.adk.a2a.converters.request_converter import _get_user_id
from google.adk.a2a.converters.request_converter import convert_a2a_request_to_agent_run_request
from google.adk.runners import RunConfig
from google.genai import types as genai_types
import pytest
class TestGetUserId:
"""Test cases for _get_user_id function."""
def test_get_user_id_from_call_context(self):
"""Test getting user ID from call context when auth is enabled."""
# Arrange
mock_user = Mock()
mock_user.user_name = "authenticated_user"
mock_call_context = Mock()
mock_call_context.user = mock_user
request = Mock(spec=RequestContext)
request.call_context = mock_call_context
request.context_id = "test_context"
# Act
result = _get_user_id(request)
# Assert
assert result == "authenticated_user"
def test_get_user_id_from_context_when_no_call_context(self):
"""Test getting user ID from context when call context is not available."""
# Arrange
request = Mock(spec=RequestContext)
request.call_context = None
request.context_id = "test_context"
# Act
result = _get_user_id(request)
# Assert
assert result == "A2A_USER_test_context"
def test_get_user_id_from_context_when_call_context_has_no_user(self):
"""Test getting user ID from context when call context has no user."""
# Arrange
mock_call_context = Mock()
mock_call_context.user = None
request = Mock(spec=RequestContext)
request.call_context = mock_call_context
request.context_id = "test_context"
# Act
result = _get_user_id(request)
# Assert
assert result == "A2A_USER_test_context"
def test_get_user_id_with_empty_user_name(self):
"""Test getting user ID when user exists but user_name is empty."""
# Arrange
mock_user = Mock()
mock_user.user_name = ""
mock_call_context = Mock()
mock_call_context.user = mock_user
request = Mock(spec=RequestContext)
request.call_context = mock_call_context
request.context_id = "test_context"
# Act
result = _get_user_id(request)
# Assert
assert result == "A2A_USER_test_context"
def test_get_user_id_with_none_user_name(self):
"""Test getting user ID when user exists but user_name is None."""
# Arrange
mock_user = Mock()
mock_user.user_name = None
mock_call_context = Mock()
mock_call_context.user = mock_user
request = Mock(spec=RequestContext)
request.call_context = mock_call_context
request.context_id = "test_context"
# Act
result = _get_user_id(request)
# Assert
assert result == "A2A_USER_test_context"
def test_get_user_id_with_none_context_id(self):
"""Test getting user ID when context_id is None."""
# Arrange
request = Mock(spec=RequestContext)
request.call_context = None
request.context_id = None
# Act
result = _get_user_id(request)
# Assert
assert result == "A2A_USER_None"
class TestConvertA2aRequestToAgentRunRequest:
"""Test cases for convert_a2a_request_to_agent_run_request function."""
def test_convert_a2a_request_basic(self):
"""Test basic conversion of A2A request to ADK AgentRunRequest."""
# Arrange
mock_part1 = Mock()
mock_part2 = Mock()
mock_message = Mock()
mock_message.parts = [mock_part1, mock_part2]
mock_user = Mock()
mock_user.user_name = "test_user"
mock_call_context = Mock()
mock_call_context.user = mock_user
request = Mock(spec=RequestContext)
request.message = mock_message
request.context_id = "test_context_123"
request.call_context = mock_call_context
request.metadata = {"test_key": "test_value"}
# Create proper genai_types.Part objects instead of mocks
mock_genai_part1 = genai_types.Part(text="test part 1")
mock_genai_part2 = genai_types.Part(text="test part 2")
mock_convert_part = Mock()
mock_convert_part.side_effect = [mock_genai_part1, mock_genai_part2]
# Act
result = convert_a2a_request_to_agent_run_request(
request, mock_convert_part
)
# Assert
assert result is not None
assert result.user_id == "test_user"
assert result.session_id == "test_context_123"
assert isinstance(result.new_message, genai_types.Content)
assert result.new_message.role == "user"
assert result.new_message.parts == [mock_genai_part1, mock_genai_part2]
assert isinstance(result.run_config, RunConfig)
assert result.run_config.custom_metadata == {
"a2a_metadata": {"test_key": "test_value"}
}
# Verify calls
assert mock_convert_part.call_count == 2
mock_convert_part.assert_any_call(mock_part1)
mock_convert_part.assert_any_call(mock_part2)
def test_convert_a2a_request_multiple_parts(self):
"""Test basic conversion of A2A request to ADK AgentRunRequest."""
# Arrange
mock_part1 = Mock()
mock_part2 = Mock()
mock_message = Mock()
mock_message.parts = [mock_part1, mock_part2]
mock_user = Mock()
mock_user.user_name = "test_user"
mock_call_context = Mock()
mock_call_context.user = mock_user
request = Mock(spec=RequestContext)
request.message = mock_message
request.context_id = "test_context_123"
request.call_context = mock_call_context
request.metadata = {"test_key": "test_value"}
# Create proper genai_types.Part objects instead of mocks
mock_genai_part1 = genai_types.Part(text="test part 1")
mock_genai_part2 = genai_types.Part(text="test part 2")
mock_convert_part = Mock()
mock_convert_part.side_effect = [mock_genai_part1, mock_genai_part2]
# Act
result = convert_a2a_request_to_agent_run_request(
request, mock_convert_part
)
# Assert
assert result is not None
assert result.user_id == "test_user"
assert result.session_id == "test_context_123"
assert isinstance(result.new_message, genai_types.Content)
assert result.new_message.role == "user"
assert result.new_message.parts == [
mock_genai_part1,
mock_genai_part2,
]
assert isinstance(result.run_config, RunConfig)
assert result.run_config.custom_metadata == {
"a2a_metadata": {"test_key": "test_value"}
}
# Verify calls
assert mock_convert_part.call_count == 2
mock_convert_part.assert_any_call(mock_part1)
mock_convert_part.assert_any_call(mock_part2)
def test_convert_a2a_request_normalizes_proto_struct_metadata(self):
"""Request metadata is normalized to a plain dict across SDK versions."""
# Build metadata in the native shape of the active SDK (proto Struct on 1.x,
# plain dict on 0.3.x) by writing onto a real a2a Message.
message_with_meta = _compat.make_message(
message_id="m1", role=_compat.ROLE_USER, parts=[]
)
_compat.set_struct_metadata(
message_with_meta, {"test_key": "test_value", "n": 1}
)
mock_message = Mock()
mock_message.parts = []
request = Mock(spec=RequestContext)
request.message = mock_message
request.context_id = "ctx"
request.call_context = None
request.metadata = message_with_meta.metadata
result = convert_a2a_request_to_agent_run_request(request, Mock())
stored = result.run_config.custom_metadata["a2a_metadata"]
# Must be a plain dict (not a proto Struct) regardless of SDK version.
assert isinstance(stored, dict)
assert stored["test_key"] == "test_value"
# Numbers may come back as float on 1.x (proto Struct) -> tolerate both.
assert float(stored["n"]) == 1.0
def test_convert_a2a_request_empty_metadata_omitted(self):
"""Empty request metadata must not add an ``a2a_metadata`` entry."""
empty_meta_msg = _compat.make_message(
message_id="m1", role=_compat.ROLE_USER, parts=[]
)
mock_message = Mock()
mock_message.parts = []
request = Mock(spec=RequestContext)
request.message = mock_message
request.context_id = "ctx"
request.call_context = None
request.metadata = empty_meta_msg.metadata
result = convert_a2a_request_to_agent_run_request(request, Mock())
assert "a2a_metadata" not in result.run_config.custom_metadata
def test_convert_a2a_request_no_message_raises_error(self):
"""Test that conversion raises ValueError when message is None."""
# Arrange
request = Mock(spec=RequestContext)
request.message = None
# Act & Assert
with pytest.raises(ValueError, match="Request message cannot be None"):
convert_a2a_request_to_agent_run_request(request)
def test_convert_a2a_request_empty_parts(self):
"""Test conversion with empty parts list."""
# Arrange
mock_message = Mock()
mock_message.parts = []
mock_convert_part = Mock()
request = Mock(spec=RequestContext)
request.message = mock_message
request.context_id = "test_context_123"
request.call_context = None
request.metadata = {}
# Act
result = convert_a2a_request_to_agent_run_request(
request, mock_convert_part
)
# Assert
assert result is not None
assert result.user_id == "A2A_USER_test_context_123"
assert result.session_id == "test_context_123"
assert isinstance(result.new_message, genai_types.Content)
assert result.new_message.role == "user"
assert result.new_message.parts == []
assert isinstance(result.run_config, RunConfig)
# Verify convert_part wasn't called
mock_convert_part.assert_not_called()
def test_convert_a2a_request_none_context_id(self):
"""Test conversion when context_id is None."""
# Arrange
mock_part = Mock()
mock_message = Mock()
mock_message.parts = [mock_part]
request = Mock(spec=RequestContext)
request.message = mock_message
request.context_id = None
request.call_context = None
request.metadata = {}
# Create proper genai_types.Part object instead of mock
mock_genai_part = genai_types.Part(text="test part")
mock_convert_part = Mock()
mock_convert_part.return_value = mock_genai_part
# Act
result = convert_a2a_request_to_agent_run_request(
request, mock_convert_part
)
# Assert
assert result is not None
assert result.user_id == "A2A_USER_None"
assert result.session_id is None
assert isinstance(result.new_message, genai_types.Content)
assert result.new_message.role == "user"
assert result.new_message.parts == [mock_genai_part]
assert isinstance(result.run_config, RunConfig)
def test_convert_a2a_request_no_auth(self):
"""Test conversion when no authentication is available."""
# Arrange
mock_part = Mock()
mock_message = Mock()
mock_message.parts = [mock_part]
request = Mock(spec=RequestContext)
request.message = mock_message
request.context_id = "session_123"
request.call_context = None
request.metadata = {}
# Create proper genai_types.Part object instead of mock
mock_genai_part = genai_types.Part(text="test part")
mock_convert_part = Mock()
mock_convert_part.return_value = mock_genai_part
# Act
result = convert_a2a_request_to_agent_run_request(
request, mock_convert_part
)
# Assert
assert result is not None
assert result.user_id == "A2A_USER_session_123"
assert result.session_id == "session_123"
assert isinstance(result.new_message, genai_types.Content)
assert result.new_message.role == "user"
assert result.new_message.parts == [mock_genai_part]
assert isinstance(result.run_config, RunConfig)
class TestIntegration:
"""Integration test cases combining both functions."""
def test_end_to_end_conversion_with_auth_user(self):
"""Test end-to-end conversion with authenticated user."""
# Arrange
mock_user = Mock()
mock_user.user_name = "auth_user"
mock_call_context = Mock()
mock_call_context.user = mock_user
mock_part = Mock()
mock_message = Mock()
mock_message.parts = [mock_part]
request = Mock(spec=RequestContext)
request.call_context = mock_call_context
request.message = mock_message
request.context_id = "mysession"
request.metadata = {}
# Create proper genai_types.Part object instead of mock
mock_genai_part = genai_types.Part(text="test part")
mock_convert_part = Mock()
mock_convert_part.return_value = mock_genai_part
# Act
result = convert_a2a_request_to_agent_run_request(
request, mock_convert_part
)
# Assert
assert result is not None
assert result.user_id == "auth_user" # Should use authenticated user
assert result.session_id == "mysession"
assert isinstance(result.new_message, genai_types.Content)
assert result.new_message.role == "user"
assert result.new_message.parts == [mock_genai_part]
assert isinstance(result.run_config, RunConfig)
def test_end_to_end_conversion_with_fallback_user(self):
"""Test end-to-end conversion with fallback user ID."""
# Arrange
mock_part = Mock()
mock_message = Mock()
mock_message.parts = [mock_part]
request = Mock(spec=RequestContext)
request.call_context = None
request.message = mock_message
request.context_id = "test_session_456"
request.metadata = {}
# Create proper genai_types.Part object instead of mock
mock_genai_part = genai_types.Part(text="test part")
mock_convert_part = Mock()
mock_convert_part.return_value = mock_genai_part
# Act
result = convert_a2a_request_to_agent_run_request(
request, mock_convert_part
)
# Assert
assert result is not None
assert (
result.user_id == "A2A_USER_test_session_456"
) # Should fall back to context ID
assert result.session_id == "test_session_456"
assert isinstance(result.new_message, genai_types.Content)
assert result.new_message.role == "user"
assert result.new_message.parts == [mock_genai_part]
assert isinstance(result.run_config, RunConfig)
@@ -0,0 +1,682 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import json
from unittest.mock import Mock
from a2a.types import Message
from a2a.types import Part as A2APart
from a2a.types import Task
from a2a.types import TaskArtifactUpdateEvent
from google.adk.a2a import _compat
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_END_TAG
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_START_TAG
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_TEXT_MIME_TYPE
from google.adk.a2a.converters.to_adk_event import convert_a2a_artifact_update_to_event
from google.adk.a2a.converters.to_adk_event import convert_a2a_message_to_event
from google.adk.a2a.converters.to_adk_event import convert_a2a_status_update_to_event
from google.adk.a2a.converters.to_adk_event import convert_a2a_task_to_event
from google.adk.a2a.converters.to_adk_event import MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_AUTH
from google.adk.a2a.converters.to_adk_event import MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_INPUT
from google.adk.a2a.converters.utils import _get_adk_metadata_key
from google.adk.agents.invocation_context import InvocationContext
from google.genai import types as genai_types
import pytest
def _make_a2a_part_for_test(metadata=None):
"""Returns a real proto Part on 1.x, or Mock(spec=A2APart) on 0.3."""
if _compat.IS_A2A_V1:
p = _compat.make_text_part("test")
if metadata:
_compat.set_part_metadata(p, metadata)
return p
else:
from unittest.mock import Mock
from a2a.types import TextPart
m = Mock(spec=A2APart)
m.root = Mock(spec=TextPart)
m.root.metadata = metadata or {}
return m
class TestToAdk:
"""Test suite for to_adk functions."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_context = Mock(spec=InvocationContext)
self.mock_context.invocation_id = "test-invocation"
self.mock_context.branch = "test-branch"
def test_convert_a2a_message_to_event_success(self):
"""Test successful conversion of A2A message to Event."""
a2a_part = _make_a2a_part_for_test({})
message = Message(
message_id="msg-1", role=_compat.ROLE_USER, parts=[a2a_part]
)
mock_genai_part = genai_types.Part.from_text(text="hello")
mock_part_converter = Mock(return_value=[mock_genai_part])
event = convert_a2a_message_to_event(
message,
author="test-author",
invocation_context=self.mock_context,
part_converter=mock_part_converter,
)
assert event.author == "test-author"
assert event.invocation_id == "test-invocation"
assert event.branch == "test-branch"
assert len(event.content.parts) == 1
assert event.content.parts[0] == mock_genai_part
def test_convert_a2a_message_to_event_none(self):
"""Test convert_a2a_message_to_event with None."""
with pytest.raises(ValueError, match="A2A message cannot be None"):
convert_a2a_message_to_event(None)
def test_convert_a2a_message_to_event_restores_actions_from_metadata(self):
"""Test A2A message conversion restores ADK actions metadata."""
a2a_part = _make_a2a_part_for_test({})
message = Message(
message_id="msg-1",
role=_compat.ROLE_USER,
parts=[a2a_part],
metadata={
_get_adk_metadata_key("actions"): {
"stateDelta": {"saved_key": "saved-value"}
}
},
)
mock_genai_part = genai_types.Part.from_text(text="hello")
mock_part_converter = Mock(return_value=[mock_genai_part])
event = convert_a2a_message_to_event(
message,
author="test-author",
invocation_context=self.mock_context,
part_converter=mock_part_converter,
)
assert event.actions.state_delta == {"saved_key": "saved-value"}
assert event.content is not None
assert event.content.parts[0] == mock_genai_part
def test_convert_a2a_message_to_event_returns_action_only_event(self):
"""Test A2A message conversion returns action-only events."""
message = Message(
message_id="msg-1",
role=_compat.ROLE_USER,
parts=[],
metadata={
_get_adk_metadata_key("actions"): {
"stateDelta": {"saved_key": "saved-value"}
}
},
)
event = convert_a2a_message_to_event(
message,
author="test-author",
invocation_context=self.mock_context,
part_converter=Mock(),
)
assert event is not None
assert event.actions.state_delta == {"saved_key": "saved-value"}
assert event.content is None
def test_convert_a2a_task_to_event_success(self):
"""Test successful conversion of A2A task to Event."""
a2a_part = _make_a2a_part_for_test({})
task = Task(
id="task-1",
status=_compat.make_task_status(
_compat.TS_SUBMITTED, timestamp="2024-01-01T00:00:00Z"
),
context_id="context-1",
history=[
Message(
message_id="msg-1", role=_compat.ROLE_AGENT, parts=[a2a_part]
)
],
artifacts=[
_compat.make_artifact(
artifact_id="art-1", artifact_type="message", parts=[a2a_part]
)
],
)
mock_genai_part = genai_types.Part.from_text(text="task artifact text")
mock_part_converter = Mock(return_value=[mock_genai_part])
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=mock_part_converter,
)
assert event.author == "test-author"
assert event.invocation_id == "test-invocation"
assert len(event.content.parts) == 1
assert event.content.parts[0] == mock_genai_part
def test_convert_a2a_task_to_event_returns_action_only_event(self):
"""Test A2A task conversion returns action-only events."""
task = Task(
id="task-1",
status=_compat.make_task_status(
_compat.TS_SUBMITTED, timestamp="2024-01-01T00:00:00Z"
),
context_id="context-1",
artifacts=[
_compat.make_artifact(
artifact_id="art-1",
artifact_type="message",
parts=[],
metadata={
_get_adk_metadata_key("actions"): {
"stateDelta": {"saved_key": "saved-value"}
}
},
)
],
)
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=Mock(),
)
assert event is not None
assert event.actions.state_delta == {"saved_key": "saved-value"}
assert event.content is None
def test_convert_a2a_task_to_event_merges_actions_across_artifacts(self):
"""Test task conversion merges actions across artifact metadata."""
task = Task(
id="task-1",
status=_compat.make_task_status(
_compat.TS_SUBMITTED, timestamp="2024-01-01T00:00:00Z"
),
context_id="context-1",
artifacts=[
_compat.make_artifact(
artifact_id="art-1",
artifact_type="message",
parts=[],
metadata={
_get_adk_metadata_key("actions"): {
"stateDelta": {"first_key": "first-value"}
}
},
),
_compat.make_artifact(
artifact_id="art-2",
artifact_type="message",
parts=[],
metadata={},
),
],
)
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=Mock(),
)
assert event is not None
assert event.actions.state_delta == {"first_key": "first-value"}
assert event.content is None
def test_convert_a2a_task_to_event_overwrites_nested_state_delta_values(self):
"""Test task conversion preserves top-level state overwrite semantics."""
task = Task(
id="task-1",
status=_compat.make_task_status(
_compat.TS_SUBMITTED, timestamp="2024-01-01T00:00:00Z"
),
context_id="context-1",
artifacts=[
_compat.make_artifact(
artifact_id="art-1",
artifact_type="message",
parts=[],
metadata={
_get_adk_metadata_key("actions"): {
"stateDelta": {
"settings": {
"theme": "light",
"language": "en",
}
}
}
},
),
_compat.make_artifact(
artifact_id="art-2",
artifact_type="message",
parts=[],
metadata={
_get_adk_metadata_key("actions"): {
"stateDelta": {"settings": {"theme": "dark"}}
}
},
),
],
)
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=Mock(),
)
assert event is not None
assert event.actions.state_delta == {"settings": {"theme": "dark"}}
assert event.content is None
def test_convert_a2a_task_to_event_merges_status_and_artifact_actions(self):
"""Test task conversion merges status and artifact actions."""
a2a_part = _make_a2a_part_for_test({})
task = Task(
id="task-1",
status=_compat.make_task_status(
_compat.TS_INPUT_REQUIRED,
timestamp="2024-01-01T00:00:00Z",
message=Message(
message_id="msg-1",
role=_compat.ROLE_AGENT,
parts=[a2a_part],
metadata={
_get_adk_metadata_key("actions"): {
"transferToAgent": "agent-2"
}
},
),
),
context_id="context-1",
artifacts=[
_compat.make_artifact(
artifact_id="art-1",
artifact_type="message",
parts=[],
metadata={
_get_adk_metadata_key("actions"): {
"stateDelta": {"saved_key": "saved-value"}
}
},
)
],
)
mock_genai_part = genai_types.Part.from_text(text="need input")
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=Mock(return_value=[mock_genai_part]),
)
assert event is not None
assert event.actions.state_delta == {"saved_key": "saved-value"}
assert event.actions.transfer_to_agent == "agent-2"
assert event.content is not None
assert (
event.content.parts[0].function_call.name
== MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_INPUT
)
assert (
event.content.parts[0].function_call.args["input_required"]
== "need input"
)
def test_convert_a2a_task_to_event_auth_required_uses_auth_args_key(self):
"""Test auth-required state populates the function call with auth args."""
a2a_part = _make_a2a_part_for_test({})
task = _compat.make_task(
id="task-1",
context_id="context-1",
kind="task",
status=_compat.make_task_status(
_compat.TS_AUTH_REQUIRED,
timestamp="now",
message=Message(
message_id="m1",
role=_compat.ROLE_AGENT,
parts=[a2a_part],
),
),
)
mock_genai_part = genai_types.Part.from_text(text="need auth")
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=Mock(return_value=[mock_genai_part]),
)
assert event is not None
assert event.content is not None
assert (
event.content.parts[0].function_call.name
== MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_AUTH
)
# auth_required state should populate the auth_required arg key, not
# input_required.
assert (
event.content.parts[0].function_call.args["auth_required"]
== "need auth"
)
assert "input_required" not in event.content.parts[0].function_call.args
def test_convert_a2a_task_to_event_multiple_parts_replaces_last_text(self):
"""Test converting A2A task with multiple text parts, only replacing the last text."""
part1 = _make_a2a_part_for_test({})
part2 = _make_a2a_part_for_test({})
task = _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(
message_id="m1",
role=_compat.ROLE_AGENT,
parts=[part1, part2],
),
),
)
mock_genai_part_1 = genai_types.Part.from_text(text="Part 1")
mock_genai_part_2 = genai_types.Part.from_text(text="Part 2")
part_converter_mock = Mock()
part_converter_mock.side_effect = [[mock_genai_part_1], [mock_genai_part_2]]
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=part_converter_mock,
)
assert event is not None
assert event.content is not None
assert len(event.content.parts) == 2
assert event.content.parts[0].text == "Part 1"
assert (
event.content.parts[1].function_call.name
== MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_INPUT
)
def test_convert_a2a_task_to_event_no_text_parts(self):
"""Test converting A2A task with no text parts should not inject function call."""
# A real non-text (data) part; converter output is mocked below.
part1 = _compat.make_data_part(data={"placeholder": 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",
message=Message(
message_id="m1",
role=_compat.ROLE_AGENT,
parts=[part1],
),
),
)
mock_image_part = genai_types.Part(
inline_data=genai_types.Blob(mime_type="image/jpeg", data=b"fake")
)
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=Mock(return_value=[mock_image_part]),
)
assert event is not None
assert event.content is not None
assert event.content.parts == [mock_image_part]
def test_convert_a2a_task_to_event_data_part_input_required(self):
"""Input-required prompt carried in a data part becomes a function call."""
# A real non-text (data) part; converter output is mocked below.
part1 = _compat.make_data_part(data={"placeholder": 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",
message=Message(
message_id="m1",
role=_compat.ROLE_AGENT,
parts=[part1],
),
),
)
prompt = {
"id": "abc123",
"text": "Please confirm this action. Do you want to continue?",
}
data_part_json = json.dumps({"data": prompt, "kind": "data"}).encode(
"utf-8"
)
mock_data_blob_part = genai_types.Part(
inline_data=genai_types.Blob(
mime_type=A2A_DATA_PART_TEXT_MIME_TYPE,
data=A2A_DATA_PART_START_TAG
+ data_part_json
+ A2A_DATA_PART_END_TAG,
)
)
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=Mock(return_value=[mock_data_blob_part]),
)
assert event is not None
assert event.content is not None
assert (
event.content.parts[0].function_call.name
== MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_INPUT
)
assert event.content.parts[0].function_call.args["input_required"] == prompt
assert event.long_running_tool_ids
def test_convert_a2a_task_to_event_data_part_malformed_json(self):
"""A malformed data-part blob is left untouched (no crash, no fc)."""
# A real non-text (data) part; converter output is mocked below.
part1 = _compat.make_data_part(data={"placeholder": 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",
message=Message(
message_id="m1",
role=_compat.ROLE_AGENT,
parts=[part1],
),
),
)
mock_bad_blob_part = genai_types.Part(
inline_data=genai_types.Blob(
mime_type=A2A_DATA_PART_TEXT_MIME_TYPE,
data=A2A_DATA_PART_START_TAG + b"not-json" + A2A_DATA_PART_END_TAG,
)
)
event = convert_a2a_task_to_event(
task,
author="test-author",
invocation_context=self.mock_context,
part_converter=Mock(return_value=[mock_bad_blob_part]),
)
assert event is not None
assert event.content is not None
assert event.content.parts == [mock_bad_blob_part]
assert not event.long_running_tool_ids
def test_convert_a2a_status_update_to_event_success(self):
"""Test successful conversion of A2A status update to Event."""
a2a_part = _make_a2a_part_for_test({
_get_adk_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY): True
})
update = _compat.make_task_status_update_event(
task_id="task-1",
status=_compat.make_task_status(
_compat.TS_INPUT_REQUIRED,
timestamp="now",
message=Message(
message_id="m1",
role=_compat.ROLE_AGENT,
parts=[a2a_part],
),
),
context_id="context-1",
final=False,
)
mock_genai_part = genai_types.Part(
function_call=genai_types.FunctionCall(
name="status update text", args={"arg": "value"}, id="call-1"
)
)
mock_part_converter = Mock(return_value=[mock_genai_part])
event = convert_a2a_status_update_to_event(
update,
author="test-author",
invocation_context=self.mock_context,
part_converter=mock_part_converter,
)
assert event.author == "test-author"
assert event.invocation_id == "test-invocation"
assert len(event.content.parts) == 1
assert event.content.parts[0] == mock_genai_part
def test_convert_a2a_status_update_to_event_none(self):
"""Test convert_a2a_status_update_to_event with None."""
with pytest.raises(ValueError, match="A2A status update cannot be None"):
convert_a2a_status_update_to_event(None)
def test_convert_a2a_artifact_update_to_event_success(self):
"""Test successful conversion of A2A artifact update to Event."""
a2a_part = _make_a2a_part_for_test({})
update = TaskArtifactUpdateEvent(
task_id="task-1",
artifact=_compat.make_artifact(
artifact_id="art-1", artifact_type="message", parts=[a2a_part]
),
append=True,
context_id="context-1",
last_chunk=False,
)
mock_genai_part = genai_types.Part.from_text(text="artifact chunk text")
mock_part_converter = Mock(return_value=[mock_genai_part])
event = convert_a2a_artifact_update_to_event(
update,
author="test-author",
invocation_context=self.mock_context,
part_converter=mock_part_converter,
)
assert event.author == "test-author"
assert event.invocation_id == "test-invocation"
assert event.partial is True
assert len(event.content.parts) == 1
assert event.content.parts[0] == mock_genai_part
def test_convert_a2a_artifact_update_to_event_none(self):
"""Test convert_a2a_artifact_update_to_event with None."""
with pytest.raises(ValueError, match="A2A artifact update cannot be None"):
convert_a2a_artifact_update_to_event(None)
def test_convert_a2a_message_to_event_user_role(self) -> None:
"""Test that A2A user role maps to GenAI content role 'user'."""
a2a_part = _make_a2a_part_for_test({})
message = Message(
message_id="msg-1", role=_compat.ROLE_USER, parts=[a2a_part]
)
mock_genai_part = genai_types.Part.from_text(text="hello from user")
mock_part_converter = Mock(return_value=[mock_genai_part])
event = convert_a2a_message_to_event(
message,
author="user",
invocation_context=self.mock_context,
part_converter=mock_part_converter,
)
assert event.content.role == "user"
def test_convert_a2a_message_to_event_agent_role(self) -> None:
"""Test that A2A agent role maps to GenAI content role 'model'."""
a2a_part = _make_a2a_part_for_test({})
message = Message(
message_id="msg-1", role=_compat.ROLE_AGENT, parts=[a2a_part]
)
mock_genai_part = genai_types.Part.from_text(text="hello from agent")
mock_part_converter = Mock(return_value=[mock_genai_part])
event = convert_a2a_message_to_event(
message,
author="test-agent",
invocation_context=self.mock_context,
part_converter=mock_part_converter,
)
assert event.content.role == "model"
@@ -0,0 +1,204 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from google.adk.a2a.converters.utils import _from_a2a_context_id
from google.adk.a2a.converters.utils import _get_adk_metadata_key
from google.adk.a2a.converters.utils import _to_a2a_context_id
from google.adk.a2a.converters.utils import ADK_CONTEXT_ID_PREFIX
from google.adk.a2a.converters.utils import ADK_METADATA_KEY_PREFIX
import pytest
class TestUtilsFunctions:
"""Test suite for utils module functions."""
def test_get_adk_metadata_key_success(self):
"""Test successful metadata key generation."""
key = "test_key"
result = _get_adk_metadata_key(key)
assert result == f"{ADK_METADATA_KEY_PREFIX}{key}"
def test_get_adk_metadata_key_empty_string(self):
"""Test metadata key generation with empty string."""
with pytest.raises(
ValueError, match="Metadata key cannot be empty or None"
):
_get_adk_metadata_key("")
def test_get_adk_metadata_key_none(self):
"""Test metadata key generation with None."""
with pytest.raises(
ValueError, match="Metadata key cannot be empty or None"
):
_get_adk_metadata_key(None)
def test_get_adk_metadata_key_whitespace(self):
"""Test metadata key generation with whitespace string."""
key = " "
result = _get_adk_metadata_key(key)
assert result == f"{ADK_METADATA_KEY_PREFIX}{key}"
def test_to_a2a_context_id_success(self):
"""Test successful context ID generation."""
app_name = "test-app"
user_id = "test-user"
session_id = "test-session"
result = _to_a2a_context_id(app_name, user_id, session_id)
expected = f"{ADK_CONTEXT_ID_PREFIX}/test-app/test-user/test-session"
assert result == expected
def test_to_a2a_context_id_empty_app_name(self):
"""Test context ID generation with empty app name."""
with pytest.raises(
ValueError,
match=(
"All parameters \\(app_name, user_id, session_id\\) must be"
" non-empty"
),
):
_to_a2a_context_id("", "user", "session")
def test_to_a2a_context_id_empty_user_id(self):
"""Test context ID generation with empty user ID."""
with pytest.raises(
ValueError,
match=(
"All parameters \\(app_name, user_id, session_id\\) must be"
" non-empty"
),
):
_to_a2a_context_id("app", "", "session")
def test_to_a2a_context_id_empty_session_id(self):
"""Test context ID generation with empty session ID."""
with pytest.raises(
ValueError,
match=(
"All parameters \\(app_name, user_id, session_id\\) must be"
" non-empty"
),
):
_to_a2a_context_id("app", "user", "")
def test_to_a2a_context_id_none_values(self):
"""Test context ID generation with None values."""
with pytest.raises(
ValueError,
match=(
"All parameters \\(app_name, user_id, session_id\\) must be"
" non-empty"
),
):
_to_a2a_context_id(None, "user", "session")
def test_to_a2a_context_id_special_characters(self):
"""Test context ID generation with special characters."""
app_name = "test-app@2024"
user_id = "user_123"
session_id = "session-456"
result = _to_a2a_context_id(app_name, user_id, session_id)
expected = f"{ADK_CONTEXT_ID_PREFIX}/test-app@2024/user_123/session-456"
assert result == expected
def test_from_a2a_context_id_success(self):
"""Test successful context ID parsing."""
context_id = f"{ADK_CONTEXT_ID_PREFIX}/test-app/test-user/test-session"
app_name, user_id, session_id = _from_a2a_context_id(context_id)
assert app_name == "test-app"
assert user_id == "test-user"
assert session_id == "test-session"
def test_from_a2a_context_id_none_input(self):
"""Test context ID parsing with None input."""
result = _from_a2a_context_id(None)
assert result == (None, None, None)
def test_from_a2a_context_id_empty_string(self):
"""Test context ID parsing with empty string."""
result = _from_a2a_context_id("")
assert result == (None, None, None)
def test_from_a2a_context_id_invalid_prefix(self):
"""Test context ID parsing with invalid prefix."""
context_id = "INVALID/test-app/test-user/test-session"
result = _from_a2a_context_id(context_id)
assert result == (None, None, None)
def test_from_a2a_context_id_too_few_parts(self):
"""Test context ID parsing with too few parts."""
context_id = f"{ADK_CONTEXT_ID_PREFIX}/test-app/test-user"
result = _from_a2a_context_id(context_id)
assert result == (None, None, None)
def test_from_a2a_context_id_too_many_parts(self):
"""Test context ID parsing with too many parts."""
context_id = (
f"{ADK_CONTEXT_ID_PREFIX}/test-app/test-user/test-session/extra"
)
result = _from_a2a_context_id(context_id)
assert result == (None, None, None)
def test_from_a2a_context_id_empty_components(self):
"""Test context ID parsing with empty components."""
context_id = f"{ADK_CONTEXT_ID_PREFIX}//test-user/test-session"
result = _from_a2a_context_id(context_id)
assert result == (None, None, None)
def test_from_a2a_context_id_no_dollar_separator(self):
"""Test context ID parsing without dollar separators."""
context_id = f"{ADK_CONTEXT_ID_PREFIX}-test-app-test-user-test-session"
result = _from_a2a_context_id(context_id)
assert result == (None, None, None)
def test_roundtrip_context_id(self):
"""Test roundtrip conversion: to -> from."""
app_name = "test-app"
user_id = "test-user"
session_id = "test-session"
# Convert to context ID
context_id = _to_a2a_context_id(app_name, user_id, session_id)
# Convert back
parsed_app, parsed_user, parsed_session = _from_a2a_context_id(context_id)
assert parsed_app == app_name
assert parsed_user == user_id
assert parsed_session == session_id
def test_from_a2a_context_id_special_characters(self):
"""Test context ID parsing with special characters."""
context_id = f"{ADK_CONTEXT_ID_PREFIX}/test-app@2024/user_123/session-456"
app_name, user_id, session_id = _from_a2a_context_id(context_id)
assert app_name == "test-app@2024"
assert user_id == "user_123"
assert session_id == "session-456"
+13
View File
@@ -0,0 +1,13 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,851 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from unittest.mock import AsyncMock
from unittest.mock import Mock
from unittest.mock import patch
from a2a.server.agent_execution import RequestContext
from a2a.server.events.event_queue import EventQueue
from a2a.types import Message
from a2a.types import Task
from a2a.types import TaskStatusUpdateEvent
from google.adk.a2a import _compat
from google.adk.a2a.converters.request_converter import AgentRunRequest
from google.adk.a2a.converters.utils import _get_adk_metadata_key
from google.adk.a2a.executor.a2a_agent_executor_impl import _A2aAgentExecutor as A2aAgentExecutor
from google.adk.a2a.executor.a2a_agent_executor_impl import _NEW_A2A_ADK_INTEGRATION_EXTENSION
from google.adk.a2a.executor.a2a_agent_executor_impl import A2aAgentExecutorConfig
from google.adk.a2a.executor.config import ExecuteInterceptor
from google.adk.events.event import Event
from google.adk.runners import RunConfig
from google.adk.runners import Runner
from google.adk.sessions.base_session_service import GetSessionConfig
from google.genai.types import Content
import pytest
def _assert_final(event):
"""Assert a status-update event is terminal, version-correctly.
0.3.x: ``TaskStatusUpdateEvent`` has a ``final: bool`` field -> assert True.
1.x: the ``final`` field was removed (finality is inferred from stream end)
-> assert the field is genuinely absent.
"""
if _compat.IS_A2A_V1:
assert not hasattr(event, "final")
else:
assert event.final is True
def _final_events(call_args_list):
"""Return the enqueued events considered terminal, version-correctly.
0.3.x: events whose ``final`` field is True. 1.x: the ``final`` field is gone,
so the terminal event is the last one enqueued (finality is inferred from
stream end).
"""
events = [call[0][0] for call in call_args_list]
if _compat.IS_A2A_V1:
return events[-1:]
return [e for e in events if getattr(e, "final", False)]
class TestA2aAgentExecutor:
"""Test suite for A2aAgentExecutor class."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_runner = Mock(spec=Runner)
self.mock_runner.app_name = "test-app"
self.mock_runner.session_service = Mock()
self.mock_runner._new_invocation_context = Mock()
self.mock_runner.run_async = AsyncMock()
self.mock_a2a_part_converter = Mock()
self.mock_gen_ai_part_converter = Mock()
self.mock_request_converter = Mock()
self.mock_event_converter = Mock()
self.mock_config = A2aAgentExecutorConfig(
a2a_part_converter=self.mock_a2a_part_converter,
gen_ai_part_converter=self.mock_gen_ai_part_converter,
request_converter=self.mock_request_converter,
adk_event_converter=self.mock_event_converter,
)
self.executor = A2aAgentExecutor(
runner=self.mock_runner, config=self.mock_config
)
self.mock_context = Mock(spec=RequestContext)
self.mock_context.message = Message(
message_id="test-msg",
role=_compat.ROLE_AGENT,
parts=[_compat.make_text_part("test")],
)
self.mock_context.current_task = None
self.mock_context.task_id = "test-task-id"
self.mock_context.context_id = "test-context-id"
self.mock_event_queue = Mock(spec=EventQueue)
self.expected_metadata = {
_get_adk_metadata_key("app_name"): "test-app",
_get_adk_metadata_key("user_id"): "test-user",
_get_adk_metadata_key("session_id"): "test-session",
_NEW_A2A_ADK_INTEGRATION_EXTENSION: {"adk_agent_executor_v2": True},
}
async def _create_async_generator(self, items):
"""Helper to create async generator from items."""
for item in items:
yield item
@pytest.mark.asyncio
async def test_execute_success_new_task(self):
"""Test successful execution of a new task."""
# Setup
self.mock_request_converter.return_value = AgentRunRequest(
user_id="test-user",
session_id="test-session",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
# Mock session service
mock_session = Mock()
mock_session.id = "test-session"
self.mock_runner.session_service.get_session = AsyncMock(
return_value=mock_session
)
# Mock agent run with proper async generator
mock_event = Event(
invocation_id="invocation-id",
author="test-agent",
branch="main",
partial=False,
)
# Configure run_async to return the async generator when awaited
async def mock_run_async(**kwargs):
async for item in self._create_async_generator([mock_event]):
yield item
self.mock_runner.run_async = mock_run_async
# Mock event converter to return a working status update
working_event = _compat.make_task_status_update_event(
task_id="test-task-id",
status=_compat.make_task_status(_compat.TS_WORKING, timestamp="now"),
context_id="test-context-id",
final=False,
)
self.mock_event_converter.return_value = [working_event]
# Execute
await self.executor.execute(self.mock_context, self.mock_event_queue)
# Verify request converter was called with proper arguments
self.mock_request_converter.assert_called_once_with(
self.mock_context, self.mock_a2a_part_converter
)
# Verify event converter was called with proper arguments
self.mock_event_converter.assert_called_once_with(
mock_event,
{}, # agents_artifact (initially empty)
self.mock_context.task_id,
self.mock_context.context_id,
self.mock_gen_ai_part_converter,
)
# Verify task submitted event was enqueued
# call 0: submitted
# call 1: working (from converter)
# call 2: completed (final)
assert self.mock_event_queue.enqueue_event.call_count >= 3
submitted_event = self.mock_event_queue.enqueue_event.call_args_list[0][0][
0
]
assert isinstance(submitted_event, Task)
assert submitted_event.status.state == _compat.TS_SUBMITTED
assert submitted_event.metadata == self.expected_metadata
# Verify working event was enqueued
enqueued_working_event = self.mock_event_queue.enqueue_event.call_args_list[
1
][0][0]
assert isinstance(enqueued_working_event, TaskStatusUpdateEvent)
assert enqueued_working_event.status.state == _compat.TS_WORKING
assert enqueued_working_event.metadata == self.expected_metadata
# Verify converted event was enqueued
converted_event = self.mock_event_queue.enqueue_event.call_args_list[2][0][
0
]
assert converted_event == working_event
assert converted_event.metadata == self.expected_metadata
# Verify final event was enqueued
final_event = self.mock_event_queue.enqueue_event.call_args_list[-1][0][0]
_assert_final(final_event)
assert final_event.status.state == _compat.TS_COMPLETED
assert final_event.metadata == self.expected_metadata
@pytest.mark.asyncio
async def test_execute_no_message_error(self):
"""Test execution fails when no message is provided."""
self.mock_context.message = None
with pytest.raises(ValueError, match="A2A request must have a message"):
await self.executor.execute(self.mock_context, self.mock_event_queue)
@pytest.mark.asyncio
async def test_execute_existing_task(self):
"""Test execution with existing task (no submitted event)."""
self.mock_context.current_task = Mock()
self.mock_context.task_id = "existing-task-id"
self.mock_request_converter.return_value = AgentRunRequest(
user_id="test-user",
session_id="test-session",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
# Mock session service
mock_session = Mock()
mock_session.id = "test-session"
self.mock_runner.session_service.get_session = AsyncMock(
return_value=mock_session
)
# Mock agent run with proper async generator
mock_event = Event(
invocation_id="invocation-id",
author="test-agent",
branch="main",
partial=False,
)
# Configure run_async to return the async generator when awaited
async def mock_run_async(**kwargs):
async for item in self._create_async_generator([mock_event]):
yield item
self.mock_runner.run_async = mock_run_async
# Mock event converter
working_event = _compat.make_task_status_update_event(
task_id="existing-task-id",
status=_compat.make_task_status(_compat.TS_WORKING, timestamp="now"),
context_id="test-context-id",
final=False,
)
self.mock_event_converter.return_value = [working_event]
# Execute
await self.executor.execute(self.mock_context, self.mock_event_queue)
# Verify submitted event was NOT enqueued for existing task
# So we check first event is working state
first_event = self.mock_event_queue.enqueue_event.call_args_list[0][0][0]
assert isinstance(first_event, TaskStatusUpdateEvent)
assert first_event.status.state == _compat.TS_WORKING
assert first_event.metadata == self.expected_metadata
# Verify manual working event is FIRST
assert isinstance(first_event, TaskStatusUpdateEvent)
assert first_event.status.state == _compat.TS_WORKING
# Verify converted event was enqueued
converted_event = self.mock_event_queue.enqueue_event.call_args_list[1][0][
0
]
assert converted_event == working_event
assert converted_event.metadata == self.expected_metadata
# Verify final event
final_event = self.mock_event_queue.enqueue_event.call_args_list[-1][0][0]
_assert_final(final_event)
assert final_event.status.state == _compat.TS_COMPLETED
assert final_event.metadata == self.expected_metadata
def test_constructor_with_callable_runner(self):
"""Test constructor with callable runner."""
callable_runner = Mock()
executor = A2aAgentExecutor(runner=callable_runner, config=self.mock_config)
assert executor._runner == callable_runner
assert executor._config == self.mock_config
@pytest.mark.asyncio
async def test_resolve_runner_direct_instance(self):
"""Test _resolve_runner with direct Runner instance."""
# Setup - already using direct runner instance in setup_method
runner = await self.executor._resolve_runner()
assert runner == self.mock_runner
@pytest.mark.asyncio
async def test_resolve_runner_sync_callable(self):
"""Test _resolve_runner with sync callable that returns Runner."""
def create_runner():
return self.mock_runner
executor = A2aAgentExecutor(runner=create_runner, config=self.mock_config)
runner = await executor._resolve_runner()
assert runner == self.mock_runner
@pytest.mark.asyncio
async def test_resolve_runner_async_callable(self):
"""Test _resolve_runner with async callable that returns Runner."""
async def create_runner():
return self.mock_runner
executor = A2aAgentExecutor(runner=create_runner, config=self.mock_config)
runner = await executor._resolve_runner()
assert runner == self.mock_runner
@pytest.mark.asyncio
async def test_resolve_runner_invalid_type(self):
"""Test _resolve_runner with invalid runner type."""
executor = A2aAgentExecutor(runner="invalid", config=self.mock_config)
with pytest.raises(
TypeError, match="Runner must be a Runner instance or a callable"
):
await executor._resolve_runner()
@pytest.mark.asyncio
async def test_handle_request_integration(self):
"""Test the complete request handling flow."""
# Setup context with task_id
self.mock_context.task_id = "test-task-id"
# Setup detailed mocks
self.mock_request_converter.return_value = AgentRunRequest(
user_id="test-user",
session_id="test-session",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
# Mock session service
mock_session = Mock()
mock_session.id = "test-session"
self.mock_runner.session_service.get_session = AsyncMock(
return_value=mock_session
)
# Mock agent run with multiple events using proper async generator
mock_events = [
Event(
invocation_id="invocation-id",
author="test-agent",
branch="main",
partial=False,
),
Event(
invocation_id="invocation-id",
author="test-agent",
branch="main",
partial=False,
),
]
# Configure run_async to return the async generator when awaited
async def mock_run_async(**kwargs):
async for item in self._create_async_generator(mock_events):
yield item
self.mock_runner.run_async = mock_run_async
# Mock event converter to return events
working_event = _compat.make_task_status_update_event(
task_id="test-task-id",
status=_compat.make_task_status(_compat.TS_WORKING, timestamp="now"),
context_id="test-context-id",
final=False,
)
self.mock_event_converter.return_value = [working_event]
# Initialize executor context attributes as they would be in execute()
self.executor._invocation_metadata = {}
self.executor._executor_context = Mock()
self.executor._executor_context.app_name = "test-app"
self.executor._executor_context.user_id = "test-user"
self.executor._executor_context.session_id = "test-session"
# Execute
await self.executor._handle_request(
self.mock_context,
self.executor._executor_context,
self.mock_event_queue,
self.mock_runner,
self.mock_request_converter.return_value,
)
# Verify events enqueued
# Should check for working events
working_events = [
call[0][0]
for call in self.mock_event_queue.enqueue_event.call_args_list
if hasattr(call[0][0], "status")
and call[0][0].status.state == _compat.TS_WORKING
]
# Each ADK event generates 1 working event in this mock setup
assert len(working_events) >= len(mock_events)
# Verify final event is completed
final_events = _final_events(
self.mock_event_queue.enqueue_event.call_args_list
)
assert len(final_events) >= 1
final_event = final_events[-1]
assert final_event.status.state == _compat.TS_COMPLETED
@pytest.mark.asyncio
async def test_cancel_with_task_id(self):
"""Test cancellation with a task ID."""
self.mock_context.task_id = "test-task-id"
with pytest.raises(
NotImplementedError, match="Cancellation is not supported"
):
await self.executor.cancel(self.mock_context, self.mock_event_queue)
@pytest.mark.asyncio
async def test_execute_with_exception_handling(self):
"""Test execution with exception handling."""
self.mock_context.task_id = "test-task-id"
self.mock_context.current_task = None
self.mock_request_converter.side_effect = Exception("Test error")
# Execute (should not raise since we catch the exception)
await self.executor.execute(self.mock_context, self.mock_event_queue)
# Check failure event (last)
failure_event = self.mock_event_queue.enqueue_event.call_args_list[-1][0][0]
assert failure_event.status.state == _compat.TS_FAILED
_assert_final(failure_event)
_failure_part = failure_event.status.message.parts[0]
if _compat.IS_A2A_V1:
assert "Test error" in _failure_part.text
else:
assert "Test error" in _failure_part.root.text
@pytest.mark.asyncio
async def test_handle_request_with_non_working_state(self):
"""Test handle request when a non-working state is encountered."""
# Setup context with task_id
self.mock_context.task_id = "test-task-id"
self.mock_context.context_id = "test-context-id"
# Mock agent run event
mock_event = Event(
invocation_id="invocation-id",
author="test-agent",
branch="main",
partial=False,
)
mock_event.error_code = "ERROR"
async def mock_run_async(**kwargs):
async for item in self._create_async_generator([mock_event]):
yield item
self.mock_runner.run_async = mock_run_async
# Mock event converter to return a FAILED event
failed_event = _compat.make_task_status_update_event(
task_id="test-task-id",
status=_compat.make_task_status(_compat.TS_FAILED, timestamp="now"),
context_id="test-context-id",
final=False,
)
self.mock_event_converter.return_value = [failed_event]
run_request = AgentRunRequest(
user_id="test-user",
session_id="test-session",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
# Initialize executor context attributes
self.executor._invocation_metadata = {}
self.executor._executor_context = Mock()
self.executor._executor_context.app_name = "test-app"
self.executor._executor_context.user_id = "test-user"
self.executor._executor_context.session_id = "test-session"
# Execute
await self.executor._handle_request(
self.mock_context,
self.executor._executor_context,
self.mock_event_queue,
self.mock_runner,
run_request,
)
# Verify final event is FAILED, not COMPLETED
final_events = _final_events(
self.mock_event_queue.enqueue_event.call_args_list
)
assert len(final_events) >= 1
# The last event should be the synthesized final event
final_event = final_events[-1]
assert final_event.status.state == _compat.TS_FAILED
@pytest.mark.asyncio
async def test_handle_request_with_error_message(self):
"""Test handle request when an error message is present without an error code."""
self.mock_context.task_id = "test-task-id"
self.mock_context.context_id = "test-context-id"
# Mock agent run event with only error_message
mock_event = Event(
invocation_id="invocation-id",
author="test-agent",
branch="main",
partial=False,
)
mock_event.error_code = None
mock_event.error_message = "Test Error Message"
async def mock_run_async(**kwargs):
async for item in self._create_async_generator([mock_event]):
yield item
self.mock_runner.run_async = mock_run_async
self.mock_event_converter.return_value = []
run_request = AgentRunRequest(
user_id="test-user",
session_id="test-session",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
executor_context = Mock()
executor_context.app_name = "test-app"
executor_context.user_id = "test-user"
executor_context.session_id = "test-session"
await self.executor._handle_request(
self.mock_context,
executor_context,
self.mock_event_queue,
self.mock_runner,
run_request,
)
final_events = _final_events(
self.mock_event_queue.enqueue_event.call_args_list
)
assert len(final_events) >= 1
final_event = final_events[-1]
assert final_event.status.state == _compat.TS_FAILED
assert final_event.metadata == self.expected_metadata
@pytest.mark.asyncio
async def test_interceptors(self):
"""Test interceptors execution."""
# Setup interceptors
before_interceptor = AsyncMock(return_value=self.mock_context)
after_event_interceptor = AsyncMock()
after_event_interceptor.side_effect = lambda ctx, a2a, adk: a2a
after_agent_interceptor = AsyncMock()
after_agent_interceptor.side_effect = lambda ctx, event: event
interceptor = ExecuteInterceptor(
before_agent=before_interceptor,
after_event=after_event_interceptor,
after_agent=after_agent_interceptor,
)
self.mock_config.execute_interceptors = [interceptor]
# Mock run
mock_event = Event(
invocation_id="invocation-id",
author="test-agent",
branch="main",
partial=False,
)
async def mock_run_async(**kwargs):
async for item in self._create_async_generator([mock_event]):
yield item
self.mock_runner.run_async = mock_run_async
# Mock event converter
working_event = _compat.make_task_status_update_event(
task_id="test-task-id",
status=_compat.make_task_status(_compat.TS_WORKING, timestamp="now"),
context_id="test-context-id",
final=False,
)
self.mock_event_converter.return_value = [working_event]
# Pre-setup request converter
self.mock_request_converter.return_value = AgentRunRequest(
user_id="test-user",
session_id="test-session",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
# Mock session
mock_session = Mock()
mock_session.id = "test-session"
self.mock_runner.session_service.get_session = AsyncMock(
return_value=mock_session
)
# Execute
await self.executor.execute(self.mock_context, self.mock_event_queue)
# Verify interceptors called
before_interceptor.assert_called_once_with(self.mock_context)
# after_event called for each event
assert after_event_interceptor.call_count >= 1
after_agent_interceptor.assert_called_once()
@pytest.mark.asyncio
@patch("google.adk.a2a.executor.a2a_agent_executor_impl.handle_user_input")
async def test_execute_missing_user_input(self, mock_handle_user_input):
"""Test when handle_user_input returns a missing user input event."""
self.mock_context.current_task = Mock()
self.mock_context.task_id = "test-task-id"
self.mock_context.context_id = "test-context-id"
# Set up handle_user_input to return an event
missing_event = _compat.make_task_status_update_event(
task_id="test-task-id",
status=_compat.make_task_status(
_compat.TS_INPUT_REQUIRED, timestamp="now"
),
context_id="test-context-id",
final=False,
)
mock_handle_user_input.return_value = missing_event
self.mock_runner.session_service.get_session = AsyncMock(
return_value=Mock(id="test-session")
)
self.mock_request_converter.return_value = AgentRunRequest(
user_id="test-user",
session_id="test-session",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
# Execute
await self.executor.execute(self.mock_context, self.mock_event_queue)
# Verify that the missing_event was enqueued
self.mock_event_queue.enqueue_event.assert_called_once_with(missing_event)
# Verify that metadata was injected
enqueued_event = self.mock_event_queue.enqueue_event.call_args[0][0]
assert enqueued_event.metadata == self.expected_metadata
@pytest.mark.asyncio
async def test_resolve_session_creates_new_session(self):
"""Test that _resolve_session creates a new session if it doesn't exist."""
self.mock_runner.session_service.get_session = AsyncMock(return_value=None)
new_session = Mock()
new_session.id = "new-session-id"
self.mock_runner.session_service.create_session = AsyncMock(
return_value=new_session
)
run_request = AgentRunRequest(
user_id="test-user",
session_id="old-session-id",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
await self.executor._resolve_session(run_request, self.mock_runner)
self.mock_runner.session_service.get_session.assert_called_once_with(
app_name=self.mock_runner.app_name,
user_id="test-user",
session_id="old-session-id",
config=GetSessionConfig(num_recent_events=0, after_timestamp=None),
)
self.mock_runner.session_service.create_session.assert_called_once_with(
app_name=self.mock_runner.app_name,
user_id="test-user",
state={},
session_id="old-session-id",
)
assert run_request.session_id == "new-session-id"
@pytest.mark.asyncio
async def test_execute_enqueue_error_in_exception_handler(self):
"""Test failure event publishing handles exception during enqueue."""
self.mock_context.task_id = "test-task-id"
self.mock_request_converter.side_effect = Exception("Test error")
# Make enqueue_event raise an exception
self.mock_event_queue.enqueue_event.side_effect = Exception("Enqueue error")
# This should not raise an exception itself
await self.executor.execute(self.mock_context, self.mock_event_queue)
# Verify enqueue_event was called to publish the error event
assert self.mock_event_queue.enqueue_event.call_count == 1
@pytest.mark.asyncio
@patch("google.adk.a2a.executor.a2a_agent_executor_impl.LongRunningFunctions")
async def test_long_running_functions_final_event(self, mock_lrf_class):
"""Test _handle_request when there are long running function calls."""
self.mock_context.task_id = "test-task-id"
self.mock_context.context_id = "test-context-id"
# Set up mock LongRunningFunctions
mock_lrf = mock_lrf_class.return_value
mock_lrf.process_event.side_effect = lambda e: e
mock_lrf.has_long_running_function_calls.return_value = True
lrf_event = _compat.make_task_status_update_event(
task_id="test-task-id",
status=_compat.make_task_status(
_compat.TS_INPUT_REQUIRED, timestamp="now"
),
context_id="test-context-id",
final=False,
)
mock_lrf.create_long_running_function_call_event.return_value = lrf_event
self.mock_request_converter.return_value = AgentRunRequest(
user_id="test-user",
session_id="test-session",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
mock_session = Mock()
mock_session.id = "test-session"
self.mock_runner.session_service.get_session = AsyncMock(
return_value=mock_session
)
mock_event = Event(
invocation_id="invocation-id",
author="test-agent",
branch="main",
partial=False,
)
async def mock_run_async(**kwargs):
async for item in self._create_async_generator([mock_event]):
yield item
self.mock_runner.run_async = mock_run_async
self.mock_event_converter.return_value = []
self.executor._invocation_metadata = {}
self.executor._executor_context = Mock()
self.executor._executor_context.app_name = "test-app"
self.executor._executor_context.user_id = "test-user"
self.executor._executor_context.session_id = "test-session"
await self.executor._handle_request(
self.mock_context,
self.executor._executor_context,
self.mock_event_queue,
self.mock_runner,
self.mock_request_converter.return_value,
)
# Verify final event is the long running function call event
final_events = [
call[0][0]
for call in self.mock_event_queue.enqueue_event.call_args_list
if call[0][0] == lrf_event
]
assert len(final_events) >= 1
@pytest.mark.asyncio
async def test_after_event_interceptor_returns_none(self):
"""Test after_event_interceptor returning None drops the event."""
# Setup interceptor returning None
after_event_interceptor = AsyncMock()
after_event_interceptor.side_effect = lambda ctx, a2a, adk: None
interceptor = ExecuteInterceptor(
after_event=after_event_interceptor,
)
self.mock_config.execute_interceptors = [interceptor]
self.mock_context.task_id = "test-task-id"
self.mock_context.context_id = "test-context-id"
self.mock_request_converter.return_value = AgentRunRequest(
user_id="test-user",
session_id="test-session",
new_message=Mock(spec=Content),
run_config=Mock(spec=RunConfig),
)
mock_event = Event(
invocation_id="invocation-id",
author="test-agent",
branch="main",
partial=False,
)
async def mock_run_async(**kwargs):
async for item in self._create_async_generator([mock_event]):
yield item
self.mock_runner.run_async = mock_run_async
# Event converter returns one event
working_event = _compat.make_task_status_update_event(
task_id="test-task-id",
status=_compat.make_task_status(_compat.TS_WORKING, timestamp="now"),
context_id="test-context-id",
final=False,
)
self.mock_event_converter.return_value = [working_event]
self.executor._executor_context = Mock()
self.executor._executor_context.app_name = "test-app"
self.executor._executor_context.user_id = "test-user"
self.executor._executor_context.session_id = "test-session"
await self.executor._handle_request(
self.mock_context,
self.executor._executor_context,
self.mock_event_queue,
self.mock_runner,
self.mock_request_converter.return_value,
)
# Since the interceptor returns None, working_event should NOT be enqueued
# The only event enqueued by _handle_request should be the final event
assert self.mock_event_queue.enqueue_event.call_count == 1
final_event = self.mock_event_queue.enqueue_event.call_args_list[0][0][0]
assert final_event.status.state == _compat.TS_COMPLETED
@@ -0,0 +1,331 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import Mock
from a2a.types import Message
from google.adk.a2a import _compat
from google.adk.a2a.executor.task_result_aggregator import TaskResultAggregator
def create_test_message(text: str):
"""Helper function to create a test Message object."""
return Message(
message_id="test-msg",
role=_compat.ROLE_AGENT,
parts=[_compat.make_text_part(text)],
)
class TestTaskResultAggregator:
"""Test suite for TaskResultAggregator class."""
def setup_method(self):
"""Set up test fixtures."""
self.aggregator = TaskResultAggregator()
def test_initial_state(self):
"""Test the initial state of the aggregator."""
assert self.aggregator.task_state == _compat.TS_WORKING
assert self.aggregator.task_status_message is None
def test_process_failed_event(self):
"""Test processing a failed event."""
status_message = create_test_message("Failed to process")
event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_FAILED, message=status_message
),
final=True,
)
self.aggregator.process_event(event)
assert self.aggregator.task_state == _compat.TS_FAILED
assert self.aggregator.task_status_message == status_message
# Verify the event state was modified to working
assert event.status.state == _compat.TS_WORKING
def test_process_auth_required_event(self):
"""Test processing an auth_required event."""
status_message = create_test_message("Authentication needed")
event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_AUTH_REQUIRED, message=status_message
),
final=False,
)
self.aggregator.process_event(event)
assert self.aggregator.task_state == _compat.TS_AUTH_REQUIRED
assert self.aggregator.task_status_message == status_message
# Verify the event state was modified to working
assert event.status.state == _compat.TS_WORKING
def test_process_input_required_event(self):
"""Test processing an input_required event."""
status_message = create_test_message("Input required")
event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_INPUT_REQUIRED, message=status_message
),
final=False,
)
self.aggregator.process_event(event)
assert self.aggregator.task_state == _compat.TS_INPUT_REQUIRED
assert self.aggregator.task_status_message == status_message
# Verify the event state was modified to working
assert event.status.state == _compat.TS_WORKING
def test_status_message_with_none_message(self):
"""Test that status message handles None message properly."""
event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(_compat.TS_FAILED, message=None),
final=True,
)
self.aggregator.process_event(event)
assert self.aggregator.task_state == _compat.TS_FAILED
assert self.aggregator.task_status_message is None
def test_priority_order_failed_over_auth(self):
"""Test that failed state takes priority over auth_required."""
# First set auth_required
auth_message = create_test_message("Auth required")
auth_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_AUTH_REQUIRED, message=auth_message
),
final=False,
)
self.aggregator.process_event(auth_event)
assert self.aggregator.task_state == _compat.TS_AUTH_REQUIRED
assert self.aggregator.task_status_message == auth_message
# Then process failed - should override
failed_message = create_test_message("Failed")
failed_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_FAILED, message=failed_message
),
final=True,
)
self.aggregator.process_event(failed_event)
assert self.aggregator.task_state == _compat.TS_FAILED
assert self.aggregator.task_status_message == failed_message
def test_priority_order_auth_over_input(self):
"""Test that auth_required state takes priority over input_required."""
# First set input_required
input_message = create_test_message("Input needed")
input_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_INPUT_REQUIRED, message=input_message
),
final=False,
)
self.aggregator.process_event(input_event)
assert self.aggregator.task_state == _compat.TS_INPUT_REQUIRED
assert self.aggregator.task_status_message == input_message
# Then process auth_required - should override
auth_message = create_test_message("Auth needed")
auth_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_AUTH_REQUIRED, message=auth_message
),
final=False,
)
self.aggregator.process_event(auth_event)
assert self.aggregator.task_state == _compat.TS_AUTH_REQUIRED
assert self.aggregator.task_status_message == auth_message
def test_ignore_non_status_update_events(self):
"""Test that non-TaskStatusUpdateEvent events are ignored."""
mock_event = Mock()
initial_state = self.aggregator.task_state
initial_message = self.aggregator.task_status_message
self.aggregator.process_event(mock_event)
# State should remain unchanged
assert self.aggregator.task_state == initial_state
assert self.aggregator.task_status_message == initial_message
def test_working_state_does_not_override_higher_priority(self):
"""Test that working state doesn't override higher priority states."""
# First set failed state
failed_message = create_test_message("Failure message")
failed_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_FAILED, message=failed_message
),
final=True,
)
self.aggregator.process_event(failed_event)
assert self.aggregator.task_state == _compat.TS_FAILED
assert self.aggregator.task_status_message == failed_message
# Then process working - should not override state and should not update message
# because the current task state is not working
working_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(_compat.TS_WORKING),
final=False,
)
self.aggregator.process_event(working_event)
assert self.aggregator.task_state == _compat.TS_FAILED
# Working events don't update the status message when task state is not working
assert self.aggregator.task_status_message == failed_message
def test_status_message_priority_ordering(self):
"""Test that status messages follow the same priority ordering as states."""
# Start with input_required
input_message = create_test_message("Input message")
input_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_INPUT_REQUIRED, message=input_message
),
final=False,
)
self.aggregator.process_event(input_event)
assert self.aggregator.task_status_message == input_message
# Override with auth_required
auth_message = create_test_message("Auth message")
auth_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_AUTH_REQUIRED, message=auth_message
),
final=False,
)
self.aggregator.process_event(auth_event)
assert self.aggregator.task_status_message == auth_message
# Override with failed
failed_message = create_test_message("Failed message")
failed_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_FAILED, message=failed_message
),
final=True,
)
self.aggregator.process_event(failed_event)
assert self.aggregator.task_status_message == failed_message
# Working should not override failed message because current task state is failed
working_message = create_test_message("Working message")
working_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_WORKING, message=working_message
),
final=False,
)
self.aggregator.process_event(working_event)
# State should still be failed, and message should remain the failed message
# because working events only update message when task state is working
assert self.aggregator.task_state == _compat.TS_FAILED
assert self.aggregator.task_status_message == failed_message
def test_process_working_event_updates_message(self):
"""Test that working state events update the status message."""
working_message = create_test_message("Working on task")
event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_WORKING, message=working_message
),
final=False,
)
self.aggregator.process_event(event)
assert self.aggregator.task_state == _compat.TS_WORKING
assert self.aggregator.task_status_message == working_message
# Verify the event state was modified to working (should remain working)
assert event.status.state == _compat.TS_WORKING
def test_working_event_with_none_message(self):
"""Test that working state events handle None message properly."""
event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(_compat.TS_WORKING, message=None),
final=False,
)
self.aggregator.process_event(event)
assert self.aggregator.task_state == _compat.TS_WORKING
assert self.aggregator.task_status_message is None
def test_working_event_updates_message_regardless_of_state(self):
"""Test that working events update message only when current task state is working."""
# First set auth_required state
auth_message = create_test_message("Auth required")
auth_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_AUTH_REQUIRED, message=auth_message
),
final=False,
)
self.aggregator.process_event(auth_event)
assert self.aggregator.task_state == _compat.TS_AUTH_REQUIRED
assert self.aggregator.task_status_message == auth_message
# Then process working - should not update message because task state is not working
working_message = create_test_message("Working on auth")
working_event = _compat.make_task_status_update_event(
task_id="test-task",
context_id="test-context",
status=_compat.make_task_status(
_compat.TS_WORKING, message=working_message
),
final=False,
)
self.aggregator.process_event(working_event)
assert (
self.aggregator.task_state == _compat.TS_AUTH_REQUIRED
) # State unchanged
assert (
self.aggregator.task_status_message == auth_message
) # Message unchanged because task state is not working
@@ -0,0 +1,15 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""A2A integration tests package."""
+85
View File
@@ -0,0 +1,85 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""A2A Client for integration tests."""
from a2a.client.client_factory import ClientFactory as A2AClientFactory
from a2a.extensions.common import HTTP_EXTENSION_HEADER
from google.adk.a2a import _compat
from google.adk.a2a.agent.interceptors.new_integration_extension import _NEW_A2A_ADK_INTEGRATION_EXTENSION
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
import httpx
from .server import agent_card
def create_client(app, streaming: bool = False) -> RemoteA2aAgent:
"""Creates a RemoteA2aAgent connected to the provided FastAPI app.
Args:
app: The FastAPI application (server) to connect to.
streaming: Whether to enable streaming mode in the client.
Returns:
A RemoteA2aAgent instance.
"""
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
)
client_config = _compat.make_client_config(
httpx_client=client,
streaming=streaming,
polling=False,
)
factory = A2AClientFactory(config=client_config)
# use_legacy=False forces the new implementation
agent = RemoteA2aAgent(
name="remote_agent",
agent_card=agent_card,
a2a_client_factory=factory,
use_legacy=False,
)
return agent
def create_a2a_client(app, streaming: bool = False):
"""Creates a bare A2A Client connected to the provided FastAPI app.
This is in contrast to create_client, which wraps the a2a_client into a
RemoteA2aAgent for the standard runner framework ecosystem execution.
Args:
app: The FastAPI application (server) to connect to.
streaming: Whether to enable streaming mode in the client.
Returns:
An A2A Client instance.
"""
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://test",
headers={HTTP_EXTENSION_HEADER: _NEW_A2A_ADK_INTEGRATION_EXTENSION},
)
client_config = _compat.make_client_config(
httpx_client=client,
streaming=streaming,
polling=False,
)
factory = A2AClientFactory(config=client_config)
return factory.create(agent_card)
+164
View File
@@ -0,0 +1,164 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""A2A Server for integration tests."""
from unittest.mock import AsyncMock
from unittest.mock import Mock
try:
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
from a2a.server.request_handlers.default_request_handler import DefaultRequestHandler
from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore
except ImportError:
A2AFastAPIApplication = None
DefaultRequestHandler = None
InMemoryTaskStore = None
from google.adk.a2a import _compat
from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor
from google.adk.a2a.executor.config import A2aAgentExecutorConfig
from google.adk.agents.base_agent import BaseAgent
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
class FakeRunner(Runner):
"""A Fake Runner that delegates run_async to a provided function."""
def __init__(self, run_async_fn):
agent = Mock(spec=BaseAgent)
agent.name = "FakeAgent"
session_service = InMemorySessionService()
super().__init__(
app_name="FakeApp",
agent=agent,
session_service=session_service,
)
self.run_async_fn = run_async_fn
mock_artifact_service = Mock()
mock_artifact_service.load_artifact = AsyncMock(
return_value=types.Part(text="artifact content")
)
self.artifact_service = mock_artifact_service
async def run_async(self, **kwargs):
async for event in self.run_async_fn(**kwargs):
yield event
if _compat.IS_A2A_V1:
agent_card = _compat.parse_agent_card({
"name": "remote_agent",
"description": "A fun fact generator agent",
"version": "0.0.1",
"supported_interfaces": [
{"url": "http://test", "protocol_binding": "JSONRPC"}
],
"default_input_modes": ["text/plain"],
"default_output_modes": ["text/plain"],
})
else:
agent_card = _compat.parse_agent_card({
"name": "remote_agent",
"url": "http://test",
"description": "A fun fact generator agent",
"capabilities": {"streaming": True},
"version": "0.0.1",
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [],
})
def create_server_app(
run_async_fn=None,
config: A2aAgentExecutorConfig | None = None,
task_store=None,
):
"""Creates an A2A FastAPI application with a mocked runner.
Args:
run_async_fn: A generator function that takes **kwargs and yields Event
objects.
config: Optional executor configuration.
task_store: Optional task store instance. Defaults to InMemoryTaskStore.
Returns:
A FastAPI application instance.
"""
runner = FakeRunner(run_async_fn)
executor = A2aAgentExecutor(runner=runner, config=config)
if task_store is None:
task_store = InMemoryTaskStore()
handler = DefaultRequestHandler(
agent_executor=executor, task_store=task_store
)
app = A2AFastAPIApplication(agent_card=agent_card, http_handler=handler)
return app.build()
class _FixedContentArtifactService(InMemoryArtifactService):
"""``InMemoryArtifactService`` whose ``load_artifact`` always returns content."""
async def load_artifact(self, **kwargs):
return types.Part(text="artifact content")
class FakeRunnerV1(Runner):
"""A Fake Runner for 1.x with a real in-memory artifact service."""
def __init__(self, run_async_fn):
agent = Mock(spec=BaseAgent)
agent.name = "FakeAgent"
super().__init__(
app_name="FakeApp",
agent=agent,
session_service=InMemorySessionService(),
artifact_service=_FixedContentArtifactService(),
)
self.run_async_fn = run_async_fn
async def run_async(self, **kwargs):
async for event in self.run_async_fn(**kwargs):
yield event
def create_server_app_v1(
run_async_fn=None, config: A2aAgentExecutorConfig | None = None
):
"""Creates a 1.x Starlette app hosting an A2A executor (JSON-RPC routes).
Mirrors ``create_server_app`` but uses the 1.x route-factory path via
``_compat.attach_a2a_routes_to_app`` instead of the 0.3-only
``A2AFastAPIApplication``. Returns the Starlette app; callers must drive the
app's lifespan so the routes are attached before sending requests.
"""
from a2a.server.tasks import InMemoryTaskStore as TaskStore
from starlette.applications import Starlette
runner = FakeRunnerV1(run_async_fn)
executor = A2aAgentExecutor(runner=runner, config=config)
app = Starlette()
_compat.attach_a2a_routes_to_app(
app,
agent_card=agent_card,
agent_executor=executor,
task_store=TaskStore(),
)
return app
@@ -0,0 +1,821 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Integration tests for A2A client-server interaction."""
import logging
from unittest.mock import AsyncMock
try:
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
from a2a.server.request_handlers.request_handler import RequestHandler
except ImportError:
A2AFastAPIApplication = None
RequestHandler = None
from a2a.types import Message as A2AMessage
from a2a.types import Part as A2APart
from a2a.types import Task
from a2a.types import TaskStatus
try:
# 0.3.x-only wrapper part type; these integration tests are skipped on 1.x.
from a2a.types import TextPart
except ImportError:
TextPart = None
from google.adk.a2a import _compat
from google.adk.a2a.agent.interceptors.new_integration_extension import _NEW_A2A_ADK_INTEGRATION_EXTENSION
from google.adk.a2a.converters.to_adk_event import MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_INPUT
from google.adk.a2a.executor.config import A2aAgentExecutorConfig
from google.adk.a2a.executor.interceptors.include_artifacts_in_a2a_event import include_artifacts_in_a2a_event_interceptor
from google.adk.agents.remote_a2a_agent import A2A_METADATA_PREFIX
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.platform import uuid as platform_uuid
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
import pytest
pytestmark = pytest.mark.skipif(
_compat.IS_A2A_V1,
reason="integration tests use 0.3-only A2AFastAPIApplication",
)
from .client import create_a2a_client
from .client import create_client
from .server import agent_card
from .server import create_server_app
logger = logging.getLogger("google_adk." + __name__)
def create_streaming_mock_run_async(received_requests: list):
"""Creates a mock_run_async that streams multiple chunks."""
async def mock_run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent",
content=types.Content(parts=[types.Part(text="Hello")]),
partial=True,
)
yield Event(
author="FakeAgent",
content=types.Content(parts=[types.Part(text=" world")]),
partial=True,
)
yield Event(
author="FakeAgent",
partial=True,
actions=EventActions(artifact_delta={"file1": 1}),
)
yield Event(
author="FakeAgent",
content=types.Content(parts=[types.Part(text="Hello world")]),
partial=False,
)
return mock_run_async
def create_non_streaming_mock_run_async(received_requests: list):
"""Creates a mock_run_async that returns a single non-streaming event."""
async def mock_run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent",
content=types.Content(parts=[types.Part(text="Hello world")]),
partial=False,
)
return mock_run_async
@pytest.mark.asyncio
async def test_streaming_adk_to_streaming_a2a():
"""Test streaming of normal text chunks."""
received_requests = []
mock_run_async = create_streaming_mock_run_async(received_requests)
app = create_server_app(mock_run_async)
agent = create_client(app, streaming=True)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp",
agent=agent,
session_service=session_service,
)
new_message = types.Content(parts=[types.Part(text="Hi")], role="user")
texts = []
actions = []
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message
):
if event.content and event.content.parts:
for p in event.content.parts:
if p.text:
texts.append(p.text)
if event.actions and event.actions.artifact_delta:
actions.append(event.actions)
assert len(received_requests) == 1
assert received_requests[0]["session_id"] is not None
assert texts == ["Hello", " world", "Hello world"]
assert len(actions) == 1
assert actions[0].artifact_delta == {"file1": 1}
@pytest.mark.asyncio
async def test_streaming_adk_to_non_streaming_a2a():
"""Test ADK streaming into A2A Non-Streaming."""
received_requests = []
mock_run_async = create_streaming_mock_run_async(received_requests)
app = create_server_app(mock_run_async)
agent = create_client(app, streaming=False)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp", agent=agent, session_service=session_service
)
new_message = types.Content(parts=[types.Part(text="Hi")], role="user")
texts = []
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message
):
if event.content and event.content.parts:
for p in event.content.parts:
if p.text:
texts.append(p.text)
assert len(received_requests) == 1
assert texts == ["Hello world"]
@pytest.mark.asyncio
async def test_non_streaming_adk_to_streaming_a2a():
"""Test ADK Non-Streaming into A2A Streaming."""
received_requests = []
mock_run_async = create_non_streaming_mock_run_async(received_requests)
app = create_server_app(mock_run_async)
agent = create_client(app, streaming=True)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp", agent=agent, session_service=session_service
)
new_message = types.Content(parts=[types.Part(text="Hi")], role="user")
texts = []
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message
):
if event.content and event.content.parts:
for p in event.content.parts:
if p.text:
texts.append(p.text)
assert len(received_requests) == 1
assert texts == ["Hello world"]
@pytest.mark.asyncio
async def test_non_streaming_adk_to_non_streaming_a2a():
"""Test ADK Non-Streaming into A2A Non-Streaming."""
received_requests = []
mock_run_async = create_non_streaming_mock_run_async(received_requests)
app = create_server_app(mock_run_async)
agent = create_client(app, streaming=False)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp", agent=agent, session_service=session_service
)
new_message = types.Content(parts=[types.Part(text="Hi")], role="user")
texts = []
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message
):
if event.content and event.content.parts:
for p in event.content.parts:
if p.text:
texts.append(p.text)
assert len(received_requests) == 1
assert texts == ["Hello world"]
def create_streaming_mock_run_async_with_multiple_agents(
received_requests: list,
):
"""Creates a mock_run_async that streams multiple chunks."""
async def mock_run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent1",
content=types.Content(parts=[types.Part(text="Hello")]),
partial=True,
)
yield Event(
author="FakeAgent2",
content=types.Content(parts=[types.Part(text=" Hi")]),
partial=True,
)
yield Event(
author="FakeAgent1",
content=types.Content(parts=[types.Part(text=" world")]),
partial=True,
)
yield Event(
author="FakeAgent2",
content=types.Content(parts=[types.Part(text=" human")]),
partial=True,
)
yield Event(
author="FakeAgent1",
content=types.Content(parts=[types.Part(text="Hello world")]),
partial=False,
)
yield Event(
author="FakeAgent2",
content=types.Content(parts=[types.Part(text="Hi human")]),
partial=False,
)
return mock_run_async
@pytest.mark.asyncio
async def test_multiple_agents_streaming_adk_to_streaming_a2a():
"""Test streaming multiple agents chunks into A2A Streaming."""
received_requests = []
mock_run_async = create_streaming_mock_run_async_with_multiple_agents(
received_requests
)
app = create_server_app(mock_run_async)
agent = create_client(app, streaming=True)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp", agent=agent, session_service=session_service
)
new_message = types.Content(parts=[types.Part(text="Hi")], role="user")
texts = []
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message
):
if event.content and event.content.parts:
for p in event.content.parts:
if p.text:
texts.append(p.text)
assert len(received_requests) == 1
assert texts == [
"Hello",
" Hi",
" world",
" human",
"Hello world",
"Hi human",
]
@pytest.mark.asyncio
async def test_function_calls():
"""Test function call execution from agent."""
received_requests = []
async def mock_run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent",
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name="get_weather",
args={"location": "San Francisco"},
id="call_1",
)
),
types.Part(
function_response=types.FunctionResponse(
name="get_weather",
response={"temperature": "22C"},
id="call_1",
)
),
],
role="model",
),
)
app = create_server_app(mock_run_async)
agent = create_client(app)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp",
agent=agent,
session_service=session_service,
)
new_message = types.Content(parts=[types.Part(text="Hi")], role="user")
func_calls = []
func_responses = []
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message
):
func_calls.extend(event.get_function_calls())
if event.content and event.content.parts:
for p in event.content.parts:
if p.function_response:
func_responses.append(p.function_response)
assert len(func_calls) == 1
assert func_calls[0].name == "get_weather"
assert func_calls[0].args == {"location": "San Francisco"}
assert len(func_responses) == 1
assert func_responses[0].name == "get_weather"
assert func_responses[0].response == {"temperature": "22C"}
def create_long_running_mock_run_async(received_requests: list):
"""Creates a mock_run_async for long running function tests."""
async def mock_run_async(**kwargs):
received_requests.append(kwargs)
if len(received_requests) == 1:
yield Event(
author="FakeAgent",
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name="long_task", args={}, id="call_long"
)
)
],
role="model",
),
long_running_tool_ids={"call_long"},
)
yield Event(
author="FakeAgent",
content=types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name="long_task",
response={"status": "pending"},
id="call_long",
)
)
],
role="model",
),
)
else:
yield Event(
author="FakeAgent",
content=types.Content(
parts=[types.Part(text="Task completed well")], role="model"
),
)
return mock_run_async
@pytest.mark.asyncio
async def test_long_running_function_calls_success():
"""Test long running function calls flow success with user response."""
received_requests = []
mock_run_async = create_long_running_mock_run_async(received_requests)
app = create_server_app(mock_run_async)
agent = create_client(app, streaming=True)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp",
agent=agent,
session_service=session_service,
)
new_message_1 = types.Content(parts=[types.Part(text="Hi")], role="user")
func_calls_1 = []
func_responses_1 = []
task_id_1 = ""
has_long_running_id = False
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message_1
):
if event.custom_metadata:
task_id_1 = event.custom_metadata.get(
A2A_METADATA_PREFIX + "task_id", task_id_1
)
if (
event.long_running_tool_ids
and "call_long" in event.long_running_tool_ids
):
has_long_running_id = True
func_calls_1.extend(event.get_function_calls())
if event.content and event.content.parts:
for p in event.content.parts:
if p.function_response:
func_responses_1.append(p.function_response)
assert has_long_running_id
assert len(func_calls_1) == 1
assert func_calls_1[0].name == "long_task"
assert len(func_responses_1) == 1
assert func_responses_1[0].name == "long_task"
assert func_responses_1[0].response == {"status": "pending"}
new_message_2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name="long_task", response={"result": "done"}, id="call_long"
)
)
],
role="user",
)
texts = []
task_id_2 = ""
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message_2
):
if event.custom_metadata:
task_id_2 = event.custom_metadata.get(
A2A_METADATA_PREFIX + "task_id", task_id_2
)
if event.content and event.content.parts:
for p in event.content.parts:
if p.text:
texts.append(p.text)
assert task_id_1 == task_id_2
assert "Task completed well" in texts
@pytest.mark.asyncio
async def test_long_running_function_calls_error():
"""Test long running function calls returns error on missing response."""
received_requests = []
mock_run_async = create_long_running_mock_run_async(received_requests)
app = create_server_app(mock_run_async)
a2a_client = create_a2a_client(app, streaming=False)
request_1 = A2AMessage(
message_id=platform_uuid.new_uuid(),
parts=[A2APart(root=TextPart(text="Hi"))],
role="user",
)
response_1_events = []
async for event in a2a_client.send_message(request=request_1):
response_1_events.append(event)
assert len(response_1_events) == 1
# Extract task_id from Turn 1 responses
assert response_1_events[0][1] is None
task = response_1_events[0][0]
assert isinstance(task, Task)
assert task.status.state == _compat.TS_INPUT_REQUIRED
extracted_task_id = task.id
assert extracted_task_id is not None
request_2 = A2AMessage(
message_id=platform_uuid.new_uuid(),
parts=[A2APart(root=TextPart(text="Any update?"))],
role="user",
task_id=extracted_task_id,
context_id=task.context_id if hasattr(task, "context_id") else None,
)
response_2_events = []
async for event in a2a_client.send_message(request=request_2):
response_2_events.append(event)
# Verify that we get an error response for the second request due to missing function response
assert len(response_2_events) == 1
assert response_2_events[0][1] is None
error_response = response_2_events[0][0]
assert isinstance(error_response, Task)
assert error_response.status.message.parts[0].root.text == (
"It was not provided a function response for the function call."
)
@pytest.mark.asyncio
async def test_user_follow_up():
"""Test multi-turn interaction or follow up with state."""
received_requests = []
async def mock_run_async(**kwargs):
received_requests.append(kwargs)
# Yield response with custom metadata to test passing back
yield Event(
author="FakeAgent",
content=types.Content(
parts=[types.Part(text="Follow up response")], role="model"
),
custom_metadata={"server_state": "active"},
)
app = create_server_app(mock_run_async)
agent = create_client(app)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp",
agent=agent,
session_service=session_service,
)
# First Turn
new_message_1 = types.Content(parts=[types.Part(text="Turn 1")], role="user")
async for _ in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message_1
):
pass
# Second Turn
new_message_2 = types.Content(parts=[types.Part(text="Turn 2")], role="user")
last_event = None
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message_2
):
last_event = event
assert len(received_requests) == 2
# The second request should carry the same session ID as the first
assert (
received_requests[1]["session_id"] == received_requests[0]["session_id"]
)
assert last_event is not None
@pytest.mark.asyncio
async def test_include_artifacts_in_a2a_event():
"""Test that artifacts are included in A2A events when the interceptor is enabled."""
async def mock_run_async(**kwargs):
yield Event(
actions=EventActions(artifact_delta={"artifact1": 1, "artifact2": 1}),
author="agent",
content=types.Content(
parts=[types.Part(text="Here are the artifacts")]
),
)
config = A2aAgentExecutorConfig(
execute_interceptors=[include_artifacts_in_a2a_event_interceptor]
)
built_app = create_server_app(mock_run_async, config=config)
a2a_client = create_a2a_client(built_app, streaming=False)
request = A2AMessage(
message_id="test_message_id",
parts=[A2APart(root=TextPart(text="Hi"))],
role="user",
)
events = []
async for event in a2a_client.send_message(request=request):
events.append(event)
assert len(events) == 1
task = events[0][0]
assert isinstance(task, Task)
assert task.artifacts is not None
assert len(task.artifacts) == 3
assert task.artifacts[0].parts[0].root.text == "Here are the artifacts"
assert task.artifacts[1].artifact_id == "artifact1_1"
assert task.artifacts[1].name == "artifact1"
assert task.artifacts[1].parts[0].root.text == "artifact content"
assert task.artifacts[2].artifact_id == "artifact2_1"
assert task.artifacts[2].name == "artifact2"
assert task.artifacts[2].parts[0].root.text == "artifact content"
@pytest.mark.asyncio
async def test_user_follow_up_sends_task_id_with_input_required():
"""Test that client follow-up sends the same task_id."""
task_id = "mocked-task-id-123"
context_id = "mocked-context-id-456"
mock_task = Task(
id=task_id,
context_id=context_id,
kind="task",
status=TaskStatus(
state=_compat.TS_INPUT_REQUIRED,
message=A2AMessage(
message_id="mocked-message-id-789",
role="user",
parts=[A2APart(root=TextPart(text="Input required"))],
),
),
metadata={_NEW_A2A_ADK_INTEGRATION_EXTENSION: True},
)
mock_handler = AsyncMock(spec=RequestHandler)
# First call returns input_required, second call completes
mock_handler.on_message_send.side_effect = [
mock_task,
Task(
id=task_id,
context_id=context_id,
kind="task",
status=TaskStatus(state=_compat.TS_COMPLETED),
metadata={_NEW_A2A_ADK_INTEGRATION_EXTENSION: True},
),
]
app = A2AFastAPIApplication(
agent_card=agent_card, http_handler=mock_handler
).build()
agent = create_client(app, streaming=False)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp", agent=agent, session_service=session_service
)
# First Turn
new_message_1 = types.Content(parts=[types.Part(text="Turn 1")], role="user")
found_call_id = None
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message_1
):
for call in event.get_function_calls():
if call.name == MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_INPUT:
found_call_id = call.id
assert found_call_id is not None
# Second Turn (Follow-up)
function_response = types.FunctionResponse(
id=found_call_id,
name=MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_INPUT,
response={"result": "Turn 2"},
)
new_message_2 = types.Content(
parts=[types.Part(function_response=function_response)], role="user"
)
async for _ in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message_2
):
pass
assert mock_handler.on_message_send.call_count == 2
# Second call args
call_args_2 = mock_handler.on_message_send.call_args_list[1]
params_2 = call_args_2[0][0]
assert params_2.message.task_id == task_id
@pytest.mark.asyncio
async def test_user_follow_up_sends_task_id_with_input_required_legacy_impl():
"""Test that client follow-up sends the same task_id."""
task_id = "mocked-task-id-123"
context_id = "mocked-context-id-456"
mock_task = Task(
id=task_id,
context_id=context_id,
kind="task",
status=TaskStatus(
state=_compat.TS_INPUT_REQUIRED,
message=A2AMessage(
message_id="mocked-message-id-789",
role="user",
parts=[A2APart(root=TextPart(text="Input required"))],
),
),
)
mock_handler = AsyncMock(spec=RequestHandler)
# First call returns input_required, second call completes
mock_handler.on_message_send.side_effect = [
mock_task,
Task(
id=task_id,
context_id=context_id,
kind="task",
status=TaskStatus(state=_compat.TS_COMPLETED),
),
]
app = A2AFastAPIApplication(
agent_card=agent_card, http_handler=mock_handler
).build()
agent = create_client(app, streaming=False)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp", agent=agent, session_service=session_service
)
# First Turn
new_message_1 = types.Content(parts=[types.Part(text="Turn 1")], role="user")
found_call_id = None
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message_1
):
for call in event.get_function_calls():
if call.name == MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_INPUT:
found_call_id = call.id
assert found_call_id is not None
# Second Turn (Follow-up)
function_response = types.FunctionResponse(
id=found_call_id,
name=MOCK_FUNCTION_CALL_FOR_REQUIRED_USER_INPUT,
response={"result": "Turn 2"},
)
new_message_2 = types.Content(
parts=[types.Part(function_response=function_response)], role="user"
)
async for _ in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message_2
):
pass
assert mock_handler.on_message_send.call_count == 2
# Second call args
call_args_2 = mock_handler.on_message_send.call_args_list[1]
params_2 = call_args_2[0][0]
assert params_2.message.task_id == task_id
@@ -0,0 +1,749 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""End-to-end client-server integration tests for a2a-sdk 1.x."""
from __future__ import annotations
from google.adk.a2a import _compat
from google.adk.a2a.executor.config import A2aAgentExecutorConfig
from google.adk.a2a.executor.interceptors.include_artifacts_in_a2a_event import include_artifacts_in_a2a_event_interceptor
from google.adk.agents.remote_a2a_agent import A2A_METADATA_PREFIX
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.platform import uuid as platform_uuid
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
import pytest
pytestmark = pytest.mark.skipif(
not _compat.IS_A2A_V1,
reason="1.x-only client-server tests (0.3.x covered by test_client_server)",
)
from .client import create_a2a_client
from .client import create_client
from .server import agent_card
from .server import create_server_app_v1
# -----------------------------------------------------------------------------
# Mock server-side agents
# -----------------------------------------------------------------------------
def _streaming_run_async(received_requests: list):
"""A mock run_async that streams partial chunks (text + artifact) then final."""
async def run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent",
content=types.Content(parts=[types.Part(text="Hello")]),
partial=True,
)
yield Event(
author="FakeAgent",
content=types.Content(parts=[types.Part(text=" world")]),
partial=True,
)
yield Event(
author="FakeAgent",
partial=True,
actions=EventActions(artifact_delta={"file1": 1}),
)
yield Event(
author="FakeAgent",
content=types.Content(parts=[types.Part(text="Hello world")]),
partial=False,
)
return run_async
def _non_streaming_run_async(received_requests: list):
"""A mock run_async that yields a single non-partial event."""
async def run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent",
content=types.Content(parts=[types.Part(text="Hello world")]),
partial=False,
)
return run_async
def _multi_agent_run_async(received_requests: list):
"""A mock run_async that interleaves two agents' streamed chunks."""
async def run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent1",
content=types.Content(parts=[types.Part(text="Hello")]),
partial=True,
)
yield Event(
author="FakeAgent2",
content=types.Content(parts=[types.Part(text=" Hi")]),
partial=True,
)
yield Event(
author="FakeAgent1",
content=types.Content(parts=[types.Part(text=" world")]),
partial=True,
)
yield Event(
author="FakeAgent2",
content=types.Content(parts=[types.Part(text=" human")]),
partial=True,
)
yield Event(
author="FakeAgent1",
content=types.Content(parts=[types.Part(text="Hello world")]),
partial=False,
)
yield Event(
author="FakeAgent2",
content=types.Content(parts=[types.Part(text="Hi human")]),
partial=False,
)
return run_async
def _function_call_run_async(received_requests: list):
"""A mock run_async that yields a function call + response pair."""
async def run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent",
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name="get_weather",
args={"location": "San Francisco"},
id="call_1",
)
),
types.Part(
function_response=types.FunctionResponse(
name="get_weather",
response={"temperature": "22C"},
id="call_1",
)
),
],
role="model",
),
)
return run_async
def _long_running_run_async(received_requests: list):
"""A mock run_async modeling a long-running tool needing a user response."""
async def run_async(**kwargs):
received_requests.append(kwargs)
if len(received_requests) == 1:
yield Event(
author="FakeAgent",
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
name="long_task", args={}, id="call_long"
)
)
],
role="model",
),
long_running_tool_ids={"call_long"},
)
yield Event(
author="FakeAgent",
content=types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name="long_task",
response={"status": "pending"},
id="call_long",
)
)
],
role="model",
),
)
else:
yield Event(
author="FakeAgent",
content=types.Content(
parts=[types.Part(text="Task completed well")], role="model"
),
)
return run_async
# -----------------------------------------------------------------------------
# Client driver helpers
# -----------------------------------------------------------------------------
async def _run_client(agent, *, message_text: str = "Hi"):
"""Drives the agent through a client-side Runner and collects results."""
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp", agent=agent, session_service=session_service
)
new_message = types.Content(
parts=[types.Part(text=message_text)], role="user"
)
texts = []
artifact_deltas = []
func_calls = []
func_responses = []
async for event in client_runner.run_async(
user_id="test_user", session_id="test_session", new_message=new_message
):
if event.content and event.content.parts:
for part in event.content.parts:
if part.text:
texts.append(part.text)
if part.function_response:
func_responses.append(part.function_response)
func_calls.extend(event.get_function_calls())
if event.actions and event.actions.artifact_delta:
artifact_deltas.append(event.actions.artifact_delta)
return {
"texts": texts,
"artifact_deltas": artifact_deltas,
"func_calls": func_calls,
"func_responses": func_responses,
}
# -----------------------------------------------------------------------------
# RemoteA2aAgent round-trip tests (streaming variants)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_non_streaming_round_trip():
"""A non-streaming agent response round-trips to the client on 1.x."""
received_requests = []
app = create_server_app_v1(_non_streaming_run_async(received_requests))
async with app.router.lifespan_context(app):
agent = create_client(app, streaming=False)
result = await _run_client(agent)
assert len(received_requests) == 1
assert received_requests[0]["session_id"] is not None
assert "Hello world" in result["texts"]
@pytest.mark.asyncio
async def test_non_streaming_adk_to_streaming_a2a():
"""A non-streaming agent response round-trips over a streaming client."""
received_requests = []
app = create_server_app_v1(_non_streaming_run_async(received_requests))
async with app.router.lifespan_context(app):
agent = create_client(app, streaming=True)
result = await _run_client(agent)
assert len(received_requests) == 1
assert "Hello world" in result["texts"]
@pytest.mark.asyncio
async def test_streaming_round_trip():
"""A streaming agent response delivers its aggregate text on 1.x."""
received_requests = []
app = create_server_app_v1(_streaming_run_async(received_requests))
async with app.router.lifespan_context(app):
agent = create_client(app, streaming=True)
result = await _run_client(agent)
assert len(received_requests) == 1
assert "Hello world" in result["texts"]
@pytest.mark.asyncio
async def test_streaming_adk_to_non_streaming_a2a():
"""A streaming agent response collapses to its final text on a non-streaming client."""
received_requests = []
app = create_server_app_v1(_streaming_run_async(received_requests))
async with app.router.lifespan_context(app):
agent = create_client(app, streaming=False)
result = await _run_client(agent)
assert len(received_requests) == 1
assert "Hello world" in result["texts"]
@pytest.mark.asyncio
async def test_multiple_agents_streaming_round_trip():
"""Interleaved chunks from multiple server-side agents stream on 1.x."""
received_requests = []
app = create_server_app_v1(_multi_agent_run_async(received_requests))
async with app.router.lifespan_context(app):
agent = create_client(app, streaming=True)
result = await _run_client(agent)
assert len(received_requests) == 1
# The request reached the server and the last author's final text arrives.
assert "Hi human" in result["texts"]
@pytest.mark.asyncio
async def test_artifact_producing_agent_round_trip():
"""An agent that records an artifact delta still round-trips its content."""
received_requests = []
async def run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent",
content=types.Content(parts=[types.Part(text="with artifact")]),
actions=EventActions(artifact_delta={"file1": 1}),
partial=False,
)
app = create_server_app_v1(run_async)
async with app.router.lifespan_context(app):
agent = create_client(app, streaming=True)
result = await _run_client(agent)
assert len(received_requests) == 1
assert "with artifact" in result["texts"]
# -----------------------------------------------------------------------------
# Function-call round trip
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_function_calls():
"""Function call + response pairs round-trip to the client on 1.x."""
received_requests = []
app = create_server_app_v1(_function_call_run_async(received_requests))
async with app.router.lifespan_context(app):
agent = create_client(app)
result = await _run_client(agent)
assert len(result["func_calls"]) == 1
assert result["func_calls"][0].name == "get_weather"
assert result["func_calls"][0].args == {"location": "San Francisco"}
assert len(result["func_responses"]) == 1
assert result["func_responses"][0].name == "get_weather"
assert result["func_responses"][0].response == {"temperature": "22C"}
# -----------------------------------------------------------------------------
# Long-running flows (multi-turn)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_long_running_function_calls_success():
"""A long-running tool flow completes across two turns, preserving task_id."""
received_requests = []
app = create_server_app_v1(_long_running_run_async(received_requests))
async with app.router.lifespan_context(app):
agent = create_client(app, streaming=True)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp", agent=agent, session_service=session_service
)
# Turn 1: triggers the long-running tool, surfaces it to the client.
new_message_1 = types.Content(parts=[types.Part(text="Hi")], role="user")
func_calls_1 = []
task_id_1 = ""
has_long_running_id = False
async for event in client_runner.run_async(
user_id="test_user",
session_id="test_session",
new_message=new_message_1,
):
if event.custom_metadata:
task_id_1 = event.custom_metadata.get(
A2A_METADATA_PREFIX + "task_id", task_id_1
)
if (
event.long_running_tool_ids
and "call_long" in event.long_running_tool_ids
):
has_long_running_id = True
func_calls_1.extend(event.get_function_calls())
assert has_long_running_id
assert any(c.name == "long_task" for c in func_calls_1)
# Turn 2: provide the function response; task should complete.
new_message_2 = types.Content(
parts=[
types.Part(
function_response=types.FunctionResponse(
name="long_task",
response={"result": "done"},
id="call_long",
)
)
],
role="user",
)
texts = []
task_id_2 = ""
async for event in client_runner.run_async(
user_id="test_user",
session_id="test_session",
new_message=new_message_2,
):
if event.custom_metadata:
task_id_2 = event.custom_metadata.get(
A2A_METADATA_PREFIX + "task_id", task_id_2
)
if event.content and event.content.parts:
for p in event.content.parts:
if p.text:
texts.append(p.text)
assert task_id_1 == task_id_2
assert "Task completed well" in texts
@pytest.mark.asyncio
async def test_long_running_function_calls_error():
"""A follow-up without a function response yields an INPUT_REQUIRED error."""
received_requests = []
app = create_server_app_v1(_long_running_run_async(received_requests))
async with app.router.lifespan_context(app):
a2a_client = create_a2a_client(app, streaming=False)
request_1 = _compat.make_message(
message_id=platform_uuid.new_uuid(),
role=_compat.ROLE_USER,
parts=[_compat.make_text_part("Hi")],
)
response_1_events = []
normalize = _compat.make_stream_normalizer()
async for item in _compat.send_message(a2a_client, request=request_1):
response_1_events.append(normalize(item))
assert len(response_1_events) == 1
task, update = response_1_events[0]
assert update is None
assert task.status.state == _compat.TS_INPUT_REQUIRED
extracted_task_id = task.id
assert extracted_task_id
request_2 = _compat.make_message(
message_id=platform_uuid.new_uuid(),
role=_compat.ROLE_USER,
parts=[_compat.make_text_part("Any update?")],
task_id=extracted_task_id,
context_id=task.context_id,
)
response_2_events = []
normalize = _compat.make_stream_normalizer()
async for item in _compat.send_message(a2a_client, request=request_2):
response_2_events.append(normalize(item))
assert len(response_2_events) == 1
error_task, error_update = response_2_events[0]
assert error_update is None
status_message = _compat.normalize_message(error_task.status.message)
assert status_message is not None
assert _compat.part_text(status_message.parts[0]) == (
"It was not provided a function response for the function call."
)
# -----------------------------------------------------------------------------
# Multi-turn session continuity
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_user_follow_up():
"""Two turns on the same session reuse the same server-side session id."""
received_requests = []
async def run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent",
content=types.Content(
parts=[types.Part(text="Follow up response")], role="model"
),
custom_metadata={"server_state": "active"},
)
app = create_server_app_v1(run_async)
async with app.router.lifespan_context(app):
agent = create_client(app)
session_service = InMemorySessionService()
await session_service.create_session(
app_name="ClientApp", user_id="test_user", session_id="test_session"
)
client_runner = Runner(
app_name="ClientApp", agent=agent, session_service=session_service
)
new_message_1 = types.Content(
parts=[types.Part(text="Turn 1")], role="user"
)
async for _ in client_runner.run_async(
user_id="test_user",
session_id="test_session",
new_message=new_message_1,
):
pass
new_message_2 = types.Content(
parts=[types.Part(text="Turn 2")], role="user"
)
last_event = None
async for event in client_runner.run_async(
user_id="test_user",
session_id="test_session",
new_message=new_message_2,
):
last_event = event
assert len(received_requests) == 2
assert (
received_requests[1]["session_id"] == received_requests[0]["session_id"]
)
assert last_event is not None
# -----------------------------------------------------------------------------
# Artifact interceptor
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_include_artifacts_in_a2a_event():
"""The artifact interceptor surfaces recorded artifacts on the task on 1.x."""
async def run_async(**kwargs):
yield Event(
actions=EventActions(artifact_delta={"artifact1": 1, "artifact2": 1}),
author="agent",
content=types.Content(
parts=[types.Part(text="Here are the artifacts")]
),
)
config = A2aAgentExecutorConfig(
execute_interceptors=[include_artifacts_in_a2a_event_interceptor]
)
app = create_server_app_v1(run_async, config=config)
async with app.router.lifespan_context(app):
a2a_client = create_a2a_client(app, streaming=False)
request = _compat.make_message(
message_id="test_message_id",
role=_compat.ROLE_USER,
parts=[_compat.make_text_part("Hi")],
)
events = []
normalize = _compat.make_stream_normalizer()
async for item in _compat.send_message(a2a_client, request=request):
events.append(normalize(item))
assert len(events) == 1
task, update = events[0]
assert update is None
assert task.artifacts is not None
# The text part plus the two recorded artifacts.
assert len(task.artifacts) == 3
assert _compat.part_text(task.artifacts[0].parts[0]) == (
"Here are the artifacts"
)
artifact_names = {a.name for a in task.artifacts[1:]}
assert artifact_names == {"artifact1", "artifact2"}
for art in task.artifacts[1:]:
assert _compat.part_text(art.parts[0]) == "artifact content"
def _multi_artifact_streaming_run_async(received_requests: list):
"""A streaming agent that records two distinct artifacts across chunks."""
async def run_async(**kwargs):
received_requests.append(kwargs)
yield Event(
author="FakeAgent",
partial=True,
content=types.Content(parts=[types.Part(text="chunk one")]),
actions=EventActions(artifact_delta={"file1": 1}),
)
yield Event(
author="FakeAgent",
partial=True,
content=types.Content(parts=[types.Part(text="chunk two")]),
actions=EventActions(artifact_delta={"file2": 1}),
)
yield Event(
author="FakeAgent",
partial=False,
content=types.Content(parts=[types.Part(text="done")]),
)
return run_async
@pytest.mark.asyncio
async def test_streaming_artifacts_are_aggregated_into_single_task():
"""Streaming multi-artifact responses arrive aggregated as one ``task`` item."""
received_requests = []
config = A2aAgentExecutorConfig(
execute_interceptors=[include_artifacts_in_a2a_event_interceptor]
)
app = create_server_app_v1(
_multi_artifact_streaming_run_async(received_requests), config=config
)
async with app.router.lifespan_context(app):
a2a_client = create_a2a_client(app, streaming=True)
request = _compat.make_message(
message_id="test_message_id",
role=_compat.ROLE_USER,
parts=[_compat.make_text_part("Hi")],
)
events = []
normalize = _compat.make_stream_normalizer()
async for item in _compat.send_message(a2a_client, request=request):
events.append(normalize(item))
# The whole stream collapses to a single aggregated task carrier.
assert len(events) == 1
task, update = events[0]
assert update is None
assert task.artifacts is not None
# Both recorded artifacts are present (plus any text-carrying artifact).
artifact_names = {a.name for a in task.artifacts}
assert {"file1", "file2"}.issubset(artifact_names)
@pytest.mark.asyncio
async def test_streaming_artifact_run_completes_through_remote_agent():
"""A streaming multi-artifact run round-trips through RemoteA2aAgent"""
received_requests = []
config = A2aAgentExecutorConfig(
execute_interceptors=[include_artifacts_in_a2a_event_interceptor]
)
app = create_server_app_v1(
_multi_artifact_streaming_run_async(received_requests), config=config
)
async with app.router.lifespan_context(app):
agent = create_client(app, streaming=True)
result = await _run_client(agent)
assert len(received_requests) == 1
# The aggregated final response reaches the client (no mid-stream failure).
assert "done" in result["texts"]
@pytest.mark.asyncio
async def test_make_stream_normalizer_aggregates_incremental_artifacts():
"""The stateful normalizer accumulates artifacts across incremental updates."""
from a2a.types import a2a_pb2 as pb
def _artifact_update(name: str) -> pb.StreamResponse:
item = pb.StreamResponse()
item.artifact_update.task_id = "task-1"
item.artifact_update.context_id = "ctx-1"
item.artifact_update.append = False
item.artifact_update.last_chunk = True
artifact = item.artifact_update.artifact
artifact.artifact_id = name
artifact.name = name
artifact.parts.add().text = f"content-{name}"
return item
initial = pb.StreamResponse()
initial.task.id = "task-1"
initial.task.context_id = "ctx-1"
stream = [initial, _artifact_update("file1"), _artifact_update("file2")]
normalize = _compat.make_stream_normalizer()
results = [normalize(item) for item in stream]
# The running task accumulates artifacts across updates.
names_per_step = [{a.name for a in task.artifacts} for task, _ in results]
assert names_per_step[0] == set()
assert names_per_step[1] == {"file1"}
assert names_per_step[2] == {"file1", "file2"}
@pytest.mark.asyncio
async def test_make_stream_normalizer_accumulates_status_history():
"""Status update messages accumulate into task.history; status is applied.
Mirrors the 0.3.x ClientTaskManager, which appended each status message to
task.history before overwriting the task status.
"""
from a2a.types import a2a_pb2 as pb
def _status_update(text: str, state: int) -> pb.StreamResponse:
item = pb.StreamResponse()
item.status_update.task_id = "task-1"
item.status_update.context_id = "ctx-1"
item.status_update.status.state = state
item.status_update.status.message.message_id = text
item.status_update.status.message.parts.add().text = text
return item
initial = pb.StreamResponse()
initial.task.id = "task-1"
initial.task.context_id = "ctx-1"
stream = [
initial,
_status_update("working", pb.TASK_STATE_WORKING),
_status_update("done", pb.TASK_STATE_COMPLETED),
]
normalize = _compat.make_stream_normalizer()
results = [normalize(item) for item in stream]
# history accumulates one message per status update carrying a message.
history_ids_per_step = [
[m.message_id for m in task.history] for task, _ in results
]
assert history_ids_per_step[0] == []
assert history_ids_per_step[1] == ["working"]
assert history_ids_per_step[2] == ["working", "done"]
# the latest status is applied to the running task.
final_task, _ = results[2]
assert final_task.status.state == pb.TASK_STATE_COMPLETED
+13
View File
@@ -0,0 +1,13 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,836 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for ``to_a2a``.
Hosting is wired through the version-agnostic
``_compat.attach_a2a_routes_to_app``
shim, so these tests run on both a2a-sdk 0.3.x and 1.x. Tests that assert the
version-specific *route attachment* internals live in
``TestAttachA2aRoutesToApp``
and are gated per SDK major. Everything else exercises the version-agnostic
``to_a2a`` surface (validation, runner/service wiring, agent-card sources,
lifespan composition) either via mocks (construction-only checks) or
behaviorally
(driving the app lifespan and asserting routes are attached).
"""
from unittest.mock import ANY
from unittest.mock import AsyncMock
from unittest.mock import Mock
from unittest.mock import patch
from a2a.types import AgentCard
from google.adk.a2a import _compat
from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor
from google.adk.a2a.utils.agent_card_builder import AgentCardBuilder
from google.adk.a2a.utils.agent_to_a2a import to_a2a
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.llm_agent import LlmAgent
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.workflow import FunctionNode
from google.adk.workflow import START
from google.adk.workflow import Workflow
import pytest
from starlette.applications import Starlette
def _route_paths(app: Starlette) -> set:
"""Returns the set of route paths registered on an app."""
return {getattr(r, "path", None) for r in app.routes}
def _assert_a2a_routes_attached(app: Starlette) -> None:
"""Asserts both the JSON-RPC and agent-card routes were attached."""
paths = _route_paths(app)
assert "/" in paths, f"missing JSON-RPC route; got {paths}"
assert any(
p and "agent-card" in p for p in paths
), f"missing agent-card route; got {paths}"
def _minimal_agent_card_dict() -> dict:
"""A minimal agent-card dict valid on both a2a-sdk majors."""
if _compat.IS_A2A_V1:
return {
"name": "file_agent",
"description": "Test agent from file",
"version": "1.0.0",
"supported_interfaces": [
{"url": "http://example.com/", "protocol_binding": "JSONRPC"}
],
"default_input_modes": ["text/plain"],
"default_output_modes": ["text/plain"],
}
return {
"name": "file_agent",
"url": "http://example.com",
"description": "Test agent from file",
"version": "1.0.0",
"capabilities": {},
"skills": [],
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"supportsAuthenticatedExtendedCard": False,
}
def _make_minimal_agent_card() -> AgentCard:
"""A minimal AgentCard valid on both a2a-sdk majors."""
return _compat.parse_agent_card(_minimal_agent_card_dict())
class TestToA2A:
"""Tests for the to_a2a function."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_agent = Mock(spec=BaseAgent)
self.mock_agent.name = "test_agent"
self.mock_agent.description = "Test agent description"
# ---------------------------------------------------------------------------
# Construction-only checks (mock Starlette; hosting is exercised separately).
# ---------------------------------------------------------------------------
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_default_parameters(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with default parameters."""
mock_app = Mock(spec=Starlette)
mock_starlette_class.return_value = mock_app
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
result = to_a2a(self.mock_agent)
assert result == mock_app
mock_starlette_class.assert_called_once_with(lifespan=ANY)
mock_task_store_class.assert_called_once()
mock_agent_executor_class.assert_called_once()
mock_card_builder_class.assert_called_once_with(
agent=self.mock_agent, rpc_url="http://localhost:8000/"
)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_with_custom_runner(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with a custom runner."""
mock_app = Mock(spec=Starlette)
mock_starlette_class.return_value = mock_app
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
custom_runner = Mock(spec=Runner)
result = to_a2a(self.mock_agent, runner=custom_runner)
assert result == mock_app
mock_starlette_class.assert_called_once_with(lifespan=ANY)
mock_task_store_class.assert_called_once()
mock_agent_executor_class.assert_called_once_with(runner=custom_runner)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_with_custom_task_store(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with a custom task store does not build the default one."""
mock_app = Mock(spec=Starlette)
mock_starlette_class.return_value = mock_app
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
custom_task_store = Mock()
result = to_a2a(self.mock_agent, task_store=custom_task_store)
assert result == mock_app
mock_task_store_class.assert_not_called()
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_default_task_store_when_none(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a defaults to InMemoryTaskStore when task_store is None."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
to_a2a(self.mock_agent, task_store=None)
mock_task_store_class.assert_called_once()
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_custom_host_port(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with custom host and port."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
to_a2a(self.mock_agent, host="example.com", port=9000)
mock_card_builder_class.assert_called_once_with(
agent=self.mock_agent, rpc_url="http://example.com:9000/"
)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_with_custom_port_zero(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with port 0 (dynamic port assignment)."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
to_a2a(self.mock_agent, port=0)
mock_card_builder_class.assert_called_once_with(
agent=self.mock_agent, rpc_url="http://localhost:0/"
)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_with_empty_string_host(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with empty string host."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
to_a2a(self.mock_agent, host="")
mock_card_builder_class.assert_called_once_with(
agent=self.mock_agent, rpc_url="http://:8000/"
)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_with_negative_port(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with negative port number."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
to_a2a(self.mock_agent, port=-1)
mock_card_builder_class.assert_called_once_with(
agent=self.mock_agent, rpc_url="http://localhost:-1/"
)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_with_very_large_port(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with very large port number."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
to_a2a(self.mock_agent, port=65535)
mock_card_builder_class.assert_called_once_with(
agent=self.mock_agent, rpc_url="http://localhost:65535/"
)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_with_special_characters_in_host(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with special characters in host name."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
to_a2a(self.mock_agent, host="test-host.example.com")
mock_card_builder_class.assert_called_once_with(
agent=self.mock_agent, rpc_url="http://test-host.example.com:8000/"
)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_with_ip_address_host(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with IP address as host."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
to_a2a(self.mock_agent, host="192.168.1.1")
mock_card_builder_class.assert_called_once_with(
agent=self.mock_agent, rpc_url="http://192.168.1.1:8000/"
)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_agent_without_name(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test to_a2a with agent that has no name."""
self.mock_agent.name = None
mock_app = Mock(spec=Starlette)
mock_starlette_class.return_value = mock_app
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
result = to_a2a(self.mock_agent)
assert result == mock_app
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_creates_runner_with_correct_services(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test that the agent executor receives a runner factory callable."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
to_a2a(self.mock_agent)
mock_agent_executor_class.assert_called_once()
call_args = mock_agent_executor_class.call_args
assert "runner" in call_args[1]
assert callable(call_args[1]["runner"])
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
@patch("google.adk.a2a.utils.agent_to_a2a.Runner")
def test_create_runner_function_creates_runner_correctly(
self,
mock_runner_class,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test that the create_runner factory builds a Runner with correct args."""
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
mock_runner = Mock(spec=Runner)
mock_runner_class.return_value = mock_runner
to_a2a(self.mock_agent)
runner_func = mock_agent_executor_class.call_args[1]["runner"]
runner_result = runner_func()
mock_runner_class.assert_called_once_with(
app_name="test_agent",
agent=self.mock_agent,
artifact_service=mock_runner_class.call_args[1]["artifact_service"],
session_service=mock_runner_class.call_args[1]["session_service"],
memory_service=mock_runner_class.call_args[1]["memory_service"],
credential_service=mock_runner_class.call_args[1]["credential_service"],
)
call_args = mock_runner_class.call_args[1]
assert isinstance(call_args["artifact_service"], InMemoryArtifactService)
assert isinstance(call_args["session_service"], InMemorySessionService)
assert isinstance(call_args["memory_service"], InMemoryMemoryService)
assert isinstance(
call_args["credential_service"], InMemoryCredentialService
)
assert runner_result == mock_runner
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
@patch("google.adk.a2a.utils.agent_to_a2a.Runner")
def test_create_runner_function_with_agent_without_name(
self,
mock_runner_class,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test create_runner uses a default app_name when agent has no name."""
self.mock_agent.name = None
mock_starlette_class.return_value = Mock(spec=Starlette)
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
mock_runner_class.return_value = Mock(spec=Runner)
to_a2a(self.mock_agent)
runner_func = mock_agent_executor_class.call_args[1]["runner"]
runner_func()
assert mock_runner_class.call_args[1]["app_name"] == "adk_agent"
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
def test_to_a2a_returns_starlette_app(
self,
mock_starlette_class,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""Test that to_a2a returns the constructed Starlette application."""
mock_app = Mock(spec=Starlette)
mock_starlette_class.return_value = mock_app
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
result = to_a2a(self.mock_agent)
assert result == mock_app
# ---------------------------------------------------------------------------
# Behavioral hosting checks (real Starlette; drive lifespan; assert routes).
# ---------------------------------------------------------------------------
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
async def test_setup_a2a_builds_card_and_attaches_routes(
self,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""setup_a2a builds the agent card and attaches the A2A routes."""
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder = Mock(spec=AgentCardBuilder)
mock_card_builder_class.return_value = mock_card_builder
mock_card_builder.build = AsyncMock(return_value=_make_minimal_agent_card())
app = to_a2a(self.mock_agent)
async with app.router.lifespan_context(app):
pass
mock_card_builder.build.assert_called_once()
_assert_a2a_routes_attached(app)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
async def test_setup_a2a_handles_agent_card_build_failure(
self,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""A failure building the agent card propagates during lifespan startup."""
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder = Mock(spec=AgentCardBuilder)
mock_card_builder_class.return_value = mock_card_builder
mock_card_builder.build = AsyncMock(side_effect=Exception("Build failed"))
app = to_a2a(self.mock_agent)
with pytest.raises(Exception, match="Build failed"):
async with app.router.lifespan_context(app):
pass
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
async def test_to_a2a_with_custom_agent_card_object(
self,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""A provided AgentCard is used directly without building one."""
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder = Mock(spec=AgentCardBuilder)
mock_card_builder_class.return_value = mock_card_builder
mock_card_builder.build = AsyncMock()
app = to_a2a(self.mock_agent, agent_card=_make_minimal_agent_card())
async with app.router.lifespan_context(app):
pass
mock_card_builder.build.assert_not_called()
_assert_a2a_routes_attached(app)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("json.load")
@patch("pathlib.Path.open")
@patch("pathlib.Path")
async def test_to_a2a_with_agent_card_file_path(
self,
mock_path_class,
mock_open,
mock_json_load,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""An agent card loaded from a file path is used to attach routes."""
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder = Mock(spec=AgentCardBuilder)
mock_card_builder_class.return_value = mock_card_builder
mock_card_builder.build = AsyncMock()
mock_path = Mock()
mock_path_class.return_value = mock_path
mock_file_handle = Mock()
mock_context_manager = Mock()
mock_context_manager.__enter__ = Mock(return_value=mock_file_handle)
mock_context_manager.__exit__ = Mock(return_value=None)
mock_path.open = Mock(return_value=mock_context_manager)
mock_json_load.return_value = _minimal_agent_card_dict()
app = to_a2a(self.mock_agent, agent_card="/path/to/agent_card.json")
async with app.router.lifespan_context(app):
pass
mock_path_class.assert_called_once_with("/path/to/agent_card.json")
mock_path.open.assert_called_once_with("r", encoding="utf-8")
mock_json_load.assert_called_once_with(mock_file_handle)
mock_card_builder.build.assert_not_called()
_assert_a2a_routes_attached(app)
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@patch("pathlib.Path.open", side_effect=FileNotFoundError("File not found"))
@patch("pathlib.Path")
def test_to_a2a_with_invalid_agent_card_file_path(
self,
mock_path_class,
mock_open,
mock_card_builder_class,
mock_task_store_class,
mock_agent_executor_class,
):
"""An invalid agent card file path raises at to_a2a() call time."""
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
mock_path_class.return_value = Mock()
with pytest.raises(ValueError, match="Failed to load agent card from"):
to_a2a(self.mock_agent, agent_card="/invalid/path.json")
async def test_to_a2a_with_lifespan(self):
"""A user lifespan runs alongside A2A setup."""
from contextlib import asynccontextmanager
startup_called = False
shutdown_called = False
@asynccontextmanager
async def custom_lifespan(app):
nonlocal startup_called, shutdown_called
startup_called = True
app.state.test_value = "hello"
yield
shutdown_called = True
agent = LlmAgent(
name="lifespan_agent", description="d", model="gemini-2.0-flash"
)
app = to_a2a(agent, port=8001, lifespan=custom_lifespan)
async with app.router.lifespan_context(app):
_assert_a2a_routes_attached(app)
assert startup_called
assert app.state.test_value == "hello"
assert shutdown_called
async def test_to_a2a_without_lifespan(self):
"""Without a user lifespan, A2A setup still attaches routes."""
agent = LlmAgent(
name="nolifespan_agent", description="d", model="gemini-2.0-flash"
)
app = to_a2a(agent, port=8001)
async with app.router.lifespan_context(app):
_assert_a2a_routes_attached(app)
async def test_to_a2a_lifespan_setup_runs_before_user_lifespan(self):
"""A2A setup (routes attached) runs before the user lifespan startup."""
from contextlib import asynccontextmanager
call_order = []
@asynccontextmanager
async def custom_lifespan(app):
# By the time the user lifespan starts, routes must already be attached.
call_order.append("user_startup")
assert "/" in _route_paths(app)
yield
call_order.append("user_shutdown")
agent = LlmAgent(
name="order_agent", description="d", model="gemini-2.0-flash"
)
app = to_a2a(agent, port=8001, lifespan=custom_lifespan)
async with app.router.lifespan_context(app):
pass
assert call_order == ["user_startup", "user_shutdown"]
# ---------------------------------------------------------------------------
# Validation (version-agnostic).
# ---------------------------------------------------------------------------
def test_to_a2a_with_none_agent(self):
"""Test that to_a2a raises error when agent is None."""
with pytest.raises(ValueError, match="Agent cannot be None or empty."):
to_a2a(None)
def test_to_a2a_rejects_non_agent_non_workflow(self):
"""to_a2a raises TypeError immediately for unsupported types.
Only BaseAgent (e.g. LlmAgent) and Workflow are valid A2A roots. Other
BaseNode subclasses (e.g. FunctionNode) and arbitrary objects must be
rejected at call time, not silently served as a degenerate card.
"""
with pytest.raises(
TypeError, match="requires a BaseAgent or Workflow, got str"
):
to_a2a("not an agent")
async def test_to_a2a_succeeds_for_workflow(self):
"""to_a2a accepts a Workflow and the Starlette lifespan completes."""
writer = LlmAgent(
name="writer",
model="gemini-2.5-flash",
instruction="Write a short reply.",
)
workflow = Workflow(name="pipe", edges=[(START, writer)])
app = to_a2a(workflow, port=8001)
async with app.router.lifespan_context(app):
_assert_a2a_routes_attached(app)
def test_to_a2a_rejects_function_node(self):
"""to_a2a raises TypeError for a bare FunctionNode.
FunctionNode is a BaseNode but is intended for use inside a Workflow, not
as a standalone A2A root.
"""
async def my_fn(node_input):
return f"echo: {node_input}"
fn_node = FunctionNode(func=my_fn, name="echo_fn")
with pytest.raises(
TypeError, match="requires a BaseAgent or Workflow, got FunctionNode"
):
to_a2a(fn_node)
class TestAttachA2aRoutesToApp:
"""Tests for the version-agnostic ``_compat.attach_a2a_routes_to_app`` shim."""
@pytest.mark.skipif(
_compat.IS_A2A_V1,
reason="0.3.x route attachment internals (A2AStarletteApplication)",
)
@patch("a2a.server.request_handlers.DefaultRequestHandler")
@patch("a2a.server.apps.A2AStarletteApplication")
def test_prefix_is_propagated_to_add_routes_on_0_3(
self, mock_a2a_app_class, mock_handler_class
):
"""The 0.3.x branch must mount routes under the given prefix."""
# Regression: fast_api.py hosts multiple agents on one app, each under
# /a2a/{name}. The 0.3.x branch previously ignored prefix and called
# add_routes_to_app(app) with defaults, so every agent collided on the
# root RPC route and default /.well-known/... card route.
del mock_handler_class # Patched to avoid real handler construction.
mock_a2a_app = Mock()
mock_a2a_app_class.return_value = mock_a2a_app
app = Starlette()
_compat.attach_a2a_routes_to_app(
app,
agent_card=Mock(spec=AgentCard),
agent_executor=Mock(),
task_store=Mock(),
prefix="/a2a/my_agent",
)
mock_a2a_app.add_routes_to_app.assert_called_once()
_, kwargs = mock_a2a_app.add_routes_to_app.call_args
assert kwargs["rpc_url"] == "/a2a/my_agent"
assert kwargs["agent_card_url"].startswith("/a2a/my_agent/.well-known/")
@pytest.mark.skipif(
_compat.IS_A2A_V1,
reason="0.3.x route attachment internals (A2AStarletteApplication)",
)
@patch("a2a.server.request_handlers.DefaultRequestHandler")
@patch("a2a.server.apps.A2AStarletteApplication")
def test_no_prefix_uses_defaults_on_0_3(
self, mock_a2a_app_class, mock_handler_class
):
"""Without a prefix, routes mount at the SDK defaults (root)."""
del mock_handler_class # Patched to avoid real handler construction.
mock_a2a_app = Mock()
mock_a2a_app_class.return_value = mock_a2a_app
app = Starlette()
_compat.attach_a2a_routes_to_app(
app,
agent_card=Mock(spec=AgentCard),
agent_executor=Mock(),
task_store=Mock(),
)
mock_a2a_app.add_routes_to_app.assert_called_once_with(app)
@pytest.mark.skipif(
not _compat.IS_A2A_V1,
reason="1.x route-factory attachment path",
)
def test_attach_routes_with_prefix_on_v1(self):
"""The 1.x branch attaches prefixed JSON-RPC and agent-card routes."""
from a2a.server.tasks import InMemoryTaskStore
agent_card = _compat.parse_agent_card({
"name": "smoke",
"description": "d",
"version": "1.0",
"supported_interfaces": [
{"url": "http://localhost:8001/", "protocol_binding": "JSONRPC"}
],
"default_input_modes": ["text/plain"],
"default_output_modes": ["text/plain"],
})
class _NoopExecutor:
async def execute(self, context, event_queue):
return None
async def cancel(self, context, event_queue):
return None
app = Starlette()
before = len(app.routes)
_compat.attach_a2a_routes_to_app(
app,
agent_card=agent_card,
agent_executor=_NoopExecutor(),
task_store=InMemoryTaskStore(),
prefix="/a2a/smoke",
)
assert len(app.routes) > before
paths = {getattr(r, "path", None) for r in app.routes}
assert any(p and "agent-card" in p for p in paths)
assert any(p == "/a2a/smoke" for p in paths)