# 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': '