# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from unittest import mock from google.adk.models.gemini_llm_connection import GeminiLlmConnection from google.adk.utils.variant_utils import GoogleLLMVariant from google.genai import types import pytest MODEL_VERSION = 'gemini-2.5-pro' @pytest.fixture def mock_gemini_session(): """Mock Gemini session for testing.""" mock_session = mock.AsyncMock() mock_session.session_id = 'test-session-id' return mock_session @pytest.fixture def gemini_connection(mock_gemini_session): """GeminiLlmConnection instance with mocked session.""" return GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI, model_version=MODEL_VERSION, ) @pytest.fixture def gemini_api_connection(mock_gemini_session): """GeminiLlmConnection instance with mocked session for Gemini API.""" return GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.GEMINI_API, model_version=MODEL_VERSION, ) @pytest.fixture def test_blob(): """Test blob for audio data.""" return types.Blob(data=b'\x00\xFF\x00\xFF', mime_type='audio/pcm') @pytest.mark.asyncio async def test_send_realtime_default_behavior( gemini_connection, mock_gemini_session, test_blob ): """Test send_realtime with default automatic_activity_detection value (True).""" await gemini_connection.send_realtime(test_blob) # Should call send once mock_gemini_session.send_realtime_input.assert_called_once_with( media=test_blob ) # Should not call .send function mock_gemini_session.send.assert_not_called() @pytest.mark.asyncio async def test_send_realtime_audio_uses_audio_channel_for_live_translate( mock_gemini_session, test_blob ): """Live Translate models stream audio via the dedicated `audio=` channel.""" connection = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.GEMINI_API, model_version='gemini-3.5-live-translate-preview', ) await connection.send_realtime(test_blob) mock_gemini_session.send_realtime_input.assert_called_once_with( audio=test_blob ) @pytest.mark.asyncio async def test_send_history(gemini_connection, mock_gemini_session): """Test send_history method.""" history = [ types.Content(role='user', parts=[types.Part.from_text(text='Hello')]), types.Content( role='model', parts=[types.Part.from_text(text='Hi there!')] ), ] await gemini_connection.send_history(history) mock_gemini_session.send_client_content.assert_called_once() call_args = mock_gemini_session.send_client_content.call_args[1] assert 'turns' in call_args assert call_args['turns'] == history assert call_args['turn_complete'] is False # Last message is from model @pytest.mark.asyncio async def test_send_content_text(gemini_connection, mock_gemini_session): """Test send_content with text content.""" content = types.Content( role='user', parts=[types.Part.from_text(text='Hello')] ) await gemini_connection.send_content(content) mock_gemini_session.send.assert_called_once() call_args = mock_gemini_session.send.call_args[1] assert 'input' in call_args assert call_args['input'].turns == [content] assert call_args['input'].turn_complete is True @pytest.mark.asyncio async def test_send_content_text_can_keep_turn_open( gemini_connection, mock_gemini_session ): content = types.Content( role='user', parts=[types.Part.from_text(text='progress')] ) await gemini_connection._send_content(content, partial=True) mock_gemini_session.send.assert_called_once() call_args = mock_gemini_session.send.call_args[1] assert call_args['input'].turns == [content] assert call_args['input'].turn_complete is False @pytest.mark.asyncio async def test_send_content_function_response( gemini_connection, mock_gemini_session ): """Test send_content with function response.""" function_response = types.FunctionResponse( name='test_function', response={'result': 'success'} ) content = types.Content( role='user', parts=[types.Part(function_response=function_response)] ) await gemini_connection.send_content(content) mock_gemini_session.send_tool_response.assert_called_once() call_args = mock_gemini_session.send_tool_response.call_args[1] assert 'function_responses' in call_args assert call_args['function_responses'] == [function_response] @pytest.mark.asyncio async def test_close(gemini_connection, mock_gemini_session): """Test close method.""" await gemini_connection.close() mock_gemini_session.close.assert_called_once() @pytest.mark.asyncio @pytest.mark.parametrize('tx_direction', ['input', 'output']) async def test_receive_transcript_finished( gemini_connection, mock_gemini_session, tx_direction ): """Test receive_transcript_finished for input and output transcription.""" finished_tx = types.Transcription(finished=True) msg = mock.Mock() msg.tool_call = None msg.usage_metadata = None msg.session_resumption_update = None msg.go_away = None msg.voice_activity = None msg.server_content.model_turn = None msg.server_content.interrupted = False msg.server_content.turn_complete = False msg.server_content.input_transcription = ( finished_tx if tx_direction == 'input' else None ) msg.server_content.output_transcription = ( finished_tx if tx_direction == 'output' else None ) msg.server_content.grounding_metadata = None async def gen(): yield msg mock_gemini_session.receive = mock.Mock(return_value=gen()) responses = [] async for r in gemini_connection.receive(): responses.append(r) attr_name = f'{tx_direction}_transcription' tx_resps = [r for r in responses if getattr(r, attr_name)] assert tx_resps, f'Expected {tx_direction} transcription response' transcription = getattr(tx_resps[0], attr_name) assert transcription.finished is True assert not transcription.text async def test_receive_usage_metadata_and_server_content( gemini_connection, mock_gemini_session ): """Test receive with usage metadata and server content in one message.""" usage_metadata = types.UsageMetadata( prompt_token_count=10, cached_content_token_count=5, response_token_count=20, total_token_count=35, thoughts_token_count=2, prompt_tokens_details=[ types.ModalityTokenCount(modality='text', token_count=10) ], cache_tokens_details=[ types.ModalityTokenCount(modality='text', token_count=5) ], response_tokens_details=[ types.ModalityTokenCount(modality='text', token_count=20) ], ) mock_content = types.Content( role='model', parts=[types.Part.from_text(text='response text')] ) mock_server_content = mock.Mock() mock_server_content.model_turn = mock_content mock_server_content.interrupted = False mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.turn_complete = False mock_server_content.grounding_metadata = None mock_message = mock.AsyncMock() mock_message.usage_metadata = usage_metadata mock_message.server_content = mock_server_content mock_message.tool_call = None mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None async def mock_receive_generator(): yield mock_message receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert responses usage_response = next((r for r in responses if r.usage_metadata), None) assert usage_response is not None assert usage_response.model_version == MODEL_VERSION content_response = next((r for r in responses if r.content), None) assert content_response is not None # The live API's `response_token_count`/`response_tokens_details` are remapped # to `candidates_token_count`/`candidates_tokens_details`. expected_usage = types.GenerateContentResponseUsageMetadata( prompt_token_count=10, cached_content_token_count=5, candidates_token_count=20, total_token_count=35, thoughts_token_count=2, prompt_tokens_details=[ types.ModalityTokenCount(modality='text', token_count=10) ], cache_tokens_details=[ types.ModalityTokenCount(modality='text', token_count=5) ], candidates_tokens_details=[ types.ModalityTokenCount(modality='text', token_count=20) ], ) assert usage_response.usage_metadata == expected_usage assert content_response.content == mock_content async def test_receive_usage_metadata_remaps_output_tokens( gemini_connection, mock_gemini_session ): """Test that live API output tokens are remapped to candidates_token_count.""" usage_metadata = types.UsageMetadata( prompt_token_count=10, cached_content_token_count=5, response_token_count=20, total_token_count=35, thoughts_token_count=2, tool_use_prompt_token_count=3, prompt_tokens_details=[ types.ModalityTokenCount(modality='text', token_count=10) ], cache_tokens_details=[ types.ModalityTokenCount(modality='text', token_count=5) ], response_tokens_details=[ types.ModalityTokenCount(modality='text', token_count=20) ], ) mock_message = mock.AsyncMock() mock_message.usage_metadata = usage_metadata mock_message.server_content = None mock_message.tool_call = None mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None async def mock_receive_generator(): yield mock_message receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] usage_response = next((r for r in responses if r.usage_metadata), None) assert usage_response is not None result = usage_response.usage_metadata assert isinstance(result, types.GenerateContentResponseUsageMetadata) # Output tokens are remapped from response_* to candidates_*. assert result.candidates_token_count == 20 assert result.candidates_tokens_details == [ types.ModalityTokenCount(modality='text', token_count=20) ] # Shared fields are carried over unchanged. assert result.prompt_token_count == 10 assert result.cached_content_token_count == 5 assert result.total_token_count == 35 assert result.thoughts_token_count == 2 assert result.tool_use_prompt_token_count == 3 assert result.prompt_tokens_details == [ types.ModalityTokenCount(modality='text', token_count=10) ] assert result.cache_tokens_details == [ types.ModalityTokenCount(modality='text', token_count=5) ] async def test_receive_populates_live_session_id( gemini_connection, mock_gemini_session ): """Test that receive populates live_session_id in LlmResponse.""" mock_message = mock.AsyncMock() mock_message.usage_metadata = None mock_message.server_content = None mock_message.tool_call = None mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None mock_server_content = mock.Mock() mock_server_content.model_turn = types.Content( role='model', parts=[types.Part.from_text(text='text')] ) mock_server_content.interrupted = False mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.turn_complete = False mock_server_content.grounding_metadata = None mock_message.server_content = mock_server_content async def mock_receive_generator(): yield mock_message mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator()) responses = [resp async for resp in gemini_connection.receive()] assert responses for resp in responses: assert resp.live_session_id == 'test-session-id' @pytest.mark.asyncio @pytest.mark.parametrize( 'conn_fixture', ['gemini_api_connection', 'gemini_connection'], ) async def test_receive_transcript_finished_on_interrupt( conn_fixture, mock_gemini_session, request, ): """Test receive finishes transcription on interrupt signal.""" connection = request.getfixturevalue(conn_fixture) message1 = mock.Mock() message1.usage_metadata = None message1.server_content = mock.Mock() message1.server_content.model_turn = None message1.server_content.interrupted = False message1.server_content.input_transcription = types.Transcription( text='Hello', finished=False ) message1.server_content.output_transcription = None message1.server_content.turn_complete = False message1.server_content.generation_complete = False message1.server_content.grounding_metadata = None message1.tool_call = None message1.session_resumption_update = None message1.go_away = None message1.voice_activity = None message2 = mock.Mock() message2.usage_metadata = None message2.server_content = mock.Mock() message2.server_content.model_turn = None message2.server_content.interrupted = False message2.server_content.input_transcription = None message2.server_content.output_transcription = types.Transcription( text='How can', finished=False ) message2.server_content.turn_complete = False message2.server_content.generation_complete = False message2.server_content.grounding_metadata = None message2.tool_call = None message2.session_resumption_update = None message2.go_away = None message2.voice_activity = None message3 = mock.Mock() message3.usage_metadata = None message3.server_content = mock.Mock() message3.server_content.model_turn = None message3.server_content.interrupted = True message3.server_content.input_transcription = None message3.server_content.output_transcription = None message3.server_content.turn_complete = False message3.server_content.generation_complete = False message3.server_content.grounding_metadata = None message3.tool_call = None message3.session_resumption_update = None message3.go_away = None message3.voice_activity = None async def mock_receive_generator(): yield message1 yield message2 yield message3 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in connection.receive()] assert len(responses) == 5 assert responses[4].interrupted is True assert responses[0].input_transcription.text == 'Hello' assert responses[0].input_transcription.finished is False assert responses[0].partial is True assert responses[1].output_transcription.text == 'How can' assert responses[1].output_transcription.finished is False assert responses[1].partial is True assert responses[2].input_transcription.text == 'Hello' assert responses[2].input_transcription.finished is True assert responses[2].partial is False assert responses[3].output_transcription.text == 'How can' assert responses[3].output_transcription.finished is True assert responses[3].partial is False @pytest.mark.asyncio @pytest.mark.parametrize( 'conn_fixture', ['gemini_api_connection', 'gemini_connection'], ) async def test_receive_transcript_finished_on_generation_complete( conn_fixture, mock_gemini_session, request, ): """Test receive finishes transcription on generation_complete signal.""" connection = request.getfixturevalue(conn_fixture) message1 = mock.Mock() message1.usage_metadata = None message1.server_content = mock.Mock() message1.server_content.model_turn = None message1.server_content.interrupted = False message1.server_content.input_transcription = types.Transcription( text='Hello', finished=False ) message1.server_content.output_transcription = None message1.server_content.turn_complete = False message1.server_content.generation_complete = False message1.server_content.grounding_metadata = None message1.tool_call = None message1.session_resumption_update = None message1.go_away = None message1.voice_activity = None message2 = mock.Mock() message2.usage_metadata = None message2.server_content = mock.Mock() message2.server_content.model_turn = None message2.server_content.interrupted = False message2.server_content.input_transcription = None message2.server_content.output_transcription = types.Transcription( text='How can', finished=False ) message2.server_content.turn_complete = False message2.server_content.generation_complete = False message2.server_content.grounding_metadata = None message2.tool_call = None message2.session_resumption_update = None message2.go_away = None message2.voice_activity = None message3 = mock.Mock() message3.usage_metadata = None message3.server_content = mock.Mock() message3.server_content.model_turn = None message3.server_content.interrupted = False message3.server_content.input_transcription = None message3.server_content.output_transcription = None message3.server_content.turn_complete = False message3.server_content.generation_complete = True message3.server_content.grounding_metadata = None message3.tool_call = None message3.session_resumption_update = None message3.go_away = None message3.voice_activity = None async def mock_receive_generator(): yield message1 yield message2 yield message3 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in connection.receive()] assert len(responses) == 4 assert responses[0].input_transcription.text == 'Hello' assert responses[0].input_transcription.finished is False assert responses[0].partial is True assert responses[1].output_transcription.text == 'How can' assert responses[1].output_transcription.finished is False assert responses[1].partial is True assert responses[2].input_transcription.text == 'Hello' assert responses[2].input_transcription.finished is True assert responses[2].partial is False assert responses[3].output_transcription.text == 'How can' assert responses[3].output_transcription.finished is True assert responses[3].partial is False @pytest.mark.asyncio @pytest.mark.parametrize( 'conn_fixture', ['gemini_api_connection', 'gemini_connection'], ) async def test_receive_transcript_finished_on_turn_complete( conn_fixture, mock_gemini_session, request, ): """Test receive finishes transcription on interrupt or complete signals.""" connection = request.getfixturevalue(conn_fixture) message1 = mock.Mock() message1.usage_metadata = None message1.server_content = mock.Mock() message1.server_content.model_turn = None message1.server_content.interrupted = False message1.server_content.input_transcription = types.Transcription( text='Hello', finished=False ) message1.server_content.output_transcription = None message1.server_content.turn_complete = False message1.server_content.generation_complete = False message1.server_content.grounding_metadata = None message1.tool_call = None message1.session_resumption_update = None message1.go_away = None message1.voice_activity = None message2 = mock.Mock() message2.usage_metadata = None message2.server_content = mock.Mock() message2.server_content.model_turn = None message2.server_content.interrupted = False message2.server_content.input_transcription = None message2.server_content.output_transcription = types.Transcription( text='How can', finished=False ) message2.server_content.turn_complete = False message2.server_content.generation_complete = False message2.server_content.grounding_metadata = None message2.tool_call = None message2.session_resumption_update = None message2.go_away = None message2.voice_activity = None message3 = mock.Mock() message3.usage_metadata = None message3.server_content = mock.Mock() message3.server_content.model_turn = None message3.server_content.interrupted = False message3.server_content.input_transcription = None message3.server_content.output_transcription = None message3.server_content.turn_complete = True message3.server_content.generation_complete = False message3.server_content.grounding_metadata = None message3.tool_call = None message3.session_resumption_update = None message3.go_away = None message3.voice_activity = None async def mock_receive_generator(): yield message1 yield message2 yield message3 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in connection.receive()] assert len(responses) == 5 assert responses[4].turn_complete is True assert responses[0].input_transcription.text == 'Hello' assert responses[0].input_transcription.finished is False assert responses[0].partial is True assert responses[1].output_transcription.text == 'How can' assert responses[1].output_transcription.finished is False assert responses[1].partial is True assert responses[2].input_transcription.text == 'Hello' assert responses[2].input_transcription.finished is True assert responses[2].partial is False assert responses[3].output_transcription.text == 'How can' assert responses[3].output_transcription.finished is True assert responses[3].partial is False @pytest.mark.asyncio async def test_receive_handles_input_transcription_fragments( gemini_connection, mock_gemini_session ): """Test receive handles input transcription fragments correctly.""" message1 = mock.Mock() message1.usage_metadata = None message1.server_content = mock.Mock() message1.server_content.model_turn = None message1.server_content.interrupted = False message1.server_content.input_transcription = types.Transcription( text='Hello', finished=False ) message1.server_content.output_transcription = None message1.server_content.turn_complete = False message1.server_content.generation_complete = False message1.server_content.grounding_metadata = None message1.tool_call = None message1.session_resumption_update = None message1.go_away = None message1.voice_activity = None message2 = mock.Mock() message2.usage_metadata = None message2.server_content = mock.Mock() message2.server_content.model_turn = None message2.server_content.interrupted = False message2.server_content.input_transcription = types.Transcription( text=' world', finished=False ) message2.server_content.output_transcription = None message2.server_content.turn_complete = False message2.server_content.generation_complete = False message2.server_content.grounding_metadata = None message2.tool_call = None message2.session_resumption_update = None message2.go_away = None message2.voice_activity = None message3 = mock.Mock() message3.usage_metadata = None message3.server_content = mock.Mock() message3.server_content.model_turn = None message3.server_content.interrupted = False message3.server_content.input_transcription = types.Transcription( text=None, finished=True ) message3.server_content.output_transcription = None message3.server_content.turn_complete = False message3.server_content.generation_complete = False message3.server_content.grounding_metadata = None message3.tool_call = None message3.session_resumption_update = None message3.go_away = None message3.voice_activity = None async def mock_receive_generator(): yield message1 yield message2 yield message3 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 3 assert responses[0].input_transcription.text == 'Hello' assert responses[0].input_transcription.finished is False assert responses[0].partial is True assert responses[1].input_transcription.text == ' world' assert responses[1].input_transcription.finished is False assert responses[1].partial is True assert responses[2].input_transcription.text == 'Hello world' assert responses[2].input_transcription.finished is True assert responses[2].partial is False @pytest.mark.asyncio async def test_receive_handles_output_transcription_fragments( gemini_connection, mock_gemini_session ): """Test receive handles output transcription fragments correctly.""" message1 = mock.Mock() message1.usage_metadata = None message1.server_content = mock.Mock() message1.server_content.model_turn = None message1.server_content.interrupted = False message1.server_content.input_transcription = None message1.server_content.output_transcription = types.Transcription( text='How can', finished=False ) message1.server_content.turn_complete = False message1.server_content.generation_complete = False message1.server_content.grounding_metadata = None message1.tool_call = None message1.session_resumption_update = None message1.go_away = None message1.voice_activity = None message2 = mock.Mock() message2.usage_metadata = None message2.server_content = mock.Mock() message2.server_content.model_turn = None message2.server_content.interrupted = False message2.server_content.input_transcription = None message2.server_content.output_transcription = types.Transcription( text=' I help?', finished=False ) message2.server_content.turn_complete = False message2.server_content.generation_complete = False message2.server_content.grounding_metadata = None message2.tool_call = None message2.session_resumption_update = None message2.go_away = None message2.voice_activity = None message3 = mock.Mock() message3.usage_metadata = None message3.server_content = mock.Mock() message3.server_content.model_turn = None message3.server_content.interrupted = False message3.server_content.input_transcription = None message3.server_content.output_transcription = types.Transcription( text=None, finished=True ) message3.server_content.turn_complete = False message3.server_content.generation_complete = False message3.server_content.grounding_metadata = None message3.tool_call = None message3.session_resumption_update = None message3.go_away = None message3.voice_activity = None async def mock_receive_generator(): yield message1 yield message2 yield message3 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 3 assert responses[0].output_transcription.text == 'How can' assert responses[0].output_transcription.finished is False assert responses[0].partial is True assert responses[1].output_transcription.text == ' I help?' assert responses[1].output_transcription.finished is False assert responses[1].partial is True assert responses[2].output_transcription.text == 'How can I help?' assert responses[2].output_transcription.finished is True assert responses[2].partial is False @pytest.mark.asyncio @pytest.mark.parametrize( 'audio_part', [ types.Part( inline_data=types.Blob(data=b'\x00\xFF', mime_type='audio/pcm') ), types.Part( file_data=types.FileData( file_uri='artifact://app/user/session/_adk_live/audio.pcm#1', mime_type='audio/pcm', ) ), ], ) async def test_send_history_filters_audio(mock_gemini_session, audio_part): """Test that audio parts (inline or file_data) are filtered out.""" connection = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI ) history = [ types.Content( role='user', parts=[audio_part], ), types.Content( role='model', parts=[types.Part.from_text(text='I heard you')] ), ] await connection.send_history(history) mock_gemini_session.send_client_content.assert_called_once() call_args = mock_gemini_session.send_client_content.call_args[1] sent_contents = call_args['turns'] # Only the model response should be sent (user audio filtered out) assert len(sent_contents) == 1 assert sent_contents[0].role == 'model' assert sent_contents[0].parts == [types.Part.from_text(text='I heard you')] @pytest.mark.asyncio async def test_send_history_keeps_image_data(mock_gemini_session): """Test that image data is NOT filtered out.""" connection = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI ) image_blob = types.Blob(data=b'\x89PNG\r\n', mime_type='image/png') history = [ types.Content( role='user', parts=[types.Part(inline_data=image_blob)], ), types.Content( role='model', parts=[types.Part.from_text(text='Nice image!')] ), ] await connection.send_history(history) mock_gemini_session.send_client_content.assert_called_once() call_args = mock_gemini_session.send_client_content.call_args[1] sent_contents = call_args['turns'] # Both contents should be sent (image is not filtered) assert len(sent_contents) == 2 assert sent_contents[0].parts[0].inline_data == image_blob @pytest.mark.asyncio async def test_send_history_mixed_content_filters_only_audio( mock_gemini_session, ): """Test that mixed content keeps non-audio parts.""" connection = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI ) history = [ types.Content( role='user', parts=[ types.Part( inline_data=types.Blob( data=b'\x00\xFF', mime_type='audio/wav' ) ), types.Part.from_text(text='transcribed text'), ], ), ] await connection.send_history(history) mock_gemini_session.send_client_content.assert_called_once() call_args = mock_gemini_session.send_client_content.call_args[1] sent_contents = call_args['turns'] # Content should be sent but only with the text part assert len(sent_contents) == 1 assert len(sent_contents[0].parts) == 1 assert sent_contents[0].parts[0].text == 'transcribed text' @pytest.mark.asyncio async def test_send_history_all_audio_content_not_sent(mock_gemini_session): """Test that content with only audio parts is completely removed.""" connection = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI ) history = [ types.Content( role='user', parts=[ types.Part( inline_data=types.Blob( data=b'\x00\xFF', mime_type='audio/pcm' ) ), types.Part( file_data=types.FileData( file_uri='artifact://audio.pcm#1', mime_type='audio/wav', ) ), ], ), ] await connection.send_history(history) # No content should be sent since all parts are audio mock_gemini_session.send.assert_not_called() @pytest.mark.asyncio async def test_send_history_empty_history_not_sent(mock_gemini_session): """Test that empty history does not call send.""" connection = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI ) await connection.send_history([]) mock_gemini_session.send.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize( 'audio_mime_type', ['audio/pcm', 'audio/wav', 'audio/mp3', 'audio/ogg'], ) async def test_send_history_filters_various_audio_mime_types( mock_gemini_session, audio_mime_type, ): """Test that various audio mime types are all filtered.""" connection = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI ) history = [ types.Content( role='user', parts=[ types.Part( inline_data=types.Blob(data=b'', mime_type=audio_mime_type) ) ], ), ] await connection.send_history(history) # No content should be sent since the only part is audio mock_gemini_session.send.assert_not_called() @pytest.mark.asyncio async def test_send_history_gemini_31_turn_complete(mock_gemini_session): """Verify Gemini 3.1 Live history seeding sets turn_complete based on history[-1].role == 'user'.""" conn = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.GEMINI_API, model_version='gemini-3.1-flash-live-preview', ) mock_gemini_session.send_client_content = mock.AsyncMock() # Last turn is model -> turn_complete=False mock_contents_model = [ types.Content(role='user', parts=[types.Part.from_text(text='hi')]), types.Content(role='model', parts=[types.Part.from_text(text='hello')]), ] await conn.send_history(mock_contents_model) mock_gemini_session.send_client_content.assert_called_once_with( turns=mock_contents_model, turn_complete=False, ) # Last turn is user -> turn_complete=True mock_gemini_session.send_client_content.reset_mock() mock_contents_user = [ types.Content(role='user', parts=[types.Part.from_text(text='hi')]), ] await conn.send_history(mock_contents_user) mock_gemini_session.send_client_content.assert_called_once_with( turns=mock_contents_user, turn_complete=True, ) @pytest.mark.asyncio async def test_send_history_vertex_ai_no_collapse(mock_gemini_session): """Verify history is sent without collapsing on Vertex AI backend.""" conn = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI, model_version='gemini-3.1-flash-live-preview', ) mock_gemini_session.send_client_content = mock.AsyncMock() # Last turn is model -> turn_complete=False mock_contents_model = [ types.Content(role='user', parts=[types.Part.from_text(text='hi')]), types.Content(role='model', parts=[types.Part.from_text(text='hello')]), ] await conn.send_history(mock_contents_model) mock_gemini_session.send_client_content.assert_called_once_with( turns=mock_contents_model, turn_complete=False, ) # Last turn is user -> turn_complete=True mock_gemini_session.send_client_content.reset_mock() mock_contents_user = [ types.Content(role='user', parts=[types.Part.from_text(text='hi')]), types.Content(role='model', parts=[types.Part.from_text(text='hello')]), types.Content( role='user', parts=[types.Part.from_text(text='how are you?')] ), ] await conn.send_history(mock_contents_user) mock_gemini_session.send_client_content.assert_called_once_with( turns=mock_contents_user, turn_complete=True, ) @pytest.mark.asyncio async def test_send_history_turn_complete_determined_by_filtered_content( mock_gemini_session, ): """Verify turn_complete is determined by the last element of filtered content instead of unfiltered history.""" conn = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.GEMINI_API, model_version='gemini-3.1-flash-live-preview', ) mock_gemini_session.send_client_content = mock.AsyncMock() # Scenario: Last turn in history is a user audio turn (gets filtered out). # The remaining last turn is model's turn -> turn_complete should be False. audio_part = types.Part( inline_data=types.Blob(data=b'\x00\xFF', mime_type='audio/pcm') ) history_with_final_audio_user_turn = [ types.Content(role='user', parts=[types.Part.from_text(text='hi')]), types.Content(role='model', parts=[types.Part.from_text(text='hello')]), types.Content(role='user', parts=[audio_part]), ] await conn.send_history(history_with_final_audio_user_turn) expected_contents = [ types.Content(role='user', parts=[types.Part.from_text(text='hi')]), types.Content(role='model', parts=[types.Part.from_text(text='hello')]), ] mock_gemini_session.send_client_content.assert_called_once_with( turns=expected_contents, turn_complete=False, ) # Scenario: Last turn in history is a model audio turn (gets filtered out). # The remaining last turn is user's turn -> turn_complete should be True. mock_gemini_session.send_client_content.reset_mock() history_with_final_audio_model_turn = [ types.Content(role='user', parts=[types.Part.from_text(text='hi')]), types.Content(role='model', parts=[audio_part]), ] await conn.send_history(history_with_final_audio_model_turn) expected_contents = [ types.Content(role='user', parts=[types.Part.from_text(text='hi')]), ] mock_gemini_session.send_client_content.assert_called_once_with( turns=expected_contents, turn_complete=True, ) @pytest.mark.asyncio async def test_receive_grounding_metadata_standalone( gemini_connection, mock_gemini_session ): """Test receive handles standalone grounding metadata correctly.""" grounding_metadata = types.GroundingMetadata( web_search_queries=['stock price of google'], search_entry_point=types.SearchEntryPoint( rendered_content='

Google

' ), ) mock_server_content = mock.create_autospec( types.LiveServerContent, instance=True ) mock_server_content.model_turn = None mock_server_content.grounding_metadata = grounding_metadata mock_server_content.turn_complete = False mock_server_content.interrupted = False mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.generation_complete = False mock_message = mock.create_autospec(types.LiveServerMessage, instance=True) mock_message.usage_metadata = None mock_message.server_content = mock_server_content mock_message.tool_call = None mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None async def mock_receive_generator(): yield mock_message receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 1 assert responses[0].grounding_metadata == grounding_metadata assert responses[0].content is None @pytest.mark.asyncio async def test_receive_grounding_metadata_with_content( gemini_connection, mock_gemini_session ): """Test receive handles grounding metadata attached to regular content.""" grounding_metadata = types.GroundingMetadata( web_search_queries=['stock price of google'], search_entry_point=types.SearchEntryPoint( rendered_content='

Google

' ), ) mock_content = types.Content( role='model', parts=[types.Part.from_text(text='response text')] ) mock_server_content = mock.create_autospec( types.LiveServerContent, instance=True ) mock_server_content.model_turn = mock_content mock_server_content.grounding_metadata = grounding_metadata mock_server_content.turn_complete = False mock_server_content.interrupted = False mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.generation_complete = False mock_message = mock.create_autospec(types.LiveServerMessage, instance=True) mock_message.usage_metadata = None mock_message.server_content = mock_server_content mock_message.tool_call = None mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None async def mock_receive_generator(): yield mock_message receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 1 assert responses[0].grounding_metadata == grounding_metadata assert responses[0].content == mock_content @pytest.mark.asyncio async def test_receive_tool_call_and_grounding_metadata_with_native_audio( mock_gemini_session, ): """Test receive handles tool call followed by grounding metadata.""" connection = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI, model_version='gemini-live-2.5-flash-native-audio', ) # 1. Message with tool call (e.g., enterprise_web_search) mock_tool_call_msg = mock.create_autospec( types.LiveServerMessage, instance=True ) mock_tool_call_msg.usage_metadata = None mock_tool_call_msg.server_content = None mock_tool_call_msg.session_resumption_update = None mock_tool_call_msg.go_away = None mock_tool_call_msg.voice_activity = None function_call = types.FunctionCall( name='enterprise_web_search', args={'query': 'Google stock price today'}, ) mock_tool_call = mock.create_autospec(types.LiveServerToolCall, instance=True) mock_tool_call.function_calls = [function_call] mock_tool_call_msg.tool_call = mock_tool_call # 2. Message with grounding metadata and audio content (native audio model) grounding_metadata = types.GroundingMetadata( web_search_queries=['Google stock price today'], search_entry_point=types.SearchEntryPoint( rendered_content='

Google

' ), ) audio_blob = types.Blob(data=b'\x00\xFF', mime_type='audio/pcm') mock_content = types.Content( role='model', parts=[types.Part(inline_data=audio_blob)] ) mock_server_content = mock.create_autospec( types.LiveServerContent, instance=True ) mock_server_content.model_turn = mock_content mock_server_content.grounding_metadata = grounding_metadata mock_server_content.turn_complete = False mock_server_content.interrupted = False mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.generation_complete = False mock_metadata_msg = mock.create_autospec( types.LiveServerMessage, instance=True ) mock_metadata_msg.usage_metadata = None mock_metadata_msg.server_content = mock_server_content mock_metadata_msg.tool_call = None mock_metadata_msg.session_resumption_update = None mock_metadata_msg.go_away = None mock_metadata_msg.voice_activity = None # 3. Message with turn_complete mock_turn_complete_content = mock.create_autospec( types.LiveServerContent, instance=True ) mock_turn_complete_content.model_turn = None mock_turn_complete_content.grounding_metadata = None mock_turn_complete_content.turn_complete = True mock_turn_complete_content.interrupted = False mock_turn_complete_content.input_transcription = None mock_turn_complete_content.output_transcription = None mock_turn_complete_content.generation_complete = False mock_turn_complete_msg = mock.create_autospec( types.LiveServerMessage, instance=True ) mock_turn_complete_msg.usage_metadata = None mock_turn_complete_msg.server_content = mock_turn_complete_content mock_turn_complete_msg.tool_call = None mock_turn_complete_msg.session_resumption_update = None mock_turn_complete_msg.go_away = None mock_turn_complete_msg.voice_activity = None async def mock_receive_generator(): yield mock_tool_call_msg yield mock_metadata_msg yield mock_turn_complete_msg receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in connection.receive()] assert len(responses) == 3 # First response: the audio content and grounding metadata assert responses[0].grounding_metadata == grounding_metadata assert responses[0].content == mock_content assert responses[0].content is not None assert responses[0].content.parts is not None assert responses[0].content.parts[0].inline_data == audio_blob # Second response: the tool call, buffered until turn_complete assert responses[1].content is not None assert responses[1].content.parts is not None assert responses[1].content.parts[0].function_call is not None assert ( responses[1].content.parts[0].function_call.name == 'enterprise_web_search' ) assert responses[1].content.parts[0].function_call.args == { 'query': 'Google stock price today' } assert responses[1].grounding_metadata is None # Third response: the turn_complete assert responses[2].turn_complete is True @pytest.mark.asyncio async def test_receive_multiple_tool_calls_buffered_until_turn_complete( gemini_connection, mock_gemini_session ): """Test receive buffers multiple tool call messages until turn complete.""" # First tool call message mock_tool_call_msg1 = mock.create_autospec( types.LiveServerMessage, instance=True ) mock_tool_call_msg1.usage_metadata = None mock_tool_call_msg1.server_content = None mock_tool_call_msg1.session_resumption_update = None mock_tool_call_msg1.go_away = None mock_tool_call_msg1.voice_activity = None function_call1 = types.FunctionCall( name='tool_1', args={'arg': 'value1'}, ) mock_tool_call1 = mock.create_autospec( types.LiveServerToolCall, instance=True ) mock_tool_call1.function_calls = [function_call1] mock_tool_call_msg1.tool_call = mock_tool_call1 # Second tool call message mock_tool_call_msg2 = mock.create_autospec( types.LiveServerMessage, instance=True ) mock_tool_call_msg2.usage_metadata = None mock_tool_call_msg2.server_content = None mock_tool_call_msg2.session_resumption_update = None mock_tool_call_msg2.go_away = None mock_tool_call_msg2.voice_activity = None function_call2 = types.FunctionCall( name='tool_2', args={'arg': 'value2'}, ) mock_tool_call2 = mock.create_autospec( types.LiveServerToolCall, instance=True ) mock_tool_call2.function_calls = [function_call2] mock_tool_call_msg2.tool_call = mock_tool_call2 # Turn complete message mock_turn_complete_content = mock.create_autospec( types.LiveServerContent, instance=True ) mock_turn_complete_content.model_turn = None mock_turn_complete_content.grounding_metadata = None mock_turn_complete_content.turn_complete = True mock_turn_complete_content.interrupted = False mock_turn_complete_content.input_transcription = None mock_turn_complete_content.output_transcription = None mock_turn_complete_msg = mock.create_autospec( types.LiveServerMessage, instance=True ) mock_turn_complete_msg.usage_metadata = None mock_turn_complete_msg.server_content = mock_turn_complete_content mock_turn_complete_msg.tool_call = None mock_turn_complete_msg.session_resumption_update = None mock_turn_complete_msg.go_away = None mock_turn_complete_msg.voice_activity = None async def mock_receive_generator(): yield mock_tool_call_msg1 yield mock_tool_call_msg2 yield mock_turn_complete_msg receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] # Expected: One LlmResponse with both tool calls, then one with turn_complete assert len(responses) == 2 # First response: single LlmResponse carrying both function calls assert responses[0].content is not None parts = responses[0].content.parts assert len(parts) == 2 assert parts[0].function_call.name == 'tool_1' assert parts[0].function_call.args == {'arg': 'value1'} assert parts[1].function_call.name == 'tool_2' assert parts[1].function_call.args == {'arg': 'value2'} # Second response: turn_complete True assert responses[1].turn_complete is True @pytest.mark.asyncio async def test_receive_tool_calls_yielded_immediately_for_gemini_3_1( mock_gemini_session, ): """Test that tool calls are yielded immediately for Gemini 3.1.""" connection = GeminiLlmConnection( mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI, model_version='gemini-3.1-flash-live-preview', ) mock_tool_call_msg = mock.create_autospec( types.LiveServerMessage, instance=True ) mock_tool_call_msg.usage_metadata = None mock_tool_call_msg.server_content = None mock_tool_call_msg.session_resumption_update = None mock_tool_call_msg.go_away = None mock_tool_call_msg.voice_activity = None function_call = types.FunctionCall( name='test_tool', args={'arg': 'value'}, ) mock_tool_call = mock.create_autospec(types.LiveServerToolCall, instance=True) mock_tool_call.function_calls = [function_call] mock_tool_call_msg.tool_call = mock_tool_call async def mock_receive_generator(): yield mock_tool_call_msg receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [] async for resp in connection.receive(): responses.append(resp) break assert len(responses) == 1 assert responses[0].content is not None assert responses[0].content.parts[0].function_call.name == 'test_tool' @pytest.mark.asyncio async def test_receive_go_away(gemini_connection, mock_gemini_session): """Test receive yields go_away message.""" mock_go_away = types.LiveServerGoAway(timeLeft='10s') mock_msg = mock.MagicMock() mock_msg.usage_metadata = None mock_msg.server_content = None mock_msg.tool_call = None mock_msg.session_resumption_update = None mock_msg.go_away = mock_go_away mock_msg.voice_activity = None async def mock_receive_generator(): yield mock_msg receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 1 assert responses[0].go_away == mock_go_away @pytest.mark.asyncio async def test_receive_aggregates_thoughts_separately( gemini_connection, mock_gemini_session ): """Test receive aggregates thoughts and regular text separately.""" part1 = types.Part.from_text(text='thought 1') part1.thought = True message1 = types.LiveServerMessage( server_content=types.LiveServerContent( model_turn=types.Content(role='model', parts=[part1]), ) ) part2 = types.Part.from_text(text=' thought 2') part2.thought = True message2 = types.LiveServerMessage( server_content=types.LiveServerContent( model_turn=types.Content(role='model', parts=[part2]), ) ) part3 = types.Part.from_text(text='answer') part3.thought = False message3 = types.LiveServerMessage( server_content=types.LiveServerContent( model_turn=types.Content(role='model', parts=[part3]), ) ) message4 = types.LiveServerMessage( server_content=types.LiveServerContent( turn_complete=True, ) ) async def mock_receive_generator(): yield message1 yield message2 yield message3 yield message4 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] # Expected responses: # 1. Message 1 (partial thought) # 2. Message 2 (partial thought) # 3. Aggregated thought (full) # 4. Message 3 (partial answer) # 5. Aggregated answer (full) # 6. Turn complete message assert len(responses) == 6 # Check partials assert responses[0].content.parts[0].text == 'thought 1' assert responses[0].partial is True assert responses[1].content.parts[0].text == ' thought 2' assert responses[1].partial is True # Check aggregated thought assert responses[2].content.parts[0].text == 'thought 1 thought 2' assert responses[2].content.parts[0].thought is True assert responses[2].partial is False # Check partial answer assert responses[3].content.parts[0].text == 'answer' assert responses[3].partial is True # Check aggregated answer assert responses[4].content.parts[0].text == 'answer' assert not getattr(responses[4].content.parts[0], 'thought', False) assert responses[4].partial is False # Check turn complete assert responses[5].turn_complete is True @pytest.mark.asyncio async def test_receive_video_content(gemini_connection, mock_gemini_session): """Test receive with video content.""" mock_content = types.Content( role='model', parts=[ types.Part( inline_data=types.Blob(data=b'video_data', mime_type='video/mp4') ) ], ) mock_server_content = mock.Mock() mock_server_content.model_turn = mock_content mock_server_content.interrupted = False mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.turn_complete = False mock_server_content.grounding_metadata = None mock_message = mock.AsyncMock() mock_message.usage_metadata = None mock_message.server_content = mock_server_content mock_message.tool_call = None mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None async def mock_receive_generator(): yield mock_message receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert responses content_response = next((r for r in responses if r.content), None) assert content_response is not None assert content_response.content == mock_content @pytest.mark.asyncio async def test_receive_grounding_metadata_pending( gemini_connection, mock_gemini_session ): """Test that grounding metadata in partial chunks is pending and yielded on full text.""" grounding_metadata = types.GroundingMetadata( web_search_queries=['stock price of google'], ) def make_msg( text: str | None = None, g_meta: types.GroundingMetadata | None = None, tc: bool = False, ) -> mock.Mock: msg = mock.Mock( usage_metadata=None, tool_call=None, session_resumption_update=None, go_away=None, voice_activity=None, ) msg.server_content = mock.Mock( interrupted=False, input_transcription=None, output_transcription=None, generation_complete=False, turn_complete=tc, grounding_metadata=g_meta, model_turn=types.Content( role='model', parts=[types.Part.from_text(text=text)] ) if text else None, ) return msg msg1 = make_msg(text='hello', g_meta=grounding_metadata) msg2 = make_msg(text=' world') msg3 = make_msg(tc=True) async def gen(): yield msg1 yield msg2 yield msg3 mock_gemini_session.receive = mock.Mock(return_value=gen()) responses = [resp async for resp in gemini_connection.receive()] # Expected responses: # 1. Msg 1 partial (hello) with grounding_metadata # 2. Msg 2 partial ( world) without grounding_metadata # 3. Full text response (hello world) with PENDING grounding_metadata # 4. Turn complete response without grounding_metadata (already cleared) assert len(responses) == 4 assert responses[0].content.parts[0].text == 'hello' assert responses[0].partial is True assert responses[0].grounding_metadata == grounding_metadata assert responses[1].content.parts[0].text == ' world' assert responses[1].partial is True assert responses[1].grounding_metadata is None assert responses[2].content.parts[0].text == 'hello world' assert responses[2].partial is False assert responses[2].grounding_metadata == grounding_metadata assert responses[3].turn_complete is True assert responses[3].grounding_metadata is None @pytest.mark.asyncio async def test_receive_populates_turn_complete_reason( gemini_connection, mock_gemini_session ): """Test that receive populates turn_complete_reason in LlmResponse.""" mock_server_content = mock.create_autospec( types.LiveServerContent, instance=True ) mock_server_content.model_turn = None mock_server_content.grounding_metadata = None mock_server_content.turn_complete = True mock_server_content.interrupted = False mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.generation_complete = False mock_server_content.turn_complete_reason = ( types.TurnCompleteReason.RESPONSE_REJECTED ) mock_message = mock.create_autospec(types.LiveServerMessage, instance=True) mock_message.usage_metadata = None mock_message.server_content = mock_server_content mock_message.tool_call = None mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None async def mock_receive_generator(): yield mock_message mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator()) responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 1 assert responses[0].turn_complete is True assert ( responses[0].turn_complete_reason == types.TurnCompleteReason.RESPONSE_REJECTED ) @pytest.mark.asyncio async def test_receive_populates_turn_complete_reason_standalone_grounding( gemini_connection, mock_gemini_session ): """Test that receive populates turn_complete_reason in LlmResponse for standalone grounding metadata.""" mock_server_content = mock.create_autospec( types.LiveServerContent, instance=True ) mock_server_content.model_turn = None mock_server_content.grounding_metadata = types.GroundingMetadata() mock_server_content.turn_complete = False mock_server_content.interrupted = False mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.generation_complete = False mock_server_content.turn_complete_reason = ( types.TurnCompleteReason.RESPONSE_REJECTED ) mock_message = mock.create_autospec(types.LiveServerMessage, instance=True) mock_message.usage_metadata = None mock_message.server_content = mock_server_content mock_message.tool_call = None mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None async def mock_receive_generator(): yield mock_message mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator()) responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 1 assert responses[0].grounding_metadata is not None assert responses[0].turn_complete is None assert ( responses[0].turn_complete_reason == types.TurnCompleteReason.RESPONSE_REJECTED ) @pytest.mark.asyncio async def test_receive_populates_turn_complete_reason_with_content( gemini_connection, mock_gemini_session ): """Test that receive populates turn_complete_reason in LlmResponse when model turn has content parts.""" mock_content = types.Content( role='model', parts=[types.Part.from_text(text='hello')], ) mock_server_content = mock.create_autospec( types.LiveServerContent, instance=True ) mock_server_content.model_turn = mock_content mock_server_content.grounding_metadata = None mock_server_content.turn_complete = False mock_server_content.interrupted = False mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.generation_complete = False mock_server_content.turn_complete_reason = ( types.TurnCompleteReason.RESPONSE_REJECTED ) mock_message = mock.create_autospec(types.LiveServerMessage, instance=True) mock_message.usage_metadata = None mock_message.server_content = mock_server_content mock_message.tool_call = None mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None async def mock_receive_generator(): yield mock_message mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator()) responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 1 assert responses[0].content == mock_content assert ( responses[0].turn_complete_reason == types.TurnCompleteReason.RESPONSE_REJECTED ) @pytest.mark.asyncio async def test_receive_grounding_metadata_default_gemini_3_1( mock_gemini_session, ): """Verify grounding_metadata defaults to empty GroundingMetadata for Gemini 3.1.""" conn = GeminiLlmConnection( mock_gemini_session, model_version='gemini-3.1-flash-live-preview', ) def make_msg( text: str | None = None, tc: bool = False, tool_call: types.LiveServerToolCall | None = None, ) -> mock.Mock: msg = mock.create_autospec(types.LiveServerMessage, instance=True) msg.usage_metadata = None msg.tool_call = tool_call msg.session_resumption_update = None msg.go_away = None msg.voice_activity = None msg.server_content = mock.Mock() msg.server_content.interrupted = False msg.server_content.input_transcription = None msg.server_content.output_transcription = None msg.server_content.generation_complete = False msg.server_content.turn_complete = tc msg.server_content.grounding_metadata = None msg.server_content.model_turn = ( types.Content(role='model', parts=[types.Part.from_text(text=text)]) if text else None ) return msg # 1. Content event msg1 = make_msg(text='hello') # 2. Tool call event (yields immediately for Gemini 3.1) function_call = types.FunctionCall(name='foo', args={}) tool_call = mock.create_autospec(types.LiveServerToolCall, instance=True) tool_call.function_calls = [function_call] msg2 = make_msg(tool_call=tool_call) # 3. Turn complete event msg3 = make_msg(tc=True) async def mock_receive_generator(): yield msg1 yield msg2 yield msg3 mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator()) responses = [resp async for resp in conn.receive()] # Expected: # responses[0] -> partial content response for msg1 (has no grounding_metadata) # responses[1] -> full text response for msg1 (has no grounding_metadata) # responses[2] -> tool call response for msg2 (has no grounding_metadata) # responses[3] -> turn_complete response for msg3 (has grounding_metadata) assert len(responses) == 4 assert responses[0].content.parts[0].text == 'hello' assert responses[0].grounding_metadata is None assert responses[0].partial is True assert responses[1].content.parts[0].text == 'hello' assert responses[1].grounding_metadata is None assert responses[1].partial is False assert responses[2].content.parts[0].function_call.name == 'foo' assert responses[2].grounding_metadata is None assert responses[3].turn_complete is True assert isinstance(responses[3].grounding_metadata, types.GroundingMetadata) @pytest.mark.asyncio async def test_receive_grounding_metadata_default_non_gemini_3_1( mock_gemini_session, ): """Verify grounding_metadata stays None for non-Gemini 3.1 models.""" conn = GeminiLlmConnection( mock_gemini_session, model_version='gemini-2.5-flash-live', ) def make_msg(text: str | None = None, tc: bool = False) -> mock.Mock: msg = mock.create_autospec(types.LiveServerMessage, instance=True) msg.usage_metadata = None msg.tool_call = None msg.session_resumption_update = None msg.go_away = None msg.voice_activity = None msg.server_content = mock.Mock() msg.server_content.interrupted = False msg.server_content.input_transcription = None msg.server_content.output_transcription = None msg.server_content.generation_complete = False msg.server_content.turn_complete = tc msg.server_content.grounding_metadata = None msg.server_content.model_turn = ( types.Content(role='model', parts=[types.Part.from_text(text=text)]) if text else None ) return msg msg1 = make_msg(text='hello') msg2 = make_msg(tc=True) async def mock_receive_generator(): yield msg1 yield msg2 mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator()) responses = [resp async for resp in conn.receive()] assert len(responses) == 3 assert responses[0].content.parts[0].text == 'hello' assert responses[0].grounding_metadata is None assert responses[0].partial is True assert responses[1].content.parts[0].text == 'hello' assert responses[1].grounding_metadata is None assert responses[1].partial is False assert responses[2].turn_complete is True assert responses[2].grounding_metadata is None @pytest.mark.asyncio async def test_receive_input_transcription_gemini_3_1( mock_gemini_session, ): """Verify input_transcription yields finished=True immediately for Gemini 3.1.""" conn = GeminiLlmConnection( mock_gemini_session, model_version='gemini-3.1-flash-live-preview', ) def make_msg( input_text=None, output_text=None, output_finished=False, tc=False ): msg = mock.create_autospec(types.LiveServerMessage, instance=True) msg.usage_metadata = None msg.tool_call = None msg.session_resumption_update = None msg.go_away = None msg.voice_activity = None msg.server_content = mock.Mock() msg.server_content.interrupted = False msg.server_content.input_transcription = ( types.Transcription(text=input_text, finished=False) if input_text else None ) msg.server_content.output_transcription = ( types.Transcription(text=output_text, finished=output_finished) if output_text else None ) msg.server_content.generation_complete = False msg.server_content.turn_complete = tc msg.server_content.grounding_metadata = None msg.server_content.model_turn = None return msg msg1 = make_msg(input_text='Hello') msg2 = make_msg(output_text='Hi there!', output_finished=True) msg3 = make_msg(tc=True) async def mock_receive_generator(): yield msg1 yield msg2 yield msg3 mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator()) responses = [resp async for resp in conn.receive()] assert len(responses) == 4 assert responses[0].input_transcription.text == 'Hello' assert responses[0].input_transcription.finished is True assert responses[0].partial is False assert responses[1].output_transcription.text == 'Hi there!' assert responses[1].output_transcription.finished is False assert responses[1].partial is True assert responses[2].output_transcription.text == 'Hi there!' assert responses[2].output_transcription.finished is True assert responses[2].partial is False assert responses[3].turn_complete is True def _create_mock_receive_message( model_turn: types.Content | None = None, grounding_metadata: types.GroundingMetadata | None = None, interrupted: bool = False, turn_complete: bool = False, tool_call: types.LiveServerToolCall | mock.Mock | None = None, ) -> mock.Mock: """Helper to create a mock message from the Gemini API.""" mock_server_content = mock.Mock() mock_server_content.model_turn = model_turn mock_server_content.interrupted = interrupted mock_server_content.input_transcription = None mock_server_content.output_transcription = None mock_server_content.turn_complete = turn_complete mock_server_content.generation_complete = False mock_server_content.grounding_metadata = grounding_metadata mock_message = mock.Mock() mock_message.usage_metadata = None mock_message.server_content = mock_server_content mock_message.tool_call = tool_call mock_message.session_resumption_update = None mock_message.go_away = None mock_message.voice_activity = None return mock_message @pytest.mark.asyncio async def test_receive_extracts_grounding_metadata( gemini_connection, mock_gemini_session ): """Test that grounding_metadata is extracted and included in LlmResponse.""" mock_content = types.Content( role='model', parts=[types.Part.from_text(text='response text')] ) mock_grounding_metadata = types.GroundingMetadata( retrieval_queries=['test query'], web_search_queries=['web search query'], ) mock_message = _create_mock_receive_message( model_turn=mock_content, grounding_metadata=mock_grounding_metadata, turn_complete=True, ) async def mock_receive_generator(): yield mock_message receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert responses # The last response (turn_complete) should have the grounding metadata turn_complete_response = next((r for r in responses if r.turn_complete), None) assert turn_complete_response is not None assert turn_complete_response.grounding_metadata == mock_grounding_metadata @pytest.mark.asyncio async def test_receive_grounding_metadata_reset_after_tool_call( gemini_connection, mock_gemini_session ): """Test grounding_metadata reset after tool_call.""" mock_grounding_metadata = types.GroundingMetadata( retrieval_queries=['test query'], ) message1 = _create_mock_receive_message( grounding_metadata=mock_grounding_metadata ) mock_function_call = types.FunctionCall( name='test_function', args={'param': 'value'} ) mock_tool_call = mock.Mock() mock_tool_call.function_calls = [mock_function_call] message2 = _create_mock_receive_message(tool_call=mock_tool_call) message2.server_content = None message3 = _create_mock_receive_message(turn_complete=True) async def mock_receive_generator(): yield message1 yield message2 yield message3 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] # If Gemini 3.1, it yields immediately. If not, it buffers. # But in both cases, the tool call response should have the grounding metadata # and the subsequent turn_complete should NOT have it (reset). tool_call_response = next( (r for r in responses if r.content and r.content.parts[0].function_call), None, ) assert tool_call_response is not None assert tool_call_response.grounding_metadata == mock_grounding_metadata turn_complete_response = next((r for r in responses if r.turn_complete), None) assert turn_complete_response is not None assert turn_complete_response.grounding_metadata is None @pytest.mark.asyncio async def test_receive_grounding_metadata_accumulates_across_messages( gemini_connection, mock_gemini_session ): """Test grounding_metadata accumulated across messages.""" grounding1 = types.GroundingMetadata( retrieval_queries=['query1'], ) grounding2 = types.GroundingMetadata( retrieval_queries=['query2'], grounding_chunks=[ types.GroundingChunk( web=types.GroundingChunkWeb(uri='https://example.com') ) ], ) mock_content1 = types.Content( role='model', parts=[types.Part.from_text(text='part1')] ) message1 = _create_mock_receive_message( model_turn=mock_content1, grounding_metadata=grounding1 ) mock_content2 = types.Content( role='model', parts=[types.Part.from_text(text=' part2')] ) message2 = _create_mock_receive_message( model_turn=mock_content2, grounding_metadata=grounding2 ) message3 = _create_mock_receive_message(turn_complete=True) async def mock_receive_generator(): yield message1 yield message2 yield message3 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 4 assert responses[2].content.parts[0].text == 'part1 part2' merged = responses[2].grounding_metadata assert merged is not None assert merged.retrieval_queries == ['query1', 'query2'] assert len(merged.grounding_chunks) == 1 assert merged.grounding_chunks[0].web.uri == 'https://example.com' assert responses[3].turn_complete is True assert responses[3].grounding_metadata is None @pytest.mark.asyncio async def test_receive_interrupted_with_pending_text_preserves_flag( gemini_connection, mock_gemini_session ): """Test interrupted flag when flushing pending text.""" mock_grounding_metadata = types.GroundingMetadata( retrieval_queries=['test query'], ) mock_content1 = types.Content( role='model', parts=[types.Part.from_text(text='partial')] ) message1 = _create_mock_receive_message( model_turn=mock_content1, grounding_metadata=mock_grounding_metadata ) mock_content2 = types.Content( role='model', parts=[types.Part.from_text(text=' text')] ) message2 = _create_mock_receive_message(model_turn=mock_content2) message3 = _create_mock_receive_message(interrupted=True) async def mock_receive_generator(): yield message1 yield message2 yield message3 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] full_text_responses = [ r for r in responses if r.content and not r.partial and r.interrupted ] assert ( len(full_text_responses) > 0 ), 'Should have interrupted full text response' assert full_text_responses[0].content.parts[0].text == 'partial text' assert full_text_responses[0].grounding_metadata == mock_grounding_metadata assert full_text_responses[0].interrupted is True @pytest.mark.asyncio async def test_receive_grounding_metadata_accumulates_deduplicates_and_shifts_indices( gemini_connection, mock_gemini_session ): """Test grounding_metadata deduplicates queries and shifts support indices.""" grounding1 = types.GroundingMetadata( retrieval_queries=['query1'], grounding_chunks=[ types.GroundingChunk( web=types.GroundingChunkWeb(uri='https://example.com/1') ) ], grounding_supports=[ types.GroundingSupport( segment=types.Segment(start_index=0, end_index=5, text='hello'), grounding_chunk_indices=[0], ) ], ) grounding2 = types.GroundingMetadata( retrieval_queries=['query1', 'query2'], # 'query1' is duplicate grounding_chunks=[ types.GroundingChunk( web=types.GroundingChunkWeb(uri='https://example.com/2') ) ], grounding_supports=[ types.GroundingSupport( segment=types.Segment(start_index=6, end_index=11, text='world'), grounding_chunk_indices=[0], # index should scale to 1 in merged ) ], ) mock_content1 = types.Content( role='model', parts=[types.Part.from_text(text='hello')] ) message1 = _create_mock_receive_message( model_turn=mock_content1, grounding_metadata=grounding1 ) mock_content2 = types.Content( role='model', parts=[types.Part.from_text(text=' world')] ) message2 = _create_mock_receive_message( model_turn=mock_content2, grounding_metadata=grounding2 ) message3 = _create_mock_receive_message(turn_complete=True) async def mock_receive_generator(): yield message1 yield message2 yield message3 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] # Find the full text response (yielding accumulated) full_text_resp = responses[2] assert full_text_resp.content.parts[0].text == 'hello world' merged = full_text_resp.grounding_metadata assert merged is not None # query1 should only appear once assert merged.retrieval_queries == ['query1', 'query2'] # both chunks should be present assert len(merged.grounding_chunks) == 2 assert merged.grounding_chunks[0].web.uri == 'https://example.com/1' assert merged.grounding_chunks[1].web.uri == 'https://example.com/2' # grounding supports indices: assert len(merged.grounding_supports) == 2 # first support index stays 0 assert merged.grounding_supports[0].grounding_chunk_indices == [0] # second support index shifted to 1 assert merged.grounding_supports[1].grounding_chunk_indices == [1] @pytest.mark.asyncio async def test_receive_incomplete_grounding_logs_warning_only_on_turn_complete( gemini_connection, mock_gemini_session, caplog ): """Test that incomplete grounding metadata warns at turn_complete but not midway.""" grounding1 = types.GroundingMetadata( retrieval_queries=['query1'], ) mock_content1 = types.Content( role='model', parts=[types.Part.from_text(text='hello')] ) message1 = _create_mock_receive_message( model_turn=mock_content1, grounding_metadata=grounding1 ) message2 = _create_mock_receive_message(turn_complete=True) async def mock_receive_generator(): yield message1 yield message2 receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock with caplog.at_level('WARNING'): responses = [resp async for resp in gemini_connection.receive()] # We received two messages. The warning should be logged because retrieval_queries was present # but no grounding chunks were received when turn completed. incomplete_warnings = [ record for record in caplog.records if 'Incomplete grounding_metadata received' in record.message ] assert len(incomplete_warnings) == 1 assert 'query1' in incomplete_warnings[0].message @pytest.mark.asyncio async def test_receive_voice_activity(gemini_connection, mock_gemini_session): """Test receive yields voice_activity message.""" mock_vad = types.VoiceActivity( voice_activity_type=types.VoiceActivityType.ACTIVITY_START, audio_offset='1.5s', ) message = _create_mock_receive_message() message.voice_activity = mock_vad async def mock_receive_generator(): yield message receive_mock = mock.Mock(return_value=mock_receive_generator()) mock_gemini_session.receive = receive_mock responses = [resp async for resp in gemini_connection.receive()] assert len(responses) == 1 assert responses[0].voice_activity == mock_vad