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