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
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:
@@ -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"
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user