ec2b666284
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
2273 lines
75 KiB
Python
2273 lines
75 KiB
Python
# 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.
|
|
|
|
"""Tests for interactions_utils.py conversion functions."""
|
|
|
|
import asyncio
|
|
import base64
|
|
from collections.abc import AsyncGenerator
|
|
from collections.abc import Callable
|
|
from datetime import datetime
|
|
from datetime import timezone
|
|
import json
|
|
import logging
|
|
from unittest.mock import MagicMock
|
|
|
|
from google.adk.models import interactions_utils
|
|
from google.adk.models.llm_request import LlmRequest
|
|
from google.adk.models.llm_response import LlmResponse
|
|
from google.genai import interactions
|
|
from google.genai import types
|
|
from google.genai.interactions import CodeExecutionResultStep
|
|
from google.genai.interactions import FunctionCallStep
|
|
from google.genai.interactions import FunctionResultStep
|
|
from google.genai.interactions import ImageContent
|
|
from google.genai.interactions import Interaction
|
|
from google.genai.interactions import InteractionCompletedEvent
|
|
from google.genai.interactions import InteractionCreatedEvent
|
|
from google.genai.interactions import InteractionSseEventInteraction
|
|
from google.genai.interactions import ModelOutputStep
|
|
from google.genai.interactions import StepDelta
|
|
from google.genai.interactions import StepStart
|
|
from google.genai.interactions import StepStop
|
|
from google.genai.interactions import TextContent
|
|
from google.genai.interactions import ThoughtStep
|
|
from google.genai.interactions import Usage
|
|
import pytest
|
|
|
|
|
|
class _MockAsyncIterator:
|
|
"""Simple async iterator for streaming interaction events."""
|
|
|
|
def __init__(self, sequence: list[object]):
|
|
self._iterator = iter(sequence)
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
try:
|
|
return next(self._iterator)
|
|
except StopIteration as exc:
|
|
raise StopAsyncIteration from exc
|
|
|
|
|
|
class _FakeInteractions:
|
|
"""Fake interactions resource for create() tests.
|
|
|
|
Records each create() call's kwargs (including the ``stream`` flag) so tests
|
|
can assert verbatim forwarding. Streaming calls (``stream`` truthy) return an
|
|
async iterator over the configured events; non-streaming calls return the
|
|
configured Interaction. ``_create_interactions`` always passes ``stream``
|
|
explicitly, so there is no need to distinguish "unset" from ``stream=False``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
events: list[object] | None = None,
|
|
*,
|
|
interaction: Interaction | None = None,
|
|
):
|
|
self._events = events or []
|
|
self._interaction = interaction
|
|
self.create_calls: list[dict[str, object]] = []
|
|
|
|
async def create(self, **kwargs):
|
|
self.create_calls.append(kwargs)
|
|
if kwargs.get('stream'):
|
|
return _MockAsyncIterator(self._events)
|
|
return self._interaction
|
|
|
|
|
|
class _FakeAio:
|
|
"""Namespace matching the expected api_client.aio shape."""
|
|
|
|
def __init__(
|
|
self,
|
|
events: list[object] | None = None,
|
|
*,
|
|
interaction: Interaction | None = None,
|
|
):
|
|
self.interactions = _FakeInteractions(events, interaction=interaction)
|
|
|
|
|
|
class _FakeApiClient:
|
|
"""Minimal fake API client for interactions create() tests.
|
|
|
|
Streaming calls return an async iterator over the configured events;
|
|
non-streaming calls return the configured Interaction. ``create_calls``
|
|
exposes the recorded kwargs of each ``interactions.create`` call.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
events: list[object] | None = None,
|
|
*,
|
|
interaction: Interaction | None = None,
|
|
):
|
|
self.aio = _FakeAio(events, interaction=interaction)
|
|
|
|
@property
|
|
def create_calls(self) -> list[dict[str, object]]:
|
|
return self.aio.interactions.create_calls
|
|
|
|
|
|
def _build_llm_request() -> LlmRequest:
|
|
"""Build a minimal request for interactions streaming tests."""
|
|
return LlmRequest(
|
|
model='gemini-2.5-flash',
|
|
contents=[
|
|
types.Content(
|
|
role='user',
|
|
parts=[types.Part(text='Weather in Tokyo?')],
|
|
)
|
|
],
|
|
config=types.GenerateContentConfig(),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def fc_step() -> FunctionCallStep:
|
|
"""Fixture providing a basic FunctionCallStep."""
|
|
return FunctionCallStep(
|
|
type='function_call',
|
|
id='call_1',
|
|
name='get_weather',
|
|
arguments={'city': 'Tokyo'},
|
|
)
|
|
|
|
|
|
def _build_lifecycle_streamed_events(fc_step: FunctionCallStep) -> list[object]:
|
|
"""Build streamed events with lifecycle updates carrying the ID."""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
interaction = InteractionSseEventInteraction(
|
|
id='interaction_123',
|
|
created=now,
|
|
updated=now,
|
|
status='requires_action',
|
|
steps=[fc_step],
|
|
)
|
|
|
|
return [
|
|
InteractionCreatedEvent(
|
|
event_type='interaction.created',
|
|
interaction=interaction,
|
|
),
|
|
InteractionCompletedEvent(
|
|
event_type='interaction.completed',
|
|
interaction=interaction,
|
|
),
|
|
]
|
|
|
|
|
|
def _build_complete_streamed_events(fc_step: FunctionCallStep) -> list[object]:
|
|
"""Build streamed events with the ID on an interaction.complete event."""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
interaction = InteractionSseEventInteraction(
|
|
id='interaction_complete_123',
|
|
created=now,
|
|
updated=now,
|
|
status='requires_action',
|
|
steps=[fc_step],
|
|
)
|
|
|
|
return [
|
|
InteractionCompletedEvent(
|
|
event_type='interaction.completed',
|
|
interaction=interaction,
|
|
),
|
|
]
|
|
|
|
|
|
def _build_legacy_streamed_events(fc_step: FunctionCallStep) -> list[object]:
|
|
"""Build streamed events with the ID on the legacy interaction event."""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
interaction = Interaction(
|
|
id='interaction_legacy_123',
|
|
created=now,
|
|
updated=now,
|
|
status='requires_action',
|
|
steps=[fc_step],
|
|
)
|
|
|
|
return [
|
|
interaction,
|
|
]
|
|
|
|
|
|
async def _collect_function_call_interaction_ids(
|
|
streamed_events: list[object],
|
|
) -> list[str | None]:
|
|
"""Collect non-partial function call interaction IDs from streamed events."""
|
|
responses = [
|
|
response
|
|
async for response in (
|
|
interactions_utils.generate_content_via_interactions(
|
|
api_client=_FakeApiClient(streamed_events),
|
|
llm_request=_build_llm_request(),
|
|
stream=True,
|
|
)
|
|
)
|
|
]
|
|
|
|
return [
|
|
response.interaction_id
|
|
for response in responses
|
|
if response.partial is not True
|
|
and response.content is not None
|
|
and response.content.parts
|
|
and response.content.parts[0].function_call is not None
|
|
]
|
|
|
|
|
|
class TestConvertPartToInteractionContent:
|
|
"""Tests for _convert_part_to_interaction_content."""
|
|
|
|
def test_text_part(self):
|
|
"""Test converting a text Part."""
|
|
part = types.Part(text='Hello, world!')
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'user_input',
|
|
'content': [{'type': 'text', 'text': 'Hello, world!'}],
|
|
}
|
|
|
|
def test_text_part_model_role(self):
|
|
"""Test converting a text Part for model role."""
|
|
part = types.Part(text='Hello, user!')
|
|
result = interactions_utils._convert_part_to_interaction_content(
|
|
part, role='model'
|
|
)
|
|
assert result == {
|
|
'type': 'model_output',
|
|
'content': [{'type': 'text', 'text': 'Hello, user!'}],
|
|
}
|
|
|
|
def test_function_call_part(self):
|
|
"""Test converting a function call Part."""
|
|
part = types.Part(
|
|
function_call=types.FunctionCall(
|
|
id='call_123',
|
|
name='get_weather',
|
|
args={'city': 'London'},
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'function_call',
|
|
'id': 'call_123',
|
|
'name': 'get_weather',
|
|
'arguments': {'city': 'London'},
|
|
}
|
|
|
|
def test_function_call_part_no_id(self):
|
|
"""Test converting a function call Part without id."""
|
|
part = types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='get_weather',
|
|
args={'city': 'London'},
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result['id'] == ''
|
|
assert result['name'] == 'get_weather'
|
|
|
|
def test_function_call_part_thought_signature_dropped(self):
|
|
"""Thought signatures are not sent on interactions function call steps."""
|
|
part = types.Part(
|
|
function_call=types.FunctionCall(
|
|
id='call_456',
|
|
name='my_tool',
|
|
args={'doc': 'content'},
|
|
),
|
|
thought_signature=b'test_signature_bytes',
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'function_call',
|
|
'id': 'call_456',
|
|
'name': 'my_tool',
|
|
'arguments': {'doc': 'content'},
|
|
}
|
|
assert 'signature' not in result
|
|
|
|
def test_function_call_part_without_thought_signature(self):
|
|
"""Test converting a function call Part without thought_signature."""
|
|
part = types.Part(
|
|
function_call=types.FunctionCall(
|
|
id='call_789',
|
|
name='other_tool',
|
|
args={},
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result['type'] == 'function_call'
|
|
# signature should not be present
|
|
assert 'signature' not in result
|
|
|
|
def test_function_response_dict(self):
|
|
"""Test converting a function response Part with dict response."""
|
|
part = types.Part(
|
|
function_response=types.FunctionResponse(
|
|
id='call_123',
|
|
name='get_weather',
|
|
response={'temperature': 20, 'condition': 'sunny'},
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result['type'] == 'function_result'
|
|
assert result['call_id'] == 'call_123'
|
|
assert result['name'] == 'get_weather'
|
|
# Dict should be passed through directly (not JSON-serialized)
|
|
assert result['result'] == {
|
|
'temperature': 20,
|
|
'condition': 'sunny',
|
|
}
|
|
|
|
def test_function_response_simple(self):
|
|
"""Test converting a function response Part with simple response."""
|
|
part = types.Part(
|
|
function_response=types.FunctionResponse(
|
|
id='call_123',
|
|
name='check_weather',
|
|
response={'message': 'Weather is sunny'},
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result['type'] == 'function_result'
|
|
assert result['call_id'] == 'call_123'
|
|
assert result['name'] == 'check_weather'
|
|
# Dict should be JSON serialized
|
|
assert result['result'] == {'message': 'Weather is sunny'}
|
|
|
|
def test_convert_part_to_interaction_content_function_response_error(self):
|
|
part = types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name='my_function',
|
|
id='call_123',
|
|
response={'error': 'something went wrong'},
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == interactions.FunctionResultStepParam(
|
|
type='function_result',
|
|
name='my_function',
|
|
call_id='call_123',
|
|
result={'error': 'something went wrong'},
|
|
is_error=True,
|
|
)
|
|
|
|
def test_function_response_dict_not_double_serialized(self):
|
|
"""Regression test: avoid double-serializing bash tool outputs.
|
|
|
|
Bash tool responses contain JSON structures (stdout/stderr). When these
|
|
dict responses were json.dumps()'d before being sent to the Interactions
|
|
API, the API's own serialization would escape the already-escaped content,
|
|
producing unreadable output like:
|
|
{"result":"\\\"{\\\\\\\"error\\\\\\\":\\\\\\\"...\\\\\\\"}\\\""
|
|
"""
|
|
bash_response = {
|
|
'stdout': '{"name": "test", "version": "1.0"}\n',
|
|
'stderr': '',
|
|
}
|
|
part = types.Part(
|
|
function_response=types.FunctionResponse(
|
|
id='call_bash',
|
|
name='bash',
|
|
response=bash_response,
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
# The result value must be the dict itself, NOT a JSON string.
|
|
assert isinstance(result['result'], dict)
|
|
assert result['result'] == bash_response
|
|
# Verify there's no double-escaping: if result were a JSON string,
|
|
# serializing it again would add backslashes before the internal quotes.
|
|
wire_json = json.dumps(result)
|
|
assert '\\\\' not in wire_json
|
|
|
|
def test_inline_data_image(self):
|
|
"""Test converting an inline image Part."""
|
|
part = types.Part(
|
|
inline_data=types.Blob(
|
|
data=b'image_data',
|
|
mime_type='image/png',
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'user_input',
|
|
'content': [{
|
|
'type': 'image',
|
|
'data': (
|
|
'aW1hZ2VfZGF0YQ=='
|
|
), # base64.b64encode(b'image_data').decode('utf-8')
|
|
'mime_type': 'image/png',
|
|
}],
|
|
}
|
|
|
|
def test_inline_data_audio(self):
|
|
"""Test converting an inline audio Part."""
|
|
part = types.Part(
|
|
inline_data=types.Blob(
|
|
data=b'audio_data',
|
|
mime_type='audio/mp3',
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'user_input',
|
|
'content': [{
|
|
'type': 'audio',
|
|
'data': (
|
|
'YXVkaW9fZGF0YQ=='
|
|
), # base64.b64encode(b'audio_data').decode('utf-8')
|
|
'mime_type': 'audio/mp3',
|
|
}],
|
|
}
|
|
|
|
def test_inline_data_video(self):
|
|
"""Test converting an inline video Part."""
|
|
part = types.Part(
|
|
inline_data=types.Blob(
|
|
data=b'video_data',
|
|
mime_type='video/mp4',
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'user_input',
|
|
'content': [{
|
|
'type': 'video',
|
|
'data': (
|
|
'dmlkZW9fZGF0YQ=='
|
|
), # base64.b64encode(b'video_data').decode('utf-8')
|
|
'mime_type': 'video/mp4',
|
|
}],
|
|
}
|
|
|
|
def test_inline_data_document(self):
|
|
"""Test converting an inline document Part."""
|
|
part = types.Part(
|
|
inline_data=types.Blob(
|
|
data=b'doc_data',
|
|
mime_type='application/pdf',
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'user_input',
|
|
'content': [{
|
|
'type': 'document',
|
|
'data': (
|
|
'ZG9jX2RhdGE='
|
|
), # base64.b64encode(b'doc_data').decode('utf-8')
|
|
'mime_type': 'application/pdf',
|
|
}],
|
|
}
|
|
|
|
def test_file_data_image(self):
|
|
"""Test converting a file data image Part."""
|
|
part = types.Part(
|
|
file_data=types.FileData(
|
|
file_uri='gs://bucket/image.png',
|
|
mime_type='image/png',
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'user_input',
|
|
'content': [{
|
|
'type': 'image',
|
|
'uri': 'gs://bucket/image.png',
|
|
'mime_type': 'image/png',
|
|
}],
|
|
}
|
|
|
|
def test_text_with_thought_flag(self):
|
|
"""Test converting a text Part with thought=True flag."""
|
|
# In types.Part, thought is a boolean flag on text content
|
|
# When text is present, the convert function returns text type (not thought)
|
|
# because text check comes before thought check in the implementation
|
|
part = types.Part(text='Let me think about this...', thought=True)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
# Text content is returned as-is (thought flag not represented in output)
|
|
assert result == {
|
|
'type': 'user_input',
|
|
'content': [{'type': 'text', 'text': 'Let me think about this...'}],
|
|
}
|
|
|
|
def test_thought_only_part(self):
|
|
"""Test converting a thought-only Part with signature."""
|
|
signature_bytes = b'test-thought-signature'
|
|
part = types.Part(thought=True, thought_signature=signature_bytes)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
expected_signature = base64.b64encode(signature_bytes).decode('utf-8')
|
|
assert result == {'type': 'thought', 'signature': expected_signature}
|
|
|
|
def test_thought_only_part_without_signature(self):
|
|
"""Test converting a thought-only Part without signature."""
|
|
part = types.Part(thought=True)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {'type': 'thought'}
|
|
|
|
def test_code_execution_result(self):
|
|
"""Test converting a code execution result Part."""
|
|
part = types.Part(
|
|
code_execution_result=types.CodeExecutionResult(
|
|
output='Hello from code',
|
|
outcome=types.Outcome.OUTCOME_OK,
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'code_execution_result',
|
|
'call_id': '',
|
|
'result': 'Hello from code',
|
|
'is_error': False,
|
|
}
|
|
|
|
def test_code_execution_result_with_error(self):
|
|
"""Test converting a failed code execution result Part."""
|
|
part = types.Part(
|
|
code_execution_result=types.CodeExecutionResult(
|
|
output='Error: something went wrong',
|
|
outcome=types.Outcome.OUTCOME_FAILED,
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'code_execution_result',
|
|
'call_id': '',
|
|
'result': 'Error: something went wrong',
|
|
'is_error': True,
|
|
}
|
|
|
|
def test_code_execution_result_deadline_exceeded(self):
|
|
"""Test converting a deadline exceeded code execution result Part."""
|
|
part = types.Part(
|
|
code_execution_result=types.CodeExecutionResult(
|
|
output='Timeout',
|
|
outcome=types.Outcome.OUTCOME_DEADLINE_EXCEEDED,
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'code_execution_result',
|
|
'call_id': '',
|
|
'result': 'Timeout',
|
|
'is_error': True,
|
|
}
|
|
|
|
def test_executable_code(self):
|
|
"""Test converting an executable code Part."""
|
|
part = types.Part(
|
|
executable_code=types.ExecutableCode(
|
|
code='print("hello")',
|
|
language='PYTHON',
|
|
)
|
|
)
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result == {
|
|
'type': 'code_execution_call',
|
|
'id': '',
|
|
'arguments': {
|
|
'code': 'print("hello")',
|
|
'language': 'PYTHON',
|
|
},
|
|
}
|
|
|
|
def test_empty_part(self):
|
|
"""Test converting an empty Part returns None."""
|
|
part = types.Part()
|
|
result = interactions_utils._convert_part_to_interaction_content(part)
|
|
assert result is None
|
|
|
|
|
|
class TestConvertContentToStep:
|
|
"""Tests for _convert_content_to_step."""
|
|
|
|
def test_user_content(self):
|
|
"""Test converting user content."""
|
|
content = types.Content(
|
|
role='user',
|
|
parts=[types.Part(text='Hello!')],
|
|
)
|
|
result = interactions_utils._convert_content_to_step(content)
|
|
assert result == [{
|
|
'type': 'user_input',
|
|
'content': [{'type': 'text', 'text': 'Hello!'}],
|
|
}]
|
|
|
|
def test_model_content(self):
|
|
"""Test converting model content."""
|
|
content = types.Content(
|
|
role='model',
|
|
parts=[types.Part(text='Hi there!')],
|
|
)
|
|
result = interactions_utils._convert_content_to_step(content)
|
|
assert result == [{
|
|
'type': 'model_output',
|
|
'content': [{'type': 'text', 'text': 'Hi there!'}],
|
|
}]
|
|
|
|
def test_multiple_parts(self):
|
|
"""Test converting content with multiple parts."""
|
|
content = types.Content(
|
|
role='user',
|
|
parts=[
|
|
types.Part(text='Look at this:'),
|
|
types.Part(
|
|
inline_data=types.Blob(data=b'img', mime_type='image/png')
|
|
),
|
|
],
|
|
)
|
|
result = interactions_utils._convert_content_to_step(content)
|
|
assert len(result) == 2
|
|
assert result[0]['type'] == 'user_input'
|
|
assert result[0]['content'][0] == {'type': 'text', 'text': 'Look at this:'}
|
|
assert result[1]['type'] == 'user_input'
|
|
assert result[1]['content'][0]['type'] == 'image'
|
|
|
|
def test_interleaved_parts(self):
|
|
"""Test converting content with interleaved text and media parts."""
|
|
content = types.Content(
|
|
role='user',
|
|
parts=[
|
|
types.Part(text='First:'),
|
|
types.Part(
|
|
inline_data=types.Blob(data=b'img1', mime_type='image/png')
|
|
),
|
|
types.Part(text='Second:'),
|
|
types.Part(
|
|
inline_data=types.Blob(data=b'img2', mime_type='image/jpeg')
|
|
),
|
|
types.Part(text='End'),
|
|
],
|
|
)
|
|
result = interactions_utils._convert_content_to_step(content)
|
|
assert len(result) == 5
|
|
assert result[0]['type'] == 'user_input'
|
|
assert result[0]['content'][0] == {'type': 'text', 'text': 'First:'}
|
|
assert result[1]['type'] == 'user_input'
|
|
assert result[1]['content'][0]['type'] == 'image'
|
|
assert result[2]['type'] == 'user_input'
|
|
assert result[2]['content'][0] == {'type': 'text', 'text': 'Second:'}
|
|
assert result[3]['type'] == 'user_input'
|
|
assert result[3]['content'][0]['type'] == 'image'
|
|
assert result[4]['type'] == 'user_input'
|
|
assert result[4]['content'][0] == {'type': 'text', 'text': 'End'}
|
|
|
|
def test_default_role(self):
|
|
"""Test that default role is 'user' when not specified."""
|
|
content = types.Content(parts=[types.Part(text='Hi')])
|
|
result = interactions_utils._convert_content_to_step(content)
|
|
assert result[0]['type'] == 'user_input'
|
|
|
|
|
|
class TestConvertContentsToSteps:
|
|
"""Tests for convert_contents_to_steps."""
|
|
|
|
def test_single_content(self):
|
|
"""Test converting a list with single content."""
|
|
contents = [
|
|
types.Content(role='user', parts=[types.Part(text='What is 2+2?')]),
|
|
]
|
|
result = interactions_utils._convert_contents_to_steps(contents)
|
|
assert len(result) == 1
|
|
assert result[0]['type'] == 'user_input'
|
|
assert result[0]['content'][0]['text'] == 'What is 2+2?'
|
|
|
|
def test_multi_turn_conversation(self):
|
|
"""Test converting a multi-turn conversation."""
|
|
contents = [
|
|
types.Content(role='user', parts=[types.Part(text='Hi')]),
|
|
types.Content(role='model', parts=[types.Part(text='Hello!')]),
|
|
types.Content(role='user', parts=[types.Part(text='How are you?')]),
|
|
]
|
|
result = interactions_utils._convert_contents_to_steps(contents)
|
|
assert len(result) == 3
|
|
assert result[0]['type'] == 'user_input'
|
|
assert result[1]['type'] == 'model_output'
|
|
assert result[2]['type'] == 'user_input'
|
|
|
|
def test_empty_content_skipped(self):
|
|
"""Test that empty contents are skipped."""
|
|
contents = [
|
|
types.Content(role='user', parts=[types.Part(text='Hi')]),
|
|
types.Content(role='model', parts=[]), # Empty parts
|
|
]
|
|
result = interactions_utils._convert_contents_to_steps(contents)
|
|
# Only the first content should be included
|
|
assert len(result) == 1
|
|
|
|
|
|
class TestConvertToolsConfig:
|
|
"""Tests for _convert_tools_config_to_interactions_format."""
|
|
|
|
def test_function_declaration(self):
|
|
"""Test converting function declarations."""
|
|
config = types.GenerateContentConfig(
|
|
tools=[
|
|
types.Tool(
|
|
function_declarations=[
|
|
types.FunctionDeclaration(
|
|
name='get_weather',
|
|
description='Get weather for a city',
|
|
parameters=types.Schema(
|
|
type='OBJECT',
|
|
properties={
|
|
'city': types.Schema(type='STRING'),
|
|
},
|
|
required=['city'],
|
|
),
|
|
)
|
|
]
|
|
)
|
|
]
|
|
)
|
|
result = interactions_utils.convert_tools_config_to_interactions_format(
|
|
config
|
|
)
|
|
assert len(result) == 1
|
|
assert result[0]['type'] == 'function'
|
|
assert result[0]['name'] == 'get_weather'
|
|
assert result[0]['description'] == 'Get weather for a city'
|
|
assert 'parameters' in result[0]
|
|
|
|
def test_google_search_tool(self):
|
|
"""Test converting google search tool."""
|
|
config = types.GenerateContentConfig(
|
|
tools=[types.Tool(google_search=types.GoogleSearch())]
|
|
)
|
|
result = interactions_utils.convert_tools_config_to_interactions_format(
|
|
config
|
|
)
|
|
assert result == [{'type': 'google_search'}]
|
|
|
|
def test_code_execution_tool(self):
|
|
"""Test converting code execution tool."""
|
|
config = types.GenerateContentConfig(
|
|
tools=[types.Tool(code_execution=types.ToolCodeExecution())]
|
|
)
|
|
result = interactions_utils.convert_tools_config_to_interactions_format(
|
|
config
|
|
)
|
|
assert result == [{'type': 'code_execution'}]
|
|
|
|
def test_no_tools(self):
|
|
"""Test handling config with no tools."""
|
|
config = types.GenerateContentConfig()
|
|
result = interactions_utils.convert_tools_config_to_interactions_format(
|
|
config
|
|
)
|
|
assert result == []
|
|
|
|
|
|
class TestConvertInteractionOutputToParts:
|
|
"""Tests for convert_interaction_output_to_parts."""
|
|
|
|
def test_text_output(self):
|
|
"""Test converting text output."""
|
|
output = ModelOutputStep(
|
|
type='model_output', content=[TextContent(type='text', text='Hello!')]
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.text == 'Hello!'
|
|
|
|
def test_function_call_output(self):
|
|
"""Test converting function call output."""
|
|
output = FunctionCallStep(
|
|
type='function_call',
|
|
id='call_123',
|
|
name='get_weather',
|
|
arguments={'city': 'London'},
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.function_call.id == 'call_123'
|
|
assert result.function_call.name == 'get_weather'
|
|
assert result.function_call.args == {'city': 'London'}
|
|
|
|
def test_function_call_output_without_thought_signature(self):
|
|
"""Test converting function call output without thought_signature."""
|
|
output = FunctionCallStep(
|
|
type='function_call',
|
|
id='call_no_sig',
|
|
name='regular_tool',
|
|
arguments={},
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.function_call.id == 'call_no_sig'
|
|
assert result.function_call.name == 'regular_tool'
|
|
# thought_signature should be None
|
|
assert result.thought_signature is None
|
|
|
|
def test_function_result_output(self):
|
|
"""Test converting function result output."""
|
|
output = FunctionResultStep(
|
|
type='function_result',
|
|
call_id='call_123',
|
|
result={'weather': 'Sunny'},
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.function_response.id == 'call_123'
|
|
assert result.function_response.response == {'weather': 'Sunny'}
|
|
|
|
def test_function_result_output_preserves_none_values(self):
|
|
"""None values in a dict result must not be dropped."""
|
|
output = FunctionResultStep(
|
|
type='function_result',
|
|
call_id='call_none',
|
|
result={'data': None, 'ok': True},
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.function_response.response == {'data': None, 'ok': True}
|
|
|
|
def test_function_result_output_string(self):
|
|
"""A plain string result is wrapped under a 'result' key."""
|
|
output = FunctionResultStep(
|
|
type='function_result',
|
|
call_id='call_str',
|
|
result='plain text',
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.function_response.response == {'result': 'plain text'}
|
|
|
|
def test_function_result_output_list(self):
|
|
"""A list result of content blocks is wrapped under a 'result' key."""
|
|
output = FunctionResultStep(
|
|
type='function_result',
|
|
call_id='call_list',
|
|
result=[{'type': 'text', 'text': 'hi'}],
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
wrapped = result.function_response.response['result']
|
|
assert wrapped[0]['type'] == 'text'
|
|
assert wrapped[0]['text'] == 'hi'
|
|
|
|
def test_image_output_with_data(self):
|
|
"""Test converting image output with inline data."""
|
|
output = ModelOutputStep(
|
|
type='model_output',
|
|
content=[
|
|
ImageContent(
|
|
type='image',
|
|
data=base64.b64encode(b'image_bytes').decode('utf-8'),
|
|
mime_type='image/png',
|
|
)
|
|
],
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.inline_data.data == b'image_bytes'
|
|
assert result.inline_data.mime_type == 'image/png'
|
|
|
|
def test_image_output_with_uri(self):
|
|
"""Test converting image output with URI."""
|
|
output = ModelOutputStep(
|
|
type='model_output',
|
|
content=[
|
|
ImageContent(
|
|
type='image',
|
|
uri='gs://bucket/image.png',
|
|
mime_type='image/png',
|
|
)
|
|
],
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.file_data.file_uri == 'gs://bucket/image.png'
|
|
assert result.file_data.mime_type == 'image/png'
|
|
|
|
def test_code_execution_result_output(self):
|
|
"""Test converting code execution result output."""
|
|
output = CodeExecutionResultStep(
|
|
type='code_execution_result',
|
|
call_id='',
|
|
result='Output from code',
|
|
is_error=False,
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.code_execution_result.output == 'Output from code'
|
|
assert result.code_execution_result.outcome == types.Outcome.OUTCOME_OK
|
|
|
|
def test_code_execution_result_error_output(self):
|
|
"""Test converting code execution result output with error."""
|
|
output = CodeExecutionResultStep(
|
|
type='code_execution_result',
|
|
call_id='',
|
|
result='Error: division by zero',
|
|
is_error=True,
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result.code_execution_result.output == 'Error: division by zero'
|
|
assert result.code_execution_result.outcome == types.Outcome.OUTCOME_FAILED
|
|
|
|
def test_thought_output_returns_empty(self):
|
|
"""Test that thought output returns empty list (not exposed as Part)."""
|
|
output = ThoughtStep(type='thought', signature='thinking...')
|
|
result = interactions_utils._convert_interaction_step_to_parts(output)
|
|
assert result == []
|
|
|
|
def test_no_type_attribute(self):
|
|
"""Test handling output without type attribute."""
|
|
output = MagicMock(spec=[]) # No 'type' attribute
|
|
result = interactions_utils._convert_interaction_step_to_parts(output)
|
|
assert result == []
|
|
|
|
def test_code_execution_call_output_uppercase_python(self):
|
|
"""Test converting code execution call output with uppercase PYTHON."""
|
|
from google.genai.interactions import CodeExecutionCallStep
|
|
|
|
mock_args = MagicMock()
|
|
mock_args.code = 'print("hello")'
|
|
mock_args.language = 'PYTHON'
|
|
|
|
output = CodeExecutionCallStep.model_construct(
|
|
type='code_execution_call',
|
|
id='',
|
|
arguments=mock_args,
|
|
)
|
|
result_list = interactions_utils._convert_interaction_step_to_parts(output)
|
|
result = result_list[0] if result_list else None
|
|
assert result is not None
|
|
assert result.executable_code.code == 'print("hello")'
|
|
assert result.executable_code.language == types.Language.PYTHON
|
|
|
|
|
|
class TestConvertInteractionToLlmResponse:
|
|
"""Tests for convert_interaction_to_llm_response."""
|
|
|
|
def test_successful_text_response(self):
|
|
"""Test converting a successful text response."""
|
|
interaction = Interaction(
|
|
id='interaction_123',
|
|
status='completed',
|
|
created=datetime.now(timezone.utc).isoformat(),
|
|
updated=datetime.now(timezone.utc).isoformat(),
|
|
steps=[
|
|
ModelOutputStep(
|
|
type='model_output',
|
|
content=[TextContent(type='text', text='The answer is 4.')],
|
|
)
|
|
],
|
|
usage=Usage(total_input_tokens=10, total_output_tokens=5),
|
|
)
|
|
result = interactions_utils.convert_interaction_to_llm_response(interaction)
|
|
|
|
assert result.interaction_id == 'interaction_123'
|
|
assert result.content.parts[0].text == 'The answer is 4.'
|
|
assert result.usage_metadata.prompt_token_count == 10
|
|
assert result.usage_metadata.candidates_token_count == 5
|
|
assert result.finish_reason == types.FinishReason.STOP
|
|
assert result.turn_complete is True
|
|
|
|
def test_failed_response(self):
|
|
"""Test converting a failed response."""
|
|
interaction = Interaction(
|
|
id='interaction_123',
|
|
status='failed',
|
|
created=datetime.now(timezone.utc).isoformat(),
|
|
updated=datetime.now(timezone.utc).isoformat(),
|
|
steps=[],
|
|
)
|
|
interaction.error = MagicMock(code='INVALID_REQUEST', message='Bad request')
|
|
|
|
result = interactions_utils.convert_interaction_to_llm_response(interaction)
|
|
|
|
assert result.interaction_id == 'interaction_123'
|
|
assert result.error_code == 'INVALID_REQUEST'
|
|
assert result.error_message == 'Bad request'
|
|
|
|
def test_requires_action_response(self):
|
|
"""Test converting a requires_action response (function call)."""
|
|
interaction = Interaction(
|
|
id='interaction_123',
|
|
status='requires_action',
|
|
created=datetime.now(timezone.utc).isoformat(),
|
|
updated=datetime.now(timezone.utc).isoformat(),
|
|
steps=[
|
|
FunctionCallStep(
|
|
type='function_call',
|
|
id='call_1',
|
|
name='get_weather',
|
|
arguments={'city': 'Paris'},
|
|
)
|
|
],
|
|
)
|
|
result = interactions_utils.convert_interaction_to_llm_response(interaction)
|
|
|
|
assert result.interaction_id == 'interaction_123'
|
|
assert result.content.parts[0].function_call.name == 'get_weather'
|
|
assert result.finish_reason == types.FinishReason.STOP
|
|
assert result.turn_complete is True
|
|
|
|
|
|
class TestBuildGenerationConfig:
|
|
"""Tests for build_generation_config."""
|
|
|
|
def test_all_parameters(self):
|
|
"""Test building config with all parameters."""
|
|
config = types.GenerateContentConfig(
|
|
temperature=0.7,
|
|
top_p=0.9,
|
|
top_k=40,
|
|
max_output_tokens=100,
|
|
stop_sequences=['END'],
|
|
presence_penalty=0.5,
|
|
frequency_penalty=0.3,
|
|
)
|
|
result = interactions_utils.build_generation_config(config)
|
|
assert result == {
|
|
'temperature': 0.7,
|
|
'top_p': 0.9,
|
|
'top_k': 40,
|
|
'max_output_tokens': 100,
|
|
'stop_sequences': ['END'],
|
|
'presence_penalty': 0.5,
|
|
'frequency_penalty': 0.3,
|
|
}
|
|
|
|
def test_partial_parameters(self):
|
|
"""Test building config with partial parameters."""
|
|
config = types.GenerateContentConfig(
|
|
temperature=0.5,
|
|
max_output_tokens=50,
|
|
)
|
|
result = interactions_utils.build_generation_config(config)
|
|
assert result == {
|
|
'temperature': 0.5,
|
|
'max_output_tokens': 50,
|
|
}
|
|
|
|
def test_empty_config(self):
|
|
"""Test building config with no parameters."""
|
|
config = types.GenerateContentConfig()
|
|
result = interactions_utils.build_generation_config(config)
|
|
assert result == {}
|
|
|
|
|
|
class TestExtractSystemInstruction:
|
|
"""Tests for extract_system_instruction."""
|
|
|
|
def test_string_instruction(self):
|
|
"""Test extracting string system instruction."""
|
|
config = types.GenerateContentConfig(
|
|
system_instruction='You are a helpful assistant.'
|
|
)
|
|
result = interactions_utils.extract_system_instruction(config)
|
|
assert result == 'You are a helpful assistant.'
|
|
|
|
def test_content_instruction(self):
|
|
"""Test extracting Content system instruction."""
|
|
config = types.GenerateContentConfig(
|
|
system_instruction=types.Content(
|
|
parts=[
|
|
types.Part(text='Be helpful.'),
|
|
types.Part(text='Be concise.'),
|
|
]
|
|
)
|
|
)
|
|
result = interactions_utils.extract_system_instruction(config)
|
|
assert result == 'Be helpful.\nBe concise.'
|
|
|
|
def test_no_instruction(self):
|
|
"""Test extracting when no system instruction."""
|
|
config = types.GenerateContentConfig()
|
|
result = interactions_utils.extract_system_instruction(config)
|
|
assert result is None
|
|
|
|
|
|
class TestLlmRequestPreviousInteractionId:
|
|
"""Tests for previous_interaction_id field in LlmRequest."""
|
|
|
|
def test_previous_interaction_id_default_none(self):
|
|
"""Test that previous_interaction_id defaults to None."""
|
|
request = LlmRequest(model='gemini-2.5-flash', contents=[])
|
|
assert request.previous_interaction_id is None
|
|
|
|
def test_previous_interaction_id_can_be_set(self):
|
|
"""Test that previous_interaction_id can be set."""
|
|
request = LlmRequest(
|
|
model='gemini-2.5-flash',
|
|
contents=[],
|
|
previous_interaction_id='interaction_abc',
|
|
)
|
|
assert request.previous_interaction_id == 'interaction_abc'
|
|
|
|
|
|
class TestLlmResponseInteractionId:
|
|
"""Tests for interaction_id field in LlmResponse."""
|
|
|
|
def test_interaction_id_in_response(self):
|
|
"""Test that interaction_id is properly set in LlmResponse."""
|
|
from google.adk.models.llm_response import LlmResponse
|
|
|
|
response = LlmResponse(
|
|
content=types.Content(role='model', parts=[types.Part(text='Hi')]),
|
|
interaction_id='interaction_xyz',
|
|
)
|
|
assert response.interaction_id == 'interaction_xyz'
|
|
|
|
def test_interaction_id_default_none(self):
|
|
"""Test that interaction_id defaults to None."""
|
|
from google.adk.models.llm_response import LlmResponse
|
|
|
|
response = LlmResponse(
|
|
content=types.Content(role='model', parts=[types.Part(text='Hi')]),
|
|
)
|
|
assert response.interaction_id is None
|
|
|
|
|
|
class TestGetLatestUserContents:
|
|
"""Tests for _get_latest_user_contents."""
|
|
|
|
def test_empty_contents(self):
|
|
"""Test with empty contents list."""
|
|
result = interactions_utils._get_latest_user_contents([])
|
|
assert result == []
|
|
|
|
def test_single_user_message(self):
|
|
"""Test with a single user message."""
|
|
contents = [
|
|
types.Content(role='user', parts=[types.Part(text='Hello')]),
|
|
]
|
|
result = interactions_utils._get_latest_user_contents(contents)
|
|
assert len(result) == 1
|
|
assert result[0].parts[0].text == 'Hello'
|
|
|
|
def test_consecutive_user_messages(self):
|
|
"""Test with multiple consecutive user messages at the end."""
|
|
contents = [
|
|
types.Content(role='model', parts=[types.Part(text='Response')]),
|
|
types.Content(role='user', parts=[types.Part(text='First')]),
|
|
types.Content(role='user', parts=[types.Part(text='Second')]),
|
|
]
|
|
result = interactions_utils._get_latest_user_contents(contents)
|
|
assert len(result) == 2
|
|
assert result[0].parts[0].text == 'First'
|
|
assert result[1].parts[0].text == 'Second'
|
|
|
|
def test_stops_at_model_message(self):
|
|
"""Test that it stops when encountering a model message."""
|
|
contents = [
|
|
types.Content(role='user', parts=[types.Part(text='First user')]),
|
|
types.Content(role='model', parts=[types.Part(text='Model response')]),
|
|
types.Content(role='user', parts=[types.Part(text='Second user')]),
|
|
]
|
|
result = interactions_utils._get_latest_user_contents(contents)
|
|
assert len(result) == 1
|
|
assert result[0].parts[0].text == 'Second user'
|
|
|
|
def test_all_model_messages(self):
|
|
"""Test with only model messages returns empty list."""
|
|
contents = [
|
|
types.Content(role='model', parts=[types.Part(text='Response 1')]),
|
|
types.Content(role='model', parts=[types.Part(text='Response 2')]),
|
|
]
|
|
result = interactions_utils._get_latest_user_contents(contents)
|
|
assert result == []
|
|
|
|
def test_full_conversation(self):
|
|
"""Test with a full conversation, returns only latest user turn."""
|
|
contents = [
|
|
types.Content(role='user', parts=[types.Part(text='Hi')]),
|
|
types.Content(role='model', parts=[types.Part(text='Hello!')]),
|
|
types.Content(role='user', parts=[types.Part(text='How are you?')]),
|
|
types.Content(role='model', parts=[types.Part(text='I am fine.')]),
|
|
types.Content(role='user', parts=[types.Part(text='Great')]),
|
|
types.Content(role='user', parts=[types.Part(text='Tell me more')]),
|
|
]
|
|
result = interactions_utils._get_latest_user_contents(contents)
|
|
assert len(result) == 2
|
|
assert result[0].parts[0].text == 'Great'
|
|
assert result[1].parts[0].text == 'Tell me more'
|
|
|
|
|
|
class TestConvertInteractionEventToLlmResponse:
|
|
"""Tests for convert_interaction_event_to_llm_response."""
|
|
|
|
def test_text_delta_event(self):
|
|
"""Test converting a text delta event."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'text', 'text': 'Hello world'},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_123'
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.partial
|
|
assert result.content.parts[0].text == 'Hello world'
|
|
assert result.interaction_id == 'int_123'
|
|
assert len(state.parts) == 1
|
|
|
|
def test_image_delta_with_data(self):
|
|
"""Test converting an image delta with inline data."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'image',
|
|
'data': base64.b64encode(b'image_bytes').decode('utf-8'),
|
|
'mime_type': 'image/png',
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_img'
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.partial
|
|
assert result.content.parts[0].inline_data.data == b'image_bytes'
|
|
assert len(state.parts) == 1
|
|
|
|
def test_thought_summary_delta(self):
|
|
"""thought_summary delta becomes a thought part."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'thought_summary',
|
|
'content': {'type': 'text', 'text': 'Let me think...'},
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_t'
|
|
)
|
|
assert result is not None
|
|
assert result.partial is True
|
|
part = result.content.parts[0]
|
|
assert part.text == 'Let me think...'
|
|
assert part.thought is True
|
|
assert len(state.parts) == 1
|
|
|
|
def test_thought_signature_delta_attaches_to_last_thought(self):
|
|
"""thought_signature mutates the last thought part and emits no event."""
|
|
state = interactions_utils._StreamState()
|
|
interactions_utils.convert_interaction_event_to_llm_response(
|
|
StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'thought_summary',
|
|
'content': {'type': 'text', 'text': 'reasoning'},
|
|
},
|
|
),
|
|
state,
|
|
interaction_id='int_ts',
|
|
)
|
|
sig_b64 = base64.b64encode(b'sig-bytes').decode('utf-8')
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'thought_signature', 'signature': sig_b64},
|
|
),
|
|
state,
|
|
interaction_id='int_ts',
|
|
)
|
|
assert result is None
|
|
assert state.parts[-1].thought_signature == b'sig-bytes'
|
|
|
|
def test_audio_delta_with_data(self):
|
|
"""audio delta becomes an inline_data part via the shared media handler."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'audio',
|
|
'data': base64.b64encode(b'audio_bytes').decode('utf-8'),
|
|
'mime_type': 'audio/wav',
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_a'
|
|
)
|
|
assert result is not None
|
|
assert result.partial is True
|
|
assert result.content.parts[0].inline_data.data == b'audio_bytes'
|
|
assert result.content.parts[0].inline_data.mime_type == 'audio/wav'
|
|
assert len(state.parts) == 1
|
|
|
|
def test_code_execution_call_delta(self):
|
|
"""code_execution_call delta becomes an executable_code part."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'code_execution_call',
|
|
'arguments': {'code': 'print(1)', 'language': 'python'},
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_c'
|
|
)
|
|
assert result is not None
|
|
part = result.content.parts[0]
|
|
assert part.executable_code.code == 'print(1)'
|
|
assert part.executable_code.language == types.Language.PYTHON
|
|
assert len(state.parts) == 1
|
|
|
|
def test_code_execution_result_delta(self):
|
|
"""code_execution_result delta becomes a code_execution_result part."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'code_execution_result',
|
|
'result': '1\n',
|
|
'is_error': False,
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_cr'
|
|
)
|
|
assert result is not None
|
|
part = result.content.parts[0]
|
|
assert part.code_execution_result.output == '1\n'
|
|
assert part.code_execution_result.outcome == types.Outcome.OUTCOME_OK
|
|
assert len(state.parts) == 1
|
|
|
|
def test_code_execution_result_error_delta(self):
|
|
"""code_execution_result with is_error maps to OUTCOME_FAILED."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'code_execution_result',
|
|
'result': 'Traceback (most recent call last): ...',
|
|
'is_error': True,
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_cr_err'
|
|
)
|
|
assert result is not None
|
|
part = result.content.parts[0]
|
|
assert (
|
|
part.code_execution_result.output
|
|
== 'Traceback (most recent call last): ...'
|
|
)
|
|
assert part.code_execution_result.outcome == types.Outcome.OUTCOME_FAILED
|
|
assert len(state.parts) == 1
|
|
|
|
def test_google_search_call_delta(self):
|
|
"""google_search_call delta emits partial grounding web_search_queries."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'google_search_call',
|
|
'arguments': {'queries': ['rocky project hail mary']},
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_s'
|
|
)
|
|
assert result is not None
|
|
assert result.partial is True
|
|
assert result.content is None
|
|
assert result.grounding_metadata.web_search_queries == [
|
|
'rocky project hail mary'
|
|
]
|
|
assert state.web_search_queries == ['rocky project hail mary']
|
|
|
|
def test_google_search_result_delta(self):
|
|
"""google_search_result delta emits a partial search_entry_point."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'google_search_result',
|
|
'result': [{'search_suggestions': '<div>suggestions</div>'}],
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_sr'
|
|
)
|
|
assert result is not None
|
|
assert result.partial is True
|
|
assert (
|
|
result.grounding_metadata.search_entry_point.rendered_content
|
|
== '<div>suggestions</div>'
|
|
)
|
|
assert state.search_entry_point is not None
|
|
|
|
def test_text_annotation_delta(self):
|
|
"""url_citation annotations become grounding chunks + supports."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'text_annotation_delta',
|
|
'annotations': [{
|
|
'type': 'url_citation',
|
|
'url': 'https://example.com',
|
|
'title': 'Example',
|
|
'start_index': 0,
|
|
'end_index': 5,
|
|
}],
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_an'
|
|
)
|
|
assert result is not None
|
|
assert result.partial is True
|
|
chunk = result.grounding_metadata.grounding_chunks[0]
|
|
assert chunk.web.uri == 'https://example.com'
|
|
assert chunk.web.title == 'Example'
|
|
support = result.grounding_metadata.grounding_supports[0]
|
|
assert support.grounding_chunk_indices == [0]
|
|
assert support.segment.start_index == 0
|
|
assert support.segment.end_index == 5
|
|
assert len(state.grounding_chunks) == 1
|
|
assert len(state.grounding_supports) == 1
|
|
|
|
def test_function_result_delta(self):
|
|
"""function_result delta becomes a function_response part."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'function_result',
|
|
'call_id': 'call_9',
|
|
'result': {'temp': 72},
|
|
},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_fr'
|
|
)
|
|
assert result is not None
|
|
part = result.content.parts[0]
|
|
assert part.function_response.id == 'call_9'
|
|
assert part.function_response.response == {'temp': 72}
|
|
assert len(state.parts) == 1
|
|
|
|
def test_grounding_accumulated_into_final_event(self):
|
|
"""Grounding from partial deltas is reattached to the final event."""
|
|
state = interactions_utils._StreamState()
|
|
conv = interactions_utils.convert_interaction_event_to_llm_response
|
|
conv(
|
|
StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'google_search_call',
|
|
'arguments': {'queries': ['q1']},
|
|
},
|
|
),
|
|
state,
|
|
interaction_id='int_f',
|
|
)
|
|
conv(
|
|
StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'google_search_result',
|
|
'result': [{'search_suggestions': '<div>s</div>'}],
|
|
},
|
|
),
|
|
state,
|
|
interaction_id='int_f',
|
|
)
|
|
conv(
|
|
StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'text', 'text': 'Mark Watney.'},
|
|
),
|
|
state,
|
|
interaction_id='int_f',
|
|
)
|
|
conv(
|
|
StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={
|
|
'type': 'text_annotation_delta',
|
|
'annotations': [{
|
|
'type': 'url_citation',
|
|
'url': 'https://e.com',
|
|
'title': 'E',
|
|
'start_index': 0,
|
|
'end_index': 4,
|
|
}],
|
|
},
|
|
),
|
|
state,
|
|
interaction_id='int_f',
|
|
)
|
|
final = conv(
|
|
InteractionCompletedEvent(
|
|
event_type='interaction.completed',
|
|
interaction=InteractionSseEventInteraction(
|
|
id='int_f', status='completed', steps=[]
|
|
),
|
|
),
|
|
state,
|
|
interaction_id='int_f',
|
|
)
|
|
assert final is not None
|
|
assert final.partial is False
|
|
assert final.turn_complete is True
|
|
assert final.content.parts[0].text == 'Mark Watney.'
|
|
gm = final.grounding_metadata
|
|
assert gm.web_search_queries == ['q1']
|
|
assert gm.search_entry_point.rendered_content == '<div>s</div>'
|
|
assert gm.grounding_chunks[0].web.uri == 'https://e.com'
|
|
assert gm.grounding_supports[0].grounding_chunk_indices == [0]
|
|
|
|
def test_final_event_includes_usage_metadata(self):
|
|
"""The streaming final event carries usage_metadata from the interaction."""
|
|
state = interactions_utils._StreamState()
|
|
conv = interactions_utils.convert_interaction_event_to_llm_response
|
|
conv(
|
|
StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'text', 'text': 'Answer.'},
|
|
),
|
|
state,
|
|
interaction_id='int_u1',
|
|
)
|
|
final = conv(
|
|
InteractionCompletedEvent(
|
|
event_type='interaction.completed',
|
|
interaction=InteractionSseEventInteraction(
|
|
id='int_u1',
|
|
status='completed',
|
|
steps=[],
|
|
usage=Usage(total_input_tokens=12, total_output_tokens=7),
|
|
),
|
|
),
|
|
state,
|
|
interaction_id='int_u1',
|
|
)
|
|
assert final is not None
|
|
assert final.partial is False
|
|
assert final.usage_metadata is not None
|
|
assert final.usage_metadata.prompt_token_count == 12
|
|
assert final.usage_metadata.candidates_token_count == 7
|
|
assert final.usage_metadata.total_token_count == 19
|
|
|
|
def test_final_event_without_usage_has_no_usage_metadata(self):
|
|
"""No interaction.usage -> final event has usage_metadata None."""
|
|
state = interactions_utils._StreamState()
|
|
conv = interactions_utils.convert_interaction_event_to_llm_response
|
|
conv(
|
|
StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'text', 'text': 'Answer.'},
|
|
),
|
|
state,
|
|
interaction_id='int_u2',
|
|
)
|
|
final = conv(
|
|
InteractionCompletedEvent(
|
|
event_type='interaction.completed',
|
|
interaction=InteractionSseEventInteraction(
|
|
id='int_u2', status='completed', steps=[]
|
|
),
|
|
),
|
|
state,
|
|
interaction_id='int_u2',
|
|
)
|
|
assert final is not None
|
|
assert final.partial is False
|
|
assert final.usage_metadata is None
|
|
|
|
def test_known_unhandled_delta_type_logs_debug_and_drops(self, caplog):
|
|
"""A known but unhandled delta type logs at debug and emits no event."""
|
|
# 'url_context_call' is a recognized genai delta variant that ADK does not
|
|
# handle yet, so it must fall through to the debug branch (not a warning).
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'url_context_call', 'arguments': {}},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
with caplog.at_level(logging.DEBUG, logger=interactions_utils.logger.name):
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_u'
|
|
)
|
|
assert result is None
|
|
assert not state.parts
|
|
debug_records = [
|
|
r
|
|
for r in caplog.records
|
|
if r.levelno == logging.DEBUG
|
|
and 'unhandled step delta type' in r.message
|
|
]
|
|
assert len(debug_records) == 1
|
|
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
|
|
|
|
def test_unrecognized_delta_logs_raw_warning_and_drops(self, caplog):
|
|
"""A truly-unrecognized delta logs a warning preserving its raw payload."""
|
|
event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'totally_made_up_xyz', 'foo': 'bar'},
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
with caplog.at_level(
|
|
logging.WARNING, logger=interactions_utils.logger.name
|
|
):
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_u2'
|
|
)
|
|
assert result is None
|
|
assert not state.parts
|
|
warnings = [
|
|
r
|
|
for r in caplog.records
|
|
if r.levelno == logging.WARNING
|
|
and 'unrecognized step delta' in r.message
|
|
]
|
|
assert len(warnings) == 1
|
|
assert 'foo' in warnings[0].message
|
|
# The full raw payload (not just delta.type='UNKNOWN') is preserved.
|
|
assert warnings[0].args == {'type': 'totally_made_up_xyz', 'foo': 'bar'}
|
|
|
|
def test_unknown_event_type_returns_none(self):
|
|
"""Test that unknown event types return None."""
|
|
event = MagicMock()
|
|
event.event_type = 'some_unknown_event' # Unknown event type
|
|
|
|
state = interactions_utils._StreamState()
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event, state, interaction_id='int_other'
|
|
)
|
|
|
|
assert result is None
|
|
assert not state.parts
|
|
|
|
def test_completed_event_failed_partial_interaction(self):
|
|
"""A failed lifecycle event with a partial interaction does not crash."""
|
|
event = InteractionCompletedEvent(
|
|
event_type='interaction.completed',
|
|
interaction=InteractionSseEventInteraction(
|
|
id='int_failed',
|
|
status='failed',
|
|
steps=[],
|
|
),
|
|
)
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
event,
|
|
state=interactions_utils._StreamState(),
|
|
interaction_id='int_failed',
|
|
)
|
|
assert result is not None
|
|
assert result.error_code == 'UNKNOWN_ERROR'
|
|
assert result.interaction_id == 'int_failed'
|
|
|
|
def test_function_call_streaming_flow(self):
|
|
"""Test the complete streaming flow for function calls (Start, Delta, Stop)."""
|
|
# 1. StepStart
|
|
start_event = StepStart(
|
|
event_type='step.start',
|
|
index=0,
|
|
step=FunctionCallStep(
|
|
type='function_call',
|
|
id='call_1',
|
|
name='get_weather',
|
|
arguments={},
|
|
),
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
result1 = interactions_utils.convert_interaction_event_to_llm_response(
|
|
start_event, state, interaction_id='int_123'
|
|
)
|
|
|
|
assert result1 is not None
|
|
assert result1.partial is True
|
|
assert len(state.parts) == 1
|
|
fc = state.parts[-1].function_call
|
|
assert fc
|
|
assert fc.name == 'get_weather'
|
|
assert fc.id == 'call_1'
|
|
assert fc.partial_args == []
|
|
|
|
# 2. StepDelta
|
|
delta_event1 = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'arguments_delta', 'arguments': '{"city": '},
|
|
)
|
|
result2 = interactions_utils.convert_interaction_event_to_llm_response(
|
|
delta_event1, state, interaction_id='int_123'
|
|
)
|
|
|
|
assert result2 is not None
|
|
assert result2.partial is True
|
|
assert (
|
|
result2.content.parts[0].function_call.partial_args[0].string_value
|
|
== '{"city": '
|
|
)
|
|
|
|
delta_event2 = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'arguments_delta', 'arguments': '"Paris"}'},
|
|
)
|
|
result3 = interactions_utils.convert_interaction_event_to_llm_response(
|
|
delta_event2, state, interaction_id='int_123'
|
|
)
|
|
|
|
assert result3 is not None
|
|
assert len(state.parts[0].function_call.partial_args) == 2
|
|
|
|
# 3. StepStop
|
|
stop_event = StepStop(
|
|
event_type='step.stop',
|
|
index=0,
|
|
)
|
|
result4 = interactions_utils.convert_interaction_event_to_llm_response(
|
|
stop_event, state, interaction_id='int_123'
|
|
)
|
|
|
|
assert result4 is None
|
|
assert state.parts[0].function_call.args == {'city': 'Paris'}
|
|
assert state.parts[0].function_call.partial_args is None
|
|
|
|
def test_function_call_streaming_json_parse_error(self, caplog):
|
|
"""Test function call streaming returns an error response on JSON parse error."""
|
|
# 1. StepStart
|
|
start_event = StepStart(
|
|
event_type='step.start',
|
|
index=0,
|
|
step=FunctionCallStep(
|
|
type='function_call',
|
|
id='call_err',
|
|
name='bad_json_tool',
|
|
arguments={},
|
|
),
|
|
)
|
|
state = interactions_utils._StreamState()
|
|
interactions_utils.convert_interaction_event_to_llm_response(
|
|
start_event, state, interaction_id='int_err'
|
|
)
|
|
|
|
# 2. StepDelta (invalid JSON)
|
|
delta_event = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'arguments_delta', 'arguments': '{"broken": "json'},
|
|
)
|
|
interactions_utils.convert_interaction_event_to_llm_response(
|
|
delta_event, state, interaction_id='int_err'
|
|
)
|
|
|
|
# 3. StepStop
|
|
stop_event = StepStop(
|
|
event_type='step.stop',
|
|
index=0,
|
|
)
|
|
result = interactions_utils.convert_interaction_event_to_llm_response(
|
|
stop_event, state, interaction_id='int_err'
|
|
)
|
|
|
|
# Assert an error LlmResponse is returned
|
|
assert result is not None
|
|
assert result.error_code == 'JSON_PARSE_ERROR'
|
|
assert result.error_message == 'Failed to parse function call arguments'
|
|
assert result.turn_complete is True
|
|
assert result.interaction_id == 'int_err'
|
|
|
|
# The logging check can remain to ensure the raw exception is still logged.
|
|
assert 'Failed to parse function call args' in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('streamed_events_factory', 'expected_ids'),
|
|
[
|
|
pytest.param(
|
|
_build_lifecycle_streamed_events,
|
|
['interaction_123'],
|
|
id='lifecycle-events',
|
|
),
|
|
pytest.param(
|
|
_build_complete_streamed_events,
|
|
['interaction_complete_123'],
|
|
id='complete-event',
|
|
),
|
|
pytest.param(
|
|
_build_legacy_streamed_events,
|
|
['interaction_legacy_123'],
|
|
id='legacy-event',
|
|
),
|
|
],
|
|
)
|
|
def test_generate_content_via_interactions_stream_extracts_interaction_id(
|
|
streamed_events_factory: Callable[[FunctionCallStep], list[object]],
|
|
expected_ids: list[str],
|
|
fc_step: FunctionCallStep,
|
|
):
|
|
"""Streamed interaction IDs should be preserved across event variants."""
|
|
streamed_events = streamed_events_factory(fc_step)
|
|
|
|
assert (
|
|
asyncio.run(_collect_function_call_interaction_ids(streamed_events))
|
|
== expected_ids
|
|
)
|
|
|
|
|
|
def _build_simple_text_stream() -> list[object]:
|
|
"""A minimal streamed interaction: created -> text delta -> completed."""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
created = InteractionCreatedEvent(
|
|
event_type='interaction.created',
|
|
interaction=InteractionSseEventInteraction(
|
|
id='interaction_xyz',
|
|
created=now,
|
|
updated=now,
|
|
status='requires_action',
|
|
steps=[],
|
|
),
|
|
)
|
|
step_start = StepStart(
|
|
event_type='step.start',
|
|
index=0,
|
|
step=ModelOutputStep(type='model_output'),
|
|
)
|
|
step_delta = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'text', 'text': 'Sunny in Tokyo.'},
|
|
)
|
|
step_stop = StepStop(event_type='step.stop', index=0)
|
|
completed = InteractionCompletedEvent(
|
|
event_type='interaction.completed',
|
|
interaction=InteractionSseEventInteraction(
|
|
id='interaction_xyz',
|
|
created=now,
|
|
updated=now,
|
|
status='completed',
|
|
steps=[
|
|
ModelOutputStep(
|
|
type='model_output',
|
|
content=[TextContent(type='text', text='Sunny in Tokyo.')],
|
|
)
|
|
],
|
|
),
|
|
)
|
|
return [created, step_start, step_delta, step_stop, completed]
|
|
|
|
|
|
async def _collect_stream_responses(events: list[object]):
|
|
api_client = _FakeApiClient(events)
|
|
llm_request = _build_llm_request()
|
|
responses = []
|
|
async for resp in interactions_utils.generate_content_via_interactions(
|
|
api_client, llm_request, stream=True
|
|
):
|
|
responses.append(resp)
|
|
return responses
|
|
|
|
|
|
async def test_generate_content_via_interactions_stream_characterization():
|
|
"""Streaming yields text responses carrying the interaction id."""
|
|
responses = await _collect_stream_responses(_build_simple_text_stream())
|
|
|
|
assert responses, 'expected at least one streamed LlmResponse'
|
|
assert all(r.interaction_id == 'interaction_xyz' for r in responses)
|
|
joined = ''.join(
|
|
part.text
|
|
for r in responses
|
|
if r.content and r.content.parts
|
|
for part in r.content.parts
|
|
if part.text
|
|
)
|
|
assert 'Sunny in Tokyo.' in joined
|
|
|
|
|
|
def _build_non_streaming_interaction() -> Interaction:
|
|
"""A completed non-streaming Interaction with a single text output."""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
return Interaction(
|
|
id='interaction_ns',
|
|
status='completed',
|
|
created=now,
|
|
updated=now,
|
|
steps=[
|
|
ModelOutputStep(
|
|
type='model_output',
|
|
content=[TextContent(type='text', text='Sunny in Tokyo.')],
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
async def _drain(
|
|
responses: AsyncGenerator[LlmResponse, None],
|
|
) -> list[LlmResponse]:
|
|
"""Collect all responses yielded by an async generator."""
|
|
return [resp async for resp in responses]
|
|
|
|
|
|
async def test_create_interactions_streaming_forwards_kwargs_and_converts():
|
|
"""Streaming forwards create_kwargs verbatim (plus stream) and converts."""
|
|
# Arrange.
|
|
api_client = _FakeApiClient(_build_simple_text_stream())
|
|
create_kwargs = {
|
|
'model': 'gemini-2.5-flash',
|
|
'input': [{
|
|
'type': 'user_input',
|
|
'content': [{'type': 'text', 'text': 'Weather in Tokyo?'}],
|
|
}],
|
|
'previous_interaction_id': None,
|
|
}
|
|
|
|
# Act.
|
|
responses = await _drain(
|
|
interactions_utils._create_interactions(
|
|
api_client, create_kwargs=create_kwargs, stream=True
|
|
)
|
|
)
|
|
|
|
# Assert: exactly one create() call forwarding kwargs plus the stream flag.
|
|
assert len(api_client.create_calls) == 1
|
|
assert api_client.create_calls[0] == {
|
|
**create_kwargs,
|
|
'stream': True,
|
|
'extra_headers': None,
|
|
}
|
|
|
|
# Assert: the streamed events are converted into text responses.
|
|
assert responses, 'expected at least one streamed LlmResponse'
|
|
assert all(r.interaction_id == 'interaction_xyz' for r in responses)
|
|
joined = ''.join(
|
|
part.text
|
|
for r in responses
|
|
if r.content and r.content.parts
|
|
for part in r.content.parts
|
|
if part.text
|
|
)
|
|
assert 'Sunny in Tokyo.' in joined
|
|
|
|
|
|
async def test_create_interactions_non_streaming_forwards_kwargs_and_yields_single_response():
|
|
"""Non-streaming forwards kwargs verbatim and yields a single response."""
|
|
# Arrange.
|
|
interaction = _build_non_streaming_interaction()
|
|
api_client = _FakeApiClient(interaction=interaction)
|
|
create_kwargs = {
|
|
'model': 'gemini-2.5-flash',
|
|
'input': [{
|
|
'type': 'user_input',
|
|
'content': [{'type': 'text', 'text': 'Weather in Tokyo?'}],
|
|
}],
|
|
'previous_interaction_id': None,
|
|
}
|
|
|
|
# Act.
|
|
responses = await _drain(
|
|
interactions_utils._create_interactions(
|
|
api_client, create_kwargs=create_kwargs, stream=False
|
|
)
|
|
)
|
|
|
|
# Assert: exactly one create() call forwarding kwargs plus the stream flag.
|
|
assert len(api_client.create_calls) == 1
|
|
assert api_client.create_calls[0] == {
|
|
**create_kwargs,
|
|
'stream': False,
|
|
'extra_headers': None,
|
|
}
|
|
|
|
# Assert: a single converted LlmResponse carrying the interaction output.
|
|
assert len(responses) == 1
|
|
assert responses[0].interaction_id == 'interaction_ns'
|
|
assert responses[0].content.parts[0].text == 'Sunny in Tokyo.'
|
|
|
|
|
|
async def test_generate_content_via_interactions_non_streaming_yields_single_response():
|
|
"""The public function yields a single response on the non-streaming path."""
|
|
# Arrange.
|
|
api_client = _FakeApiClient(interaction=_build_non_streaming_interaction())
|
|
|
|
# Act.
|
|
responses = await _drain(
|
|
interactions_utils.generate_content_via_interactions(
|
|
api_client, _build_llm_request(), stream=False
|
|
)
|
|
)
|
|
|
|
# Assert: a single end-to-end converted LlmResponse with the expected text.
|
|
assert len(responses) == 1
|
|
assert responses[0].interaction_id == 'interaction_ns'
|
|
assert responses[0].content.parts[0].text == 'Sunny in Tokyo.'
|
|
|
|
|
|
def _build_stream_with_environment() -> list[object]:
|
|
"""A streamed interaction whose completed event carries an environment id."""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
created = InteractionCreatedEvent(
|
|
event_type='interaction.created',
|
|
interaction=InteractionSseEventInteraction(
|
|
id='interaction_env',
|
|
created=now,
|
|
updated=now,
|
|
status='requires_action',
|
|
steps=[],
|
|
),
|
|
)
|
|
step_start = StepStart(
|
|
event_type='step.start',
|
|
index=0,
|
|
step=ModelOutputStep(type='model_output'),
|
|
)
|
|
step_delta = StepDelta(
|
|
event_type='step.delta',
|
|
index=0,
|
|
delta={'type': 'text', 'text': 'hi'},
|
|
)
|
|
step_stop = StepStop(event_type='step.stop', index=0)
|
|
completed = InteractionCompletedEvent(
|
|
event_type='interaction.completed',
|
|
interaction=InteractionSseEventInteraction(
|
|
id='interaction_env',
|
|
created=now,
|
|
updated=now,
|
|
status='completed',
|
|
environment_id='env_xyz',
|
|
steps=[
|
|
ModelOutputStep(
|
|
type='model_output',
|
|
content=[TextContent(type='text', text='hi')],
|
|
)
|
|
],
|
|
),
|
|
)
|
|
return [created, step_start, step_delta, step_stop, completed]
|
|
|
|
|
|
def test_create_interactions_surfaces_environment_id():
|
|
api_client = _FakeApiClient(_build_stream_with_environment())
|
|
|
|
async def _collect():
|
|
out = []
|
|
async for r in interactions_utils._create_interactions(
|
|
api_client,
|
|
create_kwargs={'agent': 'agents/a', 'input': []},
|
|
stream=True,
|
|
):
|
|
out.append(r)
|
|
return out
|
|
|
|
responses = asyncio.run(_collect())
|
|
assert responses, 'expected streamed responses'
|
|
# The env id arrives only on the completed event, so earlier partial
|
|
# responses carry no environment id.
|
|
assert responses[0].environment_id is None
|
|
assert responses[-1].environment_id == 'env_xyz'
|
|
|
|
|
|
class _FakeNonStreamInteractions:
|
|
"""Fake interactions resource returning a full Interaction (non-streaming)."""
|
|
|
|
def __init__(self, interaction: Interaction):
|
|
self._interaction = interaction
|
|
|
|
async def create(self, **_kwargs):
|
|
return self._interaction
|
|
|
|
|
|
class _FakeNonStreamAio:
|
|
"""Namespace matching the expected api_client.aio shape (non-streaming)."""
|
|
|
|
def __init__(self, interaction: Interaction):
|
|
self.interactions = _FakeNonStreamInteractions(interaction)
|
|
|
|
|
|
class _FakeNonStreamApiClient:
|
|
"""Minimal fake API client whose create() returns a full Interaction."""
|
|
|
|
def __init__(self, interaction: Interaction):
|
|
self.aio = _FakeNonStreamAio(interaction)
|
|
|
|
|
|
def test_create_interactions_surfaces_environment_id_non_stream():
|
|
interaction = Interaction(
|
|
id='interaction_ns',
|
|
status='completed',
|
|
created=datetime.now(timezone.utc).isoformat(),
|
|
updated=datetime.now(timezone.utc).isoformat(),
|
|
environment_id='env_ns',
|
|
steps=[
|
|
ModelOutputStep(
|
|
type='model_output',
|
|
content=[TextContent(type='text', text='hi')],
|
|
)
|
|
],
|
|
)
|
|
api_client = _FakeNonStreamApiClient(interaction)
|
|
|
|
async def _collect():
|
|
out = []
|
|
async for r in interactions_utils._create_interactions(
|
|
api_client,
|
|
create_kwargs={'agent': 'agents/a', 'input': []},
|
|
stream=False,
|
|
):
|
|
out.append(r)
|
|
return out
|
|
|
|
responses = asyncio.run(_collect())
|
|
assert len(responses) == 1
|
|
assert responses[-1].environment_id == 'env_ns'
|
|
|
|
|
|
class TestBuildMcpServerParam:
|
|
"""Tests for _build_mcp_server_param."""
|
|
|
|
def _server(self, **kwargs):
|
|
from google.adk.tools._remote_mcp_server import RemoteMcpServer
|
|
|
|
kwargs.setdefault('url', 'https://mcp.example.com/mcp')
|
|
return RemoteMcpServer(**kwargs)
|
|
|
|
def test_minimal_url_only(self):
|
|
param = interactions_utils._build_mcp_server_param(self._server(), {})
|
|
assert param == {
|
|
'type': 'mcp_server',
|
|
'url': 'https://mcp.example.com/mcp',
|
|
}
|
|
|
|
def test_with_name(self):
|
|
param = interactions_utils._build_mcp_server_param(
|
|
self._server(name='maps'), {}
|
|
)
|
|
assert param['name'] == 'maps'
|
|
|
|
def test_with_headers(self):
|
|
param = interactions_utils._build_mcp_server_param(
|
|
self._server(), {'X-Goog-Api-Key': 'k'}
|
|
)
|
|
assert param['headers'] == {'X-Goog-Api-Key': 'k'}
|
|
|
|
def test_with_allowed_tools(self):
|
|
param = interactions_utils._build_mcp_server_param(
|
|
self._server(allowed_tools=['search_places']), {}
|
|
)
|
|
assert param['allowed_tools'] == [{'tools': ['search_places']}]
|
|
|
|
def test_omits_unset_fields(self):
|
|
param = interactions_utils._build_mcp_server_param(self._server(), {})
|
|
assert 'name' not in param
|
|
assert 'headers' not in param
|
|
assert 'allowed_tools' not in param
|
|
|
|
|
|
async def test_create_interactions_forwards_extra_headers_streaming():
|
|
"""extra_headers is forwarded to interactions.create on the streaming path."""
|
|
api_client = _FakeApiClient(_build_simple_text_stream())
|
|
create_kwargs = {
|
|
'model': 'gemini-2.5-flash',
|
|
'input': [],
|
|
'previous_interaction_id': None,
|
|
}
|
|
|
|
await _drain(
|
|
interactions_utils._create_interactions(
|
|
api_client,
|
|
create_kwargs=create_kwargs,
|
|
stream=True,
|
|
extra_headers={'x-custom': 'v'},
|
|
)
|
|
)
|
|
|
|
assert api_client.create_calls[0]['extra_headers'] == {'x-custom': 'v'}
|
|
|
|
|
|
async def test_create_interactions_forwards_extra_headers_non_streaming():
|
|
"""extra_headers is forwarded to interactions.create on the non-stream path."""
|
|
api_client = _FakeApiClient(interaction=_build_non_streaming_interaction())
|
|
create_kwargs = {
|
|
'model': 'gemini-2.5-flash',
|
|
'input': [],
|
|
'previous_interaction_id': None,
|
|
}
|
|
|
|
await _drain(
|
|
interactions_utils._create_interactions(
|
|
api_client,
|
|
create_kwargs=create_kwargs,
|
|
stream=False,
|
|
extra_headers={'x-custom': 'v'},
|
|
)
|
|
)
|
|
|
|
assert api_client.create_calls[0]['extra_headers'] == {'x-custom': 'v'}
|
|
|
|
|
|
async def test_generate_content_via_interactions_forwards_request_headers():
|
|
"""User headers on the request reach interactions.create as extra_headers."""
|
|
api_client = _FakeApiClient(_build_simple_text_stream())
|
|
llm_request = _build_llm_request()
|
|
llm_request.config.http_options = types.HttpOptions(headers={'x-custom': 'v'})
|
|
|
|
await _drain(
|
|
interactions_utils.generate_content_via_interactions(
|
|
api_client, llm_request, stream=True
|
|
)
|
|
)
|
|
|
|
extra_headers = api_client.create_calls[0]['extra_headers']
|
|
assert extra_headers['x-custom'] == 'v'
|
|
assert 'google-adk/' in extra_headers['x-goog-api-client']
|
|
|
|
|
|
async def test_generate_content_via_interactions_sends_tracking_headers_without_config_headers():
|
|
"""With no request headers, tracking headers are still forwarded."""
|
|
from google.adk.utils._google_client_headers import get_tracking_headers
|
|
|
|
api_client = _FakeApiClient(_build_simple_text_stream())
|
|
|
|
await _drain(
|
|
interactions_utils.generate_content_via_interactions(
|
|
api_client, _build_llm_request(), stream=True
|
|
)
|
|
)
|
|
|
|
assert api_client.create_calls[0]['extra_headers'] == get_tracking_headers()
|