# 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 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.base_events_summarizer import BaseEventsSummarizer from google.adk.apps.compaction import _run_compaction_for_sliding_window import google.adk.apps.compaction as compaction_module from google.adk.apps.llm_event_summarizer import LlmEventSummarizer from google.adk.auth.auth_schemes import CustomAuthScheme from google.adk.auth.auth_tool import AuthConfig 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 as _contents from google.adk.sessions.base_session_service import BaseSessionService from google.adk.sessions.session import Session from google.adk.tools.tool_confirmation import ToolConfirmation from google.genai import types from google.genai.types import Content from google.genai.types import Part from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from pydantic import ValidationError import pytest class _StubSummarizer(BaseEventsSummarizer): def __init__(self, compacted_event: Event | None): self._compacted_event = compacted_event async def maybe_summarize_events( self, *, events: list[Event] ) -> Event | None: del events return self._compacted_event def _create_trace_test_event( *, timestamp: float, invocation_id: str, text: str, prompt_token_count: int | None = None, ) -> Event: usage_metadata = None if prompt_token_count is not None: usage_metadata = types.GenerateContentResponseUsageMetadata( prompt_token_count=prompt_token_count ) return Event( timestamp=timestamp, invocation_id=invocation_id, author='user', content=Content(role='user', parts=[Part(text=text)]), usage_metadata=usage_metadata, ) def _create_trace_compacted_event( *, 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( id='compacted-event-id', timestamp=end_ts, author='compactor', content=compaction.compacted_content, actions=EventActions(compaction=compaction), invocation_id='compacted-invocation-id', ) @pytest.fixture def span_exporter(monkeypatch: pytest.MonkeyPatch) -> InMemorySpanExporter: tracer_provider = TracerProvider() span_exporter = InMemorySpanExporter() tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) real_tracer = tracer_provider.get_tracer(__name__) monkeypatch.setattr( compaction_module.tracer, 'start_as_current_span', real_tracer.start_as_current_span, ) return span_exporter @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, prompt_token_count: int | None = None, thought: bool = False, ) -> Event: usage_metadata = None if prompt_token_count is not None: usage_metadata = types.GenerateContentResponseUsageMetadata( prompt_token_count=prompt_token_count ) return Event( timestamp=timestamp, invocation_id=invocation_id, author='user', content=Content(role='user', parts=[Part(text=text, thought=thought)]), usage_metadata=usage_metadata, ) def _create_function_call_event( self, timestamp: float, invocation_id: str, function_call_id: str, ) -> Event: return Event( timestamp=timestamp, invocation_id=invocation_id, author='agent', content=Content( role='model', parts=[ Part( function_call=types.FunctionCall( id=function_call_id, name='tool', args={} ) ) ], ), ) def _create_function_response_event( self, timestamp: float, invocation_id: str, function_call_id: str, prompt_token_count: int | None = None, ) -> Event: usage_metadata = None if prompt_token_count is not None: usage_metadata = types.GenerateContentResponseUsageMetadata( prompt_token_count=prompt_token_count ) return Event( timestamp=timestamp, invocation_id=invocation_id, author='agent', content=Content( role='user', parts=[ Part( function_response=types.FunctionResponse( id=function_call_id, name='tool', response={'result': 'ok'}, ) ) ], ), usage_metadata=usage_metadata, ) def _create_compacted_event( self, start_ts: float, end_ts: float, summary_text: str, appended_ts: float | None = None, ) -> Event: compaction = EventCompaction( start_timestamp=start_ts, end_timestamp=end_ts, compacted_content=Content( role='model', parts=[Part(text=summary_text)] ), ) return Event( timestamp=appended_ts if appended_ts is not None else end_ts, author='compactor', content=compaction.compacted_content, actions=EventActions(compaction=compaction), invocation_id=Event.new_id(), ) async def _run_sliding_window(self, app, session, session_service, **kwargs): """Drains the sliding-window generator, appending like the runner loop. Compaction now yields its event instead of appending it, so the runner is the single append site. This mirrors that behavior so the existing append assertions still exercise the produce-then-persist path. """ async for compaction_event in _run_compaction_for_sliding_window( app, session, session_service, **kwargs ): await session_service.append_event( session=session, event=compaction_event ) 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 self._run_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_sliding_window_yields_event_without_appending(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 ) yielded = [ event async for event in _run_compaction_for_sliding_window( app, session, self.mock_session_service ) ] # The compaction event is yielded to the caller (the runner loop), and the # function itself never appends -- persistence is the runner's job. self.assertEqual(yielded, [mock_compacted_event]) self.mock_session_service.append_event.assert_not_called() async def test_sliding_window_yields_nothing_when_no_compaction(self): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=3, overlap_size=1, ), ) 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'), ], ) yielded = [ event async for event in _run_compaction_for_sliding_window( app, session, self.mock_session_service ) ] self.assertEqual(yielded, []) 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 self._run_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 self._run_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 self._run_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 self._run_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_events_compaction_config_accepts_token_fields(self): config = EventsCompactionConfig( compaction_interval=2, overlap_size=1, token_threshold=50_000, event_retention_size=5, ) self.assertEqual(config.compaction_interval, 2) self.assertEqual(config.overlap_size, 1) self.assertEqual(config.token_threshold, 50_000) self.assertEqual(config.event_retention_size, 5) def test_events_compaction_config_accepts_sliding_window_fields(self): config = EventsCompactionConfig( compaction_interval=2, overlap_size=1, ) self.assertEqual(config.compaction_interval, 2) self.assertEqual(config.overlap_size, 1) self.assertIsNone(config.token_threshold) self.assertIsNone(config.event_retention_size) def test_events_compaction_config_rejects_partial_token_fields( self, ): with pytest.raises(ValidationError): EventsCompactionConfig( compaction_interval=2, overlap_size=1, token_threshold=50_000, ) def test_events_compaction_config_rejects_partial_sliding_fields( self, ): with pytest.raises(ValidationError): EventsCompactionConfig( compaction_interval=2, ) with pytest.raises(ValidationError): EventsCompactionConfig( overlap_size=0, ) def test_events_compaction_config_rejects_missing_modes(self): with pytest.raises(ValidationError): EventsCompactionConfig() def test_latest_prompt_token_count_fallback_applies_compaction(self): events = [ self._create_event(1.0, 'inv1', 'a' * 40), self._create_event(2.0, 'inv2', 'b' * 40), self._create_compacted_event(1.0, 2.0, 'S'), self._create_event(3.0, 'inv3', 'c' * 20), ] estimated_token_count = compaction_module._latest_prompt_token_count(events) # Visible text after compaction is: 'S' + ('c' * 20) = 21 chars. self.assertEqual(estimated_token_count, 21 // 4) def test_latest_prompt_token_count_fallback_uses_effective_contents(self): events = [ self._create_event(1.0, 'inv1', 'visible'), Event( timestamp=2.0, invocation_id='inv2', author='model', content=Content( role='model', parts=[Part(text='hidden-thought', thought=True)], ), ), ] estimated_token_count = compaction_module._latest_prompt_token_count(events) # Thought-only events are filtered by contents processing. self.assertEqual(estimated_token_count, len('visible') // 4) async def test_run_compaction_for_token_threshold_keeps_retention_events( self, ): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=2, ), ) 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'), self._create_event(3.0, 'inv3', 'e3'), self._create_event(4.0, 'inv4', 'e4'), self._create_event(5.0, 'inv5', 'e5', prompt_token_count=100), ], ) mock_compacted_event = self._create_compacted_event( 1.0, 3.0, 'Summary inv1-inv3' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) 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'], ) self.mock_session_service.append_event.assert_called_once_with( session=session, event=mock_compacted_event ) async def test_run_compaction_for_token_threshold_keeps_tool_call_pair( self, ): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=1, ), ) session = Session( app_name='test', user_id='u1', id='s1', events=[ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'tool-call-1'), self._create_function_response_event( 3.0, 'inv2', 'tool-call-1', prompt_token_count=100, ), ], ) mock_compacted_event = self._create_compacted_event( 1.0, 1.0, 'Summary inv1' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) 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'], ) self.mock_session_service.append_event.assert_called_once_with( session=session, event=mock_compacted_event ) async def test_run_compaction_for_token_threshold_equal_threshold_compacts( self, ): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=100, event_retention_size=1, ), ) 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', prompt_token_count=100), ], ) mock_compacted_event = self._create_compacted_event( 1.0, 1.0, 'Summary inv1' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) 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'], ) self.mock_session_service.append_event.assert_called_once_with( session=session, event=mock_compacted_event ) async def test_run_compaction_skip_token_compaction(self): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=1, ), ) 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', prompt_token_count=100), ], ) await self._run_sliding_window( app, session, self.mock_session_service, skip_token_compaction=True, ) self.mock_compactor.maybe_summarize_events.assert_not_called() self.mock_session_service.append_event.assert_not_called() async def test_run_compaction_for_token_threshold_seeds_previous_compaction( self, ): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=2, ), ) 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'), self._create_compacted_event(1.0, 2.0, 'Summary 1-2'), self._create_event(3.0, 'inv3', 'e3'), self._create_event(4.0, 'inv4', 'e4'), self._create_event(5.0, 'inv5', 'e5'), self._create_event(6.0, 'inv6', 'e6', prompt_token_count=100), ], ) mock_compacted_event = self._create_compacted_event(1.0, 4.0, 'Summary 1-4') self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] self.assertEqual( [e.content.parts[0].text for e in compacted_events_arg], ['Summary 1-2', 'e3', 'e4'], ) self.assertEqual(compacted_events_arg[0].timestamp, 1.0) self.assertEqual( [e.invocation_id for e in compacted_events_arg[1:]], ['inv3', 'inv4'], ) self.mock_session_service.append_event.assert_called_once_with( session=session, event=mock_compacted_event ) async def test_run_compaction_for_token_threshold_with_zero_retention( self, ): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=0, ), ) 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'), self._create_event(3.0, 'inv3', 'e3', prompt_token_count=100), ], ) mock_compacted_event = self._create_compacted_event( 1.0, 3.0, 'Summary inv1-inv3' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) 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'], ) self.mock_session_service.append_event.assert_called_once_with( session=session, event=mock_compacted_event ) async def test_run_compaction_for_token_threshold_with_retention_and_overlap( self, ): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=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'), self._create_event(3.0, 'inv3', 'e3'), self._create_event(4.0, 'inv4', 'e4'), self._create_compacted_event( 1.0, 1.0, 'Summary 1', appended_ts=5.0 ), self._create_event(6.0, 'inv6', 'e6'), self._create_event(7.0, 'inv7', 'e7'), self._create_compacted_event( 1.0, 3.0, 'Summary 1-3', appended_ts=8.0 ), self._create_event(9.0, 'inv9', 'e9', prompt_token_count=100), ], ) mock_compacted_event = self._create_compacted_event(1.0, 4.0, 'Summary 1-4') self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] self.assertEqual( [e.content.parts[0].text for e in compacted_events_arg], ['Summary 1-3', 'e4'], ) self.assertEqual(compacted_events_arg[0].timestamp, 1.0) self.assertEqual(compacted_events_arg[1].invocation_id, 'inv4') self.mock_session_service.append_event.assert_called_once_with( session=session, event=mock_compacted_event ) async def test_run_compaction_for_token_threshold_uses_latest_ordered_seed( self, ): app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=1, ), ) 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'), self._create_event(3.0, 'inv3', 'e3'), self._create_event(4.0, 'inv4', 'e4'), self._create_event(5.0, 'inv5', 'e5'), self._create_event(15.0, 'inv6', 'e6'), self._create_event(20.0, 'inv7', 'e7'), self._create_compacted_event( 15.0, 20.0, 'Summary 15-20', appended_ts=21.0 ), self._create_compacted_event( 1.0, 5.0, 'Summary 1-5', appended_ts=22.0 ), self._create_event(23.0, 'inv8', 'e8'), self._create_event(24.0, 'inv9', 'e9', prompt_token_count=120), ], ) mock_compacted_event = self._create_compacted_event( 1.0, 23.0, 'Summary 1-23' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] self.assertEqual( compacted_events_arg[0].content.parts[0].text, 'Summary 1-5' ) self.assertEqual( [e.invocation_id for e in compacted_events_arg[1:]], ['inv6', 'inv7', 'inv8'], ) self.mock_session_service.append_event.assert_called_once_with( session=session, event=mock_compacted_event ) 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_subsumed_compaction_is_hidden(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_event(4.0, 'inv4', 'Event 4'), self._create_compacted_event(1.0, 1.0, 'Summary 1'), self._create_event(6.0, 'inv6', 'Event 6'), self._create_event(7.0, 'inv7', 'Event 7'), self._create_compacted_event(1.0, 3.0, 'Summary 1-3'), self._create_event(9.0, 'inv9', 'Event 9'), ] result_contents = _contents._get_contents(None, events) expected_texts = [ 'Summary 1-3', 'Event 4', 'Event 6', 'Event 7', 'Event 9', ] actual_texts = [c.parts[0].text for c in result_contents] self.assertEqual(actual_texts, expected_texts) def test_get_contents_compaction_appended_late_keeps_newer_events(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_event(4.0, 'inv4', 'Event 4'), self._create_event(5.0, 'inv5', 'Event 5'), self._create_compacted_event(1.0, 3.0, 'Summary 1-3', appended_ts=6.0), ] result_contents = _contents._get_contents(None, events) expected_texts = ['Summary 1-3', 'Event 4', 'Event 5'] actual_texts = [c.parts[0].text for c in result_contents] self.assertEqual(actual_texts, expected_texts) 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) async def test_sliding_window_excludes_pending_function_call_events(self): """Sliding-window compaction stops before pending function calls.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) # inv1: normal text, inv2: pending function call (no response) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'pending-call-1'), self._create_event(3.0, 'inv3', 'e3'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 3.0, 'Summary without pending' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] self.assertEqual(compacted_inv_ids, ['inv1']) async def test_sliding_window_pending_function_call_remains_in_contents( self, ): """Sliding-window compaction keeps pending tool calls visible in history.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'pending-call-1'), self._create_event(3.0, 'inv3', 'e3'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) self.mock_compactor.maybe_summarize_events.side_effect = ( lambda *, events: self._create_compacted_event( events[0].timestamp, events[-1].timestamp, 'Summary safe prefix', ) ) await self._run_sliding_window(app, session, self.mock_session_service) appended_event = self.mock_session_service.append_event.call_args[1][ 'event' ] self.assertEqual(appended_event.actions.compaction.start_timestamp, 1.0) self.assertEqual(appended_event.actions.compaction.end_timestamp, 1.0) result_contents = _contents._get_contents(None, events + [appended_event]) self.assertEqual(result_contents[0].parts[0].text, 'Summary safe prefix') self.assertEqual( result_contents[1].parts[0].function_call.name, 'tool', ) self.assertEqual(result_contents[2].parts[0].text, 'e3') async def test_token_threshold_excludes_pending_function_call_events(self): """Token-threshold compaction stays contiguous before pending calls.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=0, ), ) # inv1: text, inv2: pending function call, inv3: text with token count events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'pending-call-1'), self._create_event(3.0, 'inv3', 'e3', prompt_token_count=100), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 1.0, 'Summary inv1' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] self.assertEqual(compacted_inv_ids, ['inv1']) async def test_token_threshold_pending_function_call_remains_in_contents( self, ): """Token-threshold compaction keeps pending tool calls visible.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=0, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'pending-call-1'), self._create_event(3.0, 'inv3', 'e3', prompt_token_count=100), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) self.mock_compactor.maybe_summarize_events.side_effect = ( lambda *, events: self._create_compacted_event( events[0].timestamp, events[-1].timestamp, 'Summary safe prefix', ) ) await self._run_sliding_window(app, session, self.mock_session_service) appended_event = self.mock_session_service.append_event.call_args[1][ 'event' ] self.assertEqual(appended_event.actions.compaction.start_timestamp, 1.0) self.assertEqual(appended_event.actions.compaction.end_timestamp, 1.0) result_contents = _contents._get_contents(None, events + [appended_event]) self.assertEqual(result_contents[0].parts[0].text, 'Summary safe prefix') self.assertEqual( result_contents[1].parts[0].function_call.name, 'tool', ) self.assertEqual(result_contents[2].parts[0].text, 'e3') async def test_completed_function_call_pair_is_still_compacted(self): """Completed function call/response pairs must still be compacted.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) # inv1: text, inv2: completed call+response pair, inv3: text events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'completed-call-1'), self._create_function_response_event(3.0, 'inv2', 'completed-call-1'), self._create_event(4.0, 'inv3', 'e3'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 4.0, 'Summary with completed pair' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] # Both the call and response events for inv2 should be compacted. self.assertIn('inv1', compacted_inv_ids) self.assertEqual(compacted_inv_ids.count('inv2'), 2) self.assertIn('inv3', compacted_inv_ids) def _create_hitl_confirmation_event( self, timestamp: float, invocation_id: str, function_call_id: str, ) -> Event: """Creates a function response event with a tool confirmation request.""" return Event( timestamp=timestamp, invocation_id=invocation_id, author='agent', content=Content( role='user', parts=[ Part( function_response=types.FunctionResponse( id=function_call_id, name='tool', response={ 'error': 'This tool call requires confirmation.' }, ) ) ], ), actions=EventActions( requested_tool_confirmations={ function_call_id: ToolConfirmation( hint='Please confirm this action.' ) }, ), ) def _create_hitl_auth_event( self, timestamp: float, invocation_id: str, function_call_id: str, ) -> Event: """Creates a function response event with an auth credential request.""" return Event( timestamp=timestamp, invocation_id=invocation_id, author='agent', content=Content( role='user', parts=[ Part( function_response=types.FunctionResponse( id=function_call_id, name='tool', response={'error': 'Auth required.'}, ) ) ], ), actions=EventActions( requested_auth_configs={ function_call_id: AuthConfig( auth_scheme=CustomAuthScheme(type='custom'), ) }, ), ) async def test_sliding_window_excludes_hitl_confirmation_events(self): """Sliding-window compaction stops before tool confirmation events.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) # inv1: text, inv2: call + HITL confirmation response, inv3: text events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-1'), self._create_hitl_confirmation_event(3.0, 'inv2', 'call-1'), self._create_event(4.0, 'inv3', 'e3'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 2.0, 'Summary before hitl' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] # inv2's tool call is still awaiting the final response, # so compaction won't summarize it; only the # already settled inv1 is compacted. self.assertEqual(compacted_inv_ids, ['inv1']) async def test_sliding_window_excludes_hitl_auth_events(self): """Sliding-window compaction stops before auth credential events.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-1'), self._create_hitl_auth_event(3.0, 'inv2', 'call-1'), self._create_event(4.0, 'inv3', 'e3'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 2.0, 'Summary before auth' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] # inv2's tool call is still awaiting auth approval -- an unfinished # call/response pair -- so compaction won't summarize it; only the # already settled inv1 is compacted. self.assertEqual(compacted_inv_ids, ['inv1']) async def test_token_threshold_excludes_hitl_confirmation_events(self): """Token-threshold compaction stops before tool confirmation events.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=0, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-1'), self._create_hitl_confirmation_event(3.0, 'inv2', 'call-1'), self._create_event(4.0, 'inv3', 'e3', prompt_token_count=100), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 2.0, 'Summary inv1-inv2' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] # inv2's tool call is still awaiting confirmation -- an unfinished # call/response pair -- so compaction won't summarize it; only the # already settled inv1 is compacted. self.assertEqual(compacted_inv_ids, ['inv1']) async def test_token_threshold_excludes_hitl_auth_events(self): """Token-threshold compaction stops before auth credential events.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=0, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-1'), self._create_hitl_auth_event(3.0, 'inv2', 'call-1'), self._create_event(4.0, 'inv3', 'e3', prompt_token_count=100), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 2.0, 'Summary inv1-inv2' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] # inv2's tool call is still awaiting auth approval -- an unfinished # call/response pair -- so compaction won't summarize it; only the # already settled inv1 is compacted. self.assertEqual(compacted_inv_ids, ['inv1']) async def test_hitl_event_at_start_blocks_all_compaction(self): """If the first candidate event has HITL, nothing is compacted.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) # The very first event is an HITL confirmation (no preceding function call). events = [ self._create_hitl_confirmation_event(1.0, 'inv1', 'call-1'), self._create_event(2.0, 'inv2', 'e2'), self._create_event(3.0, 'inv3', 'e3'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) await self._run_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_events_before_hitl_are_still_compacted(self): """Events before the HITL event are compacted normally.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) # inv1, inv2: text events, inv3: call + HITL confirmation, inv4: text # The HITL event at index 3 blocks compaction; events before it are safe. events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_event(2.0, 'inv2', 'e2'), self._create_function_call_event(3.0, 'inv3', 'call-1'), self._create_hitl_confirmation_event(4.0, 'inv3', 'call-1'), self._create_event(5.0, 'inv4', 'e4'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 3.0, 'Summary inv1-inv3' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] # inv3's tool call is still awaiting confirmation -- an unfinished # call/response pair -- so compaction stops before it; the settled inv1 # and inv2 are compacted. self.assertEqual(compacted_inv_ids, ['inv1', 'inv2']) async def test_resolved_hitl_confirmation_is_compactable(self): """A HITL confirmation followed by a resolved tool response is compactable.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) # inv1: text, inv2: call + HITL request + resolved response (same call-1 # id), inv3: text. The resolved HITL is safe to compact. events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-1'), self._create_hitl_confirmation_event(3.0, 'inv2', 'call-1'), self._create_function_response_event(4.0, 'inv2', 'call-1'), self._create_event(5.0, 'inv3', 'e3'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 5.0, 'Summary including resolved hitl' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] # Resolved HITL doesn't block; all events through inv3 compact together. self.assertEqual( compacted_inv_ids, ['inv1', 'inv2', 'inv2', 'inv2', 'inv3'] ) async def test_resolved_hitl_auth_is_compactable(self): """A HITL auth request followed by a resolved tool response is compactable.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-1'), self._create_hitl_auth_event(3.0, 'inv2', 'call-1'), self._create_function_response_event(4.0, 'inv2', 'call-1'), self._create_event(5.0, 'inv3', 'e3'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 5.0, 'Summary including resolved auth' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] self.assertEqual( compacted_inv_ids, ['inv1', 'inv2', 'inv2', 'inv2', 'inv3'] ) def _create_request_confirmation_call_event( self, timestamp: float, invocation_id: str, request_confirmation_id: str, original_function_call_id: str, ) -> Event: """Creates the synthetic adk_request_confirmation function-call event.""" # Mirrors functions.generate_request_confirmation_event: real ADK emits a # separate event whose function call has its own distinct id (registered in # long_running_tool_ids) and only references the original call id in its # args. See tests/unittests/runners/test_run_tool_confirmation.py. return Event( timestamp=timestamp, invocation_id=invocation_id, author='agent', content=Content( role='model', parts=[ Part( function_call=types.FunctionCall( id=request_confirmation_id, name='adk_request_confirmation', args={ 'originalFunctionCall': { 'id': original_function_call_id } }, ) ) ], ), long_running_tool_ids={request_confirmation_id}, ) def _create_request_confirmation_response_event( self, timestamp: float, invocation_id: str, request_confirmation_id: str, ) -> Event: """Creates the function_response that resolves the confirmation call.""" return Event( timestamp=timestamp, invocation_id=invocation_id, author='user', content=Content( role='user', parts=[ Part( function_response=types.FunctionResponse( id=request_confirmation_id, name='adk_request_confirmation', response={'confirmed': True}, ) ) ], ), ) async def test_sliding_window_real_hitl_shape_blocks_compaction(self): """Faithful 3-event HITL turn (two ids) that blocks compaction. Unlike the other HITL tests, this mirrors the event stream emitted by the ADK runtime in functions.generate_request_confirmation_event: a confirmation-required call produces function_call(call-1), function_call(adk_request_confirmation) with its own distinct client-generated id (registered in long_running_tool_ids), then the placeholder function_response(call-1) requesting confirmation. Both the tool call and the still-unanswered confirmation call are open, so nothing past inv1 may be compacted. """ app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-1'), self._create_request_confirmation_call_event( 3.0, 'inv2', 'confirm-1', 'call-1' ), self._create_hitl_confirmation_event(4.0, 'inv2', 'call-1'), self._create_event(5.0, 'inv3', 'e3'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 1.0, 'Summary inv1' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] # Only inv1 is self-contained: call-1 awaits confirmation and the # adk_request_confirmation call (confirm-1) has no response in the window. self.assertEqual(compacted_inv_ids, ['inv1']) async def test_sliding_window_real_hitl_shape_resolved_is_compactable(self): """Faithful resolved HITL turn (both ids closed) compacts fully. Adds the two resolving responses seen on resume: function_response(adk_request_confirmation) closes the confirmation call and function_response(call-1) returns the tool result. With every obligation closed, the whole span is safe to compact. """ app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-1'), self._create_request_confirmation_call_event( 3.0, 'inv2', 'confirm-1', 'call-1' ), self._create_hitl_confirmation_event(4.0, 'inv2', 'call-1'), self._create_request_confirmation_response_event( 5.0, 'inv3', 'confirm-1' ), self._create_function_response_event(6.0, 'inv3', 'call-1'), self._create_event(7.0, 'inv4', 'e7'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 7.0, 'Summary resolved real hitl' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] # Both call-1 and confirm-1 are resolved, so the full span compacts. self.assertEqual( compacted_inv_ids, ['inv1', 'inv2', 'inv2', 'inv2', 'inv3', 'inv3', 'inv4'], ) async def test_sliding_window_stops_compaction_at_open_obligations( self, ): """Compaction stops at the first still-open call/HITL obligation.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=2, overlap_size=0, ), ) # inv2's call-a only resolves at inv5, and inv4's call-b never resolves, # so no prefix past inv1 is self-contained. events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-a'), self._create_hitl_confirmation_event(3.0, 'inv3', 'call-a'), self._create_function_call_event(4.0, 'inv4', 'call-b'), self._create_function_response_event(5.0, 'inv5', 'call-a'), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 3.0, 'Summary including resolved hitl' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] self.assertEqual(compacted_inv_ids, ['inv1']) async def test_token_threshold_resolved_hitl_outside_window_is_compactable( self, ): """Token-threshold: HITL with resolver past the truncation point compacts.""" app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=self.mock_compactor, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=0, ), ) events = [ self._create_event(1.0, 'inv1', 'e1'), self._create_function_call_event(2.0, 'inv2', 'call-a'), self._create_hitl_confirmation_event(3.0, 'inv3', 'call-a'), self._create_function_call_event(4.0, 'inv4', 'call-b'), self._create_function_response_event( 5.0, 'inv5', 'call-a', prompt_token_count=100 ), ] session = Session(app_name='test', user_id='u1', id='s1', events=events) mock_compacted_event = self._create_compacted_event( 1.0, 3.0, 'Summary including resolved hitl' ) self.mock_compactor.maybe_summarize_events.return_value = ( mock_compacted_event ) await self._run_sliding_window(app, session, self.mock_session_service) compacted_events_arg = self.mock_compactor.maybe_summarize_events.call_args[ 1 ]['events'] compacted_inv_ids = [e.invocation_id for e in compacted_events_arg] self.assertEqual(compacted_inv_ids, ['inv1']) @pytest.mark.asyncio async def test_run_compaction_for_token_threshold_adds_summary_trace( span_exporter: InMemorySpanExporter, ): session = Session( app_name='app', user_id='user', id='session-id', events=[ _create_trace_test_event( timestamp=1.0, invocation_id='inv1', text='e1' ), _create_trace_test_event( timestamp=2.0, invocation_id='inv2', text='e2' ), _create_trace_test_event( timestamp=3.0, invocation_id='inv3', text='e3', prompt_token_count=100, ), ], ) session_service = AsyncMock(spec=BaseSessionService) compacted_event = _create_trace_compacted_event( start_ts=1.0, end_ts=2.0, summary_text='summary' ) summarizer = _StubSummarizer(compacted_event) config = EventsCompactionConfig( summarizer=summarizer, compaction_interval=999, overlap_size=0, token_threshold=50, event_retention_size=1, ) compacted = ( await ( compaction_module._run_compaction_for_token_threshold_config( config=config, session=session, session_service=session_service, agent=Mock(spec=BaseAgent), ) ) ) assert compacted is True spans = span_exporter.get_finished_spans() summary_span = next( span for span in spans if span.name == 'compact_events token_threshold' ) assert summary_span.attributes['gen_ai.conversation.id'] == 'session-id' assert ( summary_span.attributes['gen_ai.compaction.trigger'] == 'token_threshold' ) assert summary_span.attributes['gen_ai.compaction.event_count'] == 2 assert summary_span.attributes['gen_ai.compaction.token_threshold'] == 50 assert summary_span.attributes['gen_ai.compaction.event_retention_size'] == 1 assert ( summary_span.attributes['gen_ai.compaction.result_event_id'] == 'compacted-event-id' ) @pytest.mark.asyncio async def test_run_compaction_for_sliding_window_adds_summary_trace( span_exporter: InMemorySpanExporter, ): compacted_event = _create_trace_compacted_event( start_ts=1.0, end_ts=4.0, summary_text='summary' ) summarizer = _StubSummarizer(compacted_event) app = App( name='test', root_agent=Mock(spec=BaseAgent), events_compaction_config=EventsCompactionConfig( summarizer=summarizer, compaction_interval=2, overlap_size=1, ), ) session = Session( app_name='test', user_id='u1', id='session-id', events=[ _create_trace_test_event( timestamp=1.0, invocation_id='inv1', text='e1' ), _create_trace_test_event( timestamp=2.0, invocation_id='inv2', text='e2' ), _create_trace_test_event( timestamp=3.0, invocation_id='inv3', text='e3' ), _create_trace_test_event( timestamp=4.0, invocation_id='inv4', text='e4' ), ], ) session_service = AsyncMock(spec=BaseSessionService) async for _ in _run_compaction_for_sliding_window( app, session, session_service ): pass spans = span_exporter.get_finished_spans() summary_span = next( span for span in spans if span.name == 'compact_events sliding_window' ) assert summary_span.attributes['gen_ai.conversation.id'] == 'session-id' assert ( summary_span.attributes['gen_ai.compaction.trigger'] == 'sliding_window' ) assert summary_span.attributes['gen_ai.compaction.event_count'] == 4 assert summary_span.attributes['gen_ai.compaction.compaction_interval'] == 2 assert summary_span.attributes['gen_ai.compaction.overlap_size'] == 1 assert ( summary_span.attributes['gen_ai.compaction.result_event_id'] == 'compacted-event-id' )