# Copyright 2025 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 unittest from unittest.mock import AsyncMock from unittest.mock import Mock from google.adk.agents.base_agent import BaseAgent from google.adk.apps.app import App from google.adk.apps.app import EventsCompactionConfig from google.adk.apps.compaction import _run_compaction_for_sliding_window from google.adk.apps.llm_event_summarizer import LlmEventSummarizer from google.adk.events.event import Event from google.adk.events.event_actions import EventActions from google.adk.events.event_actions import EventCompaction from google.adk.flows.llm_flows import contents from google.adk.sessions.base_session_service import BaseSessionService from google.adk.sessions.session import Session from google.genai.types import Content from google.genai.types import Part import pytest @pytest.mark.parametrize( 'env_variables', ['GOOGLE_AI', 'VERTEX'], indirect=True ) class TestCompaction(unittest.IsolatedAsyncioTestCase): def setUp(self): self.mock_session_service = AsyncMock(spec=BaseSessionService) self.mock_compactor = AsyncMock(spec=LlmEventSummarizer) def _create_event( self, timestamp: float, invocation_id: str, text: str ) -> Event: return Event( timestamp=timestamp, invocation_id=invocation_id, author='user', content=Content(role='user', parts=[Part(text=text)]), ) def _create_compacted_event( self, start_ts: float, end_ts: float, summary_text: str ) -> Event: compaction = EventCompaction( start_timestamp=start_ts, end_timestamp=end_ts, compacted_content=Content( role='model', parts=[Part(text=summary_text)] ), ) return Event( timestamp=end_ts, author='compactor', content=compaction.compacted_content, actions=EventActions(compaction=compaction), invocation_id=Event.new_id(), ) async def test_run_compaction_for_sliding_window_no_events(self): app = App(name='test', root_agent=Mock(spec=BaseAgent)) session = Session(app_name='test', user_id='u1', id='s1', events=[]) await _run_compaction_for_sliding_window( app, session, self.mock_session_service ) self.mock_compactor.maybe_summarize_events.assert_not_called() self.mock_session_service.append_event.assert_not_called() async def test_run_compaction_for_sliding_window_not_enough_new_invocations( self, ): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=3, overlap_size=1, ), ) # Only two new invocations ('inv1', 'inv2'), less than compaction_interval=3. session = Session( app_name='test', user_id='u1', id='s1', events=[ self._create_event(1.0, 'inv1', 'e1'), self._create_event(2.0, 'inv2', 'e2'), ], ) await _run_compaction_for_sliding_window( app, session, self.mock_session_service ) self.mock_compactor.maybe_summarize_events.assert_not_called() self.mock_session_service.append_event.assert_not_called() async def test_run_compaction_for_sliding_window_first_compaction(self): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=1, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_event(2.0, 'inv2', 'e2'), self._create_event(3.0, 'inv3', 'e3'), self._create_event(4.0, 'inv4', 'e4'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 4.0, 'Summary inv1-inv4' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await _run_compaction_for_sliding_window( app, session, self.mock_session_service ) # Expected events to compact: inv1, inv2, inv3, inv4 compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] self.assertEqual( [e.invocation_id for e in compacted_events_arg], ['inv1', 'inv2', 'inv3', 'inv4'], ) self.mock_session_service.append_event.assert_called_once_with( session=session, event=mock_compacted_event ) async def test_run_compaction_for_sliding_window_with_overlap(self): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=1, ), ) # inv1-inv2 are already compacted. Last compacted end timestamp is 2.0. initial_events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_event(2.0, 'inv2', 'e2'), self._create_compacted_event(1.0, 2.0, 'Summary inv1-inv2'), ] # Add new invocations inv3, inv4, inv5 new_events = [ self._create_event(3.0, 'inv3', 'e3'), self._create_event(4.0, 'inv4', 'e4'), self._create_event(5.0, 'inv5', 'e5'), ] session = Session( app_name='test', user_id='u1', id='s1', events=initial_events + new_events, ) mock_compacted_event = self._create_compacted_event( 2.0, 5.0, 'Summary inv2-inv5' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await _run_compaction_for_sliding_window( app, session, self.mock_session_service ) # New invocations are inv3, inv4, inv5 (3 new) > threshold (2). # Overlap size is 1, so start from 1 inv before inv3, which is inv2. # Compact range: inv2 to inv5. compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] self.assertEqual( [e.invocation_id for e in compacted_events_arg], ['inv2', 'inv3', 'inv4', 'inv5'], ) self.mock_session_service.append_event.assert_called_once_with( session=session, event=mock_compacted_event ) async def test_run_compaction_for_sliding_window_no_compaction_event_returned( self, ): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=1, overlap_size=0, ), ) events = [self._create_event(1.0, 'inv1', 'e1')] session = Session(app_name='test', user_id='u1', id='s1', events=events) self.mock_compactor.maybe_summarize_events.return_value = None await _run_compaction_for_sliding_window( app, session, self.mock_session_service ) self.mock_compactor.maybe_summarize_events.assert_called_once() self.mock_session_service.append_event.assert_not_called() def test_get_contents_with_multiple_compactions(self): # Event timestamps: 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0 # Compaction 1: covers 1.0 to 4.0 (summary at 4.0) # Compaction 2: covers 6.0 to 9.0 (summary at 9.0) events = [ self._create_event(1.0, 'inv1', 'Event 1'), self._create_event(2.0, 'inv2', 'Event 2'), self._create_event(3.0, 'inv3', 'Event 3'), self._create_event(4.0, 'inv4', 'Event 4'), self._create_compacted_event(1.0, 4.0, 'Summary 1-4'), self._create_event(5.0, 'inv5', 'Event 5'), self._create_event(6.0, 'inv6', 'Event 6'), self._create_event(7.0, 'inv7', 'Event 7'), self._create_event(8.0, 'inv8', 'Event 8'), self._create_event(9.0, 'inv9', 'Event 9'), self._create_compacted_event(6.0, 9.0, 'Summary 6-9'), self._create_event(10.0, 'inv10', 'Event 10'), ] result_contents = contents._get_contents(None, events) # Expected contents: # Summary 1-4 (at timestamp 4.0) # Event 5 (at timestamp 5.0) # Summary 6-9 (at timestamp 9.0) # Event 10 (at timestamp 10.0) expected_texts = [ 'Summary 1-4', 'Event 5', 'Summary 6-9', 'Event 10', ] actual_texts = [c.parts[0].text for c in result_contents] self.assertEqual(actual_texts, expected_texts) # Verify timestamps are in order def test_get_contents_no_compaction(self): events = [ self._create_event(1.0, 'inv1', 'Event 1'), self._create_event(2.0, 'inv2', 'Event 2'), self._create_event(3.0, 'inv3', 'Event 3'), ] result_contents = contents._get_contents(None, events) expected_texts = ['Event 1', 'Event 2', 'Event 3'] actual_texts = [c.parts[0].text for c in result_contents] self.assertEqual(actual_texts, expected_texts) def test_get_contents_single_compaction_at_start(self): events = [ self._create_event(1.0, 'inv1', 'Event 1'), self._create_event(2.0, 'inv2', 'Event 2'), self._create_compacted_event(1.0, 2.0, 'Summary 1-2'), self._create_event(3.0, 'inv3', 'Event 3'), ] result_contents = contents._get_contents(None, events) expected_texts = ['Summary 1-2', 'Event 3'] actual_texts = [c.parts[0].text for c in result_contents] self.assertEqual(actual_texts, expected_texts) def test_get_contents_single_compaction_in_middle(self): events = [ self._create_event(1.0, 'inv1', 'Event 1'), self._create_event(2.0, 'inv2', 'Event 2'), self._create_compacted_event(1.0, 2.0, 'Summary 1-2'), self._create_event(3.0, 'inv3', 'Event 3'), self._create_event(4.0, 'inv4', 'Event 4'), self._create_compacted_event(3.0, 4.0, 'Summary 3-4'), self._create_event(5.0, 'inv5', 'Event 5'), ] result_contents = contents._get_contents(None, events) expected_texts = ['Summary 1-2', 'Summary 3-4', 'Event 5'] actual_texts = [c.parts[0].text for c in result_contents] self.assertEqual(actual_texts, expected_texts) def test_get_contents_compaction_at_end(self): events = [ self._create_event(1.0, 'inv1', 'Event 1'), self._create_event(2.0, 'inv2', 'Event 2'), self._create_event(3.0, 'inv3', 'Event 3'), self._create_compacted_event(2.0, 3.0, 'Summary 2-3'), ] result_contents = contents._get_contents(None, events) expected_texts = ['Event 1', 'Summary 2-3'] actual_texts = [c.parts[0].text for c in result_contents] self.assertEqual(actual_texts, expected_texts) def test_get_contents_compaction_at_beginning(self): events = [ self._create_compacted_event(1.0, 2.0, 'Summary 1-2'), self._create_event(3.0, 'inv3', 'Event 3'), self._create_event(4.0, 'inv4', 'Event 4'), ] result_contents = contents._get_contents(None, events) expected_texts = ['Summary 1-2', 'Event 3', 'Event 4'] actual_texts = [c.parts[0].text for c in result_contents] self.assertEqual(actual_texts, expected_texts)