ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
441 lines
16 KiB
Python
441 lines
16 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import time
|
|
from unittest.mock import AsyncMock
|
|
from unittest.mock import Mock
|
|
|
|
from google.adk.flows.llm_flows.audio_cache_manager import AudioCacheConfig
|
|
from google.adk.flows.llm_flows.audio_cache_manager import AudioCacheManager
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
from ... import testing_utils
|
|
|
|
|
|
class TestAudioCacheConfig:
|
|
"""Test the AudioCacheConfig class."""
|
|
|
|
def test_default_values(self):
|
|
"""Test that default configuration values are set correctly."""
|
|
config = AudioCacheConfig()
|
|
assert config.max_cache_size_bytes == 10 * 1024 * 1024 # 10MB
|
|
assert config.max_cache_duration_seconds == 300.0 # 5 minutes
|
|
assert config.auto_flush_threshold == 100
|
|
|
|
def test_custom_values(self):
|
|
"""Test that custom configuration values are set correctly."""
|
|
config = AudioCacheConfig(
|
|
max_cache_size_bytes=5 * 1024 * 1024,
|
|
max_cache_duration_seconds=120.0,
|
|
auto_flush_threshold=50,
|
|
)
|
|
assert config.max_cache_size_bytes == 5 * 1024 * 1024
|
|
assert config.max_cache_duration_seconds == 120.0
|
|
assert config.auto_flush_threshold == 50
|
|
|
|
|
|
class TestAudioCacheManager:
|
|
"""Test the AudioCacheManager class."""
|
|
|
|
def setup_method(self):
|
|
"""Set up test fixtures."""
|
|
self.config = AudioCacheConfig()
|
|
self.manager = AudioCacheManager(self.config)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_input_audio(self):
|
|
"""Test caching input audio data."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
audio_blob = types.Blob(data=b'test_audio_data', mime_type='audio/pcm')
|
|
|
|
# Initially no cache
|
|
assert invocation_context.input_realtime_cache is None
|
|
|
|
# Cache audio
|
|
self.manager.cache_audio(invocation_context, audio_blob, 'input')
|
|
|
|
# Verify cache is created and populated
|
|
assert invocation_context.input_realtime_cache is not None
|
|
assert len(invocation_context.input_realtime_cache) == 1
|
|
|
|
entry = invocation_context.input_realtime_cache[0]
|
|
assert entry.role == 'user'
|
|
assert entry.data == audio_blob
|
|
assert isinstance(entry.timestamp, float)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_output_audio(self):
|
|
"""Test caching output audio data."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
audio_blob = types.Blob(data=b'test_model_audio', mime_type='audio/wav')
|
|
|
|
# Initially no cache
|
|
assert invocation_context.output_realtime_cache is None
|
|
|
|
# Cache audio
|
|
self.manager.cache_audio(invocation_context, audio_blob, 'output')
|
|
|
|
# Verify cache is created and populated
|
|
assert invocation_context.output_realtime_cache is not None
|
|
assert len(invocation_context.output_realtime_cache) == 1
|
|
|
|
entry = invocation_context.output_realtime_cache[0]
|
|
assert entry.role == 'model'
|
|
assert entry.data == audio_blob
|
|
assert isinstance(entry.timestamp, float)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_audio_caching(self):
|
|
"""Test caching multiple audio chunks."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# Cache multiple input audio chunks
|
|
for i in range(3):
|
|
audio_blob = types.Blob(data=f'input_{i}'.encode(), mime_type='audio/pcm')
|
|
self.manager.cache_audio(invocation_context, audio_blob, 'input')
|
|
|
|
# Cache multiple output audio chunks
|
|
for i in range(2):
|
|
audio_blob = types.Blob(
|
|
data=f'output_{i}'.encode(), mime_type='audio/wav'
|
|
)
|
|
self.manager.cache_audio(invocation_context, audio_blob, 'output')
|
|
|
|
# Verify all chunks are cached
|
|
assert len(invocation_context.input_realtime_cache) == 3
|
|
assert len(invocation_context.output_realtime_cache) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_caches_both(self):
|
|
"""Test flushing both input and output caches."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# Set up mock artifact service
|
|
mock_artifact_service = AsyncMock()
|
|
mock_artifact_service.save_artifact.return_value = 123
|
|
invocation_context.artifact_service = mock_artifact_service
|
|
|
|
# Cache some audio
|
|
input_blob = types.Blob(data=b'input_data', mime_type='audio/pcm')
|
|
output_blob = types.Blob(data=b'output_data', mime_type='audio/wav')
|
|
self.manager.cache_audio(invocation_context, input_blob, 'input')
|
|
self.manager.cache_audio(invocation_context, output_blob, 'output')
|
|
|
|
# Flush caches
|
|
await self.manager.flush_caches(invocation_context)
|
|
|
|
# Verify caches are cleared
|
|
assert invocation_context.input_realtime_cache == []
|
|
assert invocation_context.output_realtime_cache == []
|
|
|
|
# Verify artifact service was called twice (once for each cache)
|
|
assert mock_artifact_service.save_artifact.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_caches_selective(self):
|
|
"""Test selectively flushing only one cache."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# Set up mock artifact service
|
|
mock_artifact_service = AsyncMock()
|
|
mock_artifact_service.save_artifact.return_value = 123
|
|
invocation_context.artifact_service = mock_artifact_service
|
|
|
|
# Cache some audio
|
|
input_blob = types.Blob(data=b'input_data', mime_type='audio/pcm')
|
|
output_blob = types.Blob(data=b'output_data', mime_type='audio/wav')
|
|
self.manager.cache_audio(invocation_context, input_blob, 'input')
|
|
self.manager.cache_audio(invocation_context, output_blob, 'output')
|
|
|
|
# Flush only input cache
|
|
await self.manager.flush_caches(
|
|
invocation_context, flush_user_audio=True, flush_model_audio=False
|
|
)
|
|
|
|
# Verify only input cache is cleared
|
|
assert invocation_context.input_realtime_cache == []
|
|
assert len(invocation_context.output_realtime_cache) == 1
|
|
|
|
# Verify artifact service was called once
|
|
assert mock_artifact_service.save_artifact.call_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_empty_caches(self):
|
|
"""Test flushing when caches are empty."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# Set up mock artifact service
|
|
mock_artifact_service = AsyncMock()
|
|
invocation_context.artifact_service = mock_artifact_service
|
|
|
|
# Flush empty caches (should not error)
|
|
await self.manager.flush_caches(invocation_context)
|
|
|
|
# Verify artifact service was not called
|
|
mock_artifact_service.save_artifact.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_without_artifact_service(self):
|
|
"""Test flushing when no artifact service is available."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# No artifact service
|
|
invocation_context.artifact_service = None
|
|
|
|
# Cache some audio
|
|
input_blob = types.Blob(data=b'input_data', mime_type='audio/pcm')
|
|
self.manager.cache_audio(invocation_context, input_blob, 'input')
|
|
|
|
# Flush should not error but should not clear cache either
|
|
await self.manager.flush_caches(invocation_context)
|
|
|
|
# Cache should remain (no actual flushing happened)
|
|
assert len(invocation_context.input_realtime_cache) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_artifact_creation(self):
|
|
"""Test that artifacts are created correctly during flush."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# Set up mock services
|
|
mock_artifact_service = AsyncMock()
|
|
mock_artifact_service.save_artifact.return_value = 456
|
|
mock_session_service = AsyncMock()
|
|
|
|
invocation_context.artifact_service = mock_artifact_service
|
|
invocation_context.session_service = mock_session_service
|
|
|
|
# Cache audio with specific data
|
|
test_data = b'specific_test_audio_data'
|
|
audio_blob = types.Blob(data=test_data, mime_type='audio/pcm')
|
|
self.manager.cache_audio(invocation_context, audio_blob, 'input')
|
|
|
|
# Flush cache
|
|
await self.manager.flush_caches(invocation_context)
|
|
|
|
# Verify artifact was saved with correct data
|
|
mock_artifact_service.save_artifact.assert_called_once()
|
|
call_args = mock_artifact_service.save_artifact.call_args
|
|
saved_artifact = call_args.kwargs['artifact']
|
|
assert saved_artifact.inline_data.data == test_data
|
|
assert saved_artifact.inline_data.mime_type == 'audio/pcm'
|
|
|
|
# Verify session event was created
|
|
mock_session_service.append_event.assert_not_called()
|
|
|
|
def test_get_cache_stats_empty(self):
|
|
"""Test getting statistics for empty caches."""
|
|
invocation_context = Mock()
|
|
invocation_context.input_realtime_cache = None
|
|
invocation_context.output_realtime_cache = None
|
|
|
|
stats = self.manager.get_cache_stats(invocation_context)
|
|
|
|
expected = {
|
|
'input_chunks': 0,
|
|
'output_chunks': 0,
|
|
'input_bytes': 0,
|
|
'output_bytes': 0,
|
|
'total_chunks': 0,
|
|
'total_bytes': 0,
|
|
}
|
|
assert stats == expected
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_cache_stats_with_data(self):
|
|
"""Test getting statistics for caches with data."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# Cache some audio data of different sizes
|
|
input_blob1 = types.Blob(data=b'12345', mime_type='audio/pcm') # 5 bytes
|
|
input_blob2 = types.Blob(
|
|
data=b'1234567890', mime_type='audio/pcm'
|
|
) # 10 bytes
|
|
output_blob = types.Blob(data=b'abc', mime_type='audio/wav') # 3 bytes
|
|
|
|
self.manager.cache_audio(invocation_context, input_blob1, 'input')
|
|
self.manager.cache_audio(invocation_context, input_blob2, 'input')
|
|
self.manager.cache_audio(invocation_context, output_blob, 'output')
|
|
|
|
stats = self.manager.get_cache_stats(invocation_context)
|
|
|
|
expected = {
|
|
'input_chunks': 2,
|
|
'output_chunks': 1,
|
|
'input_bytes': 15, # 5 + 10
|
|
'output_bytes': 3,
|
|
'total_chunks': 3,
|
|
'total_bytes': 18, # 15 + 3
|
|
}
|
|
assert stats == expected
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_handling_in_flush(self):
|
|
"""Test error handling during cache flush operations."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# Set up mock artifact service that raises an error
|
|
mock_artifact_service = AsyncMock()
|
|
mock_artifact_service.save_artifact.side_effect = Exception(
|
|
'Artifact service error'
|
|
)
|
|
invocation_context.artifact_service = mock_artifact_service
|
|
|
|
# Cache some audio
|
|
audio_blob = types.Blob(data=b'test_data', mime_type='audio/pcm')
|
|
self.manager.cache_audio(invocation_context, audio_blob, 'input')
|
|
|
|
# Flush should not raise exception but should log error and retain cache
|
|
await self.manager.flush_caches(invocation_context)
|
|
|
|
# Cache should remain since flush failed
|
|
assert len(invocation_context.input_realtime_cache) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_filename_uses_first_chunk_timestamp(self):
|
|
"""Test that the filename timestamp comes from the first audio chunk, not flush time."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# Set up mock services
|
|
mock_artifact_service = AsyncMock()
|
|
mock_artifact_service.save_artifact.return_value = 789
|
|
mock_session_service = AsyncMock()
|
|
|
|
invocation_context.artifact_service = mock_artifact_service
|
|
invocation_context.session_service = mock_session_service
|
|
|
|
# Cache multiple audio chunks with specific timestamps
|
|
first_timestamp = 1234567890.123 # First chunk timestamp
|
|
second_timestamp = 1234567891.456 # Second chunk timestamp (later)
|
|
|
|
# Manually create audio cache entries with specific timestamps
|
|
invocation_context.input_realtime_cache = []
|
|
|
|
from google.adk.agents.invocation_context import RealtimeCacheEntry
|
|
|
|
first_entry = RealtimeCacheEntry(
|
|
role='user',
|
|
data=types.Blob(data=b'first_chunk', mime_type='audio/pcm'),
|
|
timestamp=first_timestamp,
|
|
)
|
|
|
|
second_entry = RealtimeCacheEntry(
|
|
role='user',
|
|
data=types.Blob(data=b'second_chunk', mime_type='audio/pcm'),
|
|
timestamp=second_timestamp,
|
|
)
|
|
|
|
invocation_context.input_realtime_cache.extend([first_entry, second_entry])
|
|
|
|
# Sleep briefly to ensure current time is different from first timestamp
|
|
time.sleep(0.01)
|
|
|
|
# Flush cache
|
|
await self.manager.flush_caches(invocation_context)
|
|
|
|
# Verify artifact was saved
|
|
mock_artifact_service.save_artifact.assert_called_once()
|
|
call_args = mock_artifact_service.save_artifact.call_args
|
|
filename = call_args.kwargs['filename']
|
|
|
|
# Extract timestamp from filename (format: input_audio_{timestamp}.pcm)
|
|
expected_timestamp_ms = int(first_timestamp * 1000)
|
|
assert (
|
|
f'adk_live_audio_storage_input_audio_{expected_timestamp_ms}.pcm'
|
|
== filename
|
|
)
|
|
|
|
# Verify the timestamp in filename matches first chunk, not current time
|
|
current_timestamp_ms = int(time.time() * 1000)
|
|
assert expected_timestamp_ms != current_timestamp_ms # Should be different
|
|
assert filename.startswith(
|
|
f'adk_live_audio_storage_input_audio_{expected_timestamp_ms}'
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_event_author_for_user_audio(self):
|
|
"""Test that flushed user audio events have 'user' as author."""
|
|
invocation_context = await testing_utils.create_invocation_context(
|
|
testing_utils.create_test_agent()
|
|
)
|
|
|
|
# Set up mock artifact service
|
|
mock_artifact_service = AsyncMock()
|
|
mock_artifact_service.save_artifact.return_value = 123
|
|
invocation_context.artifact_service = mock_artifact_service
|
|
|
|
# Cache user input audio
|
|
input_blob = types.Blob(data=b'user_audio_data', mime_type='audio/pcm')
|
|
self.manager.cache_audio(invocation_context, input_blob, 'input')
|
|
|
|
# Flush cache and get events
|
|
events = await self.manager.flush_caches(
|
|
invocation_context, flush_user_audio=True, flush_model_audio=False
|
|
)
|
|
|
|
# Verify event author is 'user' for user audio
|
|
assert len(events) == 1
|
|
assert events[0].author == 'user'
|
|
assert events[0].content.role == 'user'
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_event_author_for_model_audio(self):
|
|
"""Test that flushed model audio events have agent name as author, not 'model'."""
|
|
agent = testing_utils.create_test_agent(name='my_test_agent')
|
|
invocation_context = await testing_utils.create_invocation_context(agent)
|
|
|
|
# Set up mock artifact service
|
|
mock_artifact_service = AsyncMock()
|
|
mock_artifact_service.save_artifact.return_value = 123
|
|
invocation_context.artifact_service = mock_artifact_service
|
|
|
|
# Cache model output audio
|
|
output_blob = types.Blob(data=b'model_audio_data', mime_type='audio/wav')
|
|
self.manager.cache_audio(invocation_context, output_blob, 'output')
|
|
|
|
# Flush cache and get events
|
|
events = await self.manager.flush_caches(
|
|
invocation_context, flush_user_audio=False, flush_model_audio=True
|
|
)
|
|
|
|
# Verify event author is agent name (not 'model') for model audio
|
|
assert len(events) == 1
|
|
assert events[0].author == 'my_test_agent' # Agent name, not 'model'
|
|
assert events[0].content.role == 'model' # Role is still 'model'
|