Files
google--adk-python/tests/unittests/models/test_interactions_utils.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

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()