# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import asyncio
from contextlib import AsyncExitStack
from datetime import timedelta
from unittest.mock import AsyncMock
from unittest.mock import Mock
from unittest.mock import patch

from google.adk.features import FeatureName
from google.adk.features._feature_registry import temporary_feature_override
from google.adk.tools.mcp_tool.session_context import _format_exception
from google.adk.tools.mcp_tool.session_context import SessionContext
import httpx
from mcp import ClientSession
import pytest


class MockClientSession:
  """Mock ClientSession for testing."""

  def __init__(self, *args, **kwargs):
    self._initialized = False
    self._args = args
    self._kwargs = kwargs

  async def initialize(self):
    """Mock initialize method."""
    self._initialized = True
    return self

  async def __aenter__(self):
    return self

  async def __aexit__(self, exc_type, exc_val, exc_tb):
    return False


class MockClient:
  """Mock MCP client."""

  def __init__(
      self,
      transports=None,
      raise_on_enter=None,
      delay_on_enter=0,
  ):
    self._transports = transports or ('read_stream', 'write_stream')
    self._raise_on_enter = raise_on_enter
    self._delay_on_enter = delay_on_enter
    self._entered = False
    self._exited = False

  async def __aenter__(self):
    if self._delay_on_enter > 0:
      await asyncio.sleep(self._delay_on_enter)
    if self._raise_on_enter:
      raise self._raise_on_enter
    self._entered = True
    return self._transports

  async def __aexit__(self, exc_type, exc_val, exc_tb):
    self._exited = True
    return False


class TestSessionContext:
  """Test suite for SessionContext class."""

  @pytest.mark.asyncio
  async def test_start_success_ready_event_set_and_session_returned(self):
    """Test that start() sets _ready_event and returns session."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    # Mock ClientSession
    mock_session = MockClientSession()

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      session = await session_context.start()

      # Verify ready_event was set
      assert session_context._ready_event.is_set()

      # Verify session was returned
      assert session == mock_session
      assert session_context.session == mock_session

      # Verify initialize was called
      assert mock_session._initialized

      # Verify task was created and is still running (waiting for close)
      assert session_context._task is not None
      assert not session_context._task.done()

      # Clean up
      await session_context.close()

  @pytest.mark.asyncio
  async def test_start_raises_connection_error_on_exception(self):
    """Test that start() raises ConnectionError when exception occurs."""
    test_exception = ValueError('Connection failed')
    mock_client = MockClient(raise_on_enter=test_exception)
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    with pytest.raises(ConnectionError) as exc_info:
      await session_context.start()

    # Verify ConnectionError message contains original exception
    assert 'Failed to create MCP session' in str(exc_info.value)
    assert 'Connection failed' in str(exc_info.value)

    # Verify ready_event was set (in finally block)
    assert session_context._ready_event.is_set()

  @pytest.mark.asyncio
  async def test_start_raises_connection_error_on_cancelled_error(self):
    """Test that start() raises ConnectionError when CancelledError occurs."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    # Mock session that will cause cancellation
    mock_session = MockClientSession()

    # Make initialize raise CancelledError
    async def cancelled_initialize():
      raise asyncio.CancelledError('Task cancelled')

    mock_session.initialize = cancelled_initialize

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      # Should raise ConnectionError (not CancelledError directly)
      with pytest.raises(ConnectionError) as exc_info:
        await session_context.start()

      # Verify it's a ConnectionError about cancellation
      assert 'Failed to create MCP session' in str(exc_info.value)
      assert 'task cancelled' in str(exc_info.value)

      # Verify ready_event was set
      assert session_context._ready_event.is_set()

  @pytest.mark.asyncio
  async def test_close_cleans_up_task(self):
    """Test that close() properly cleans up the task."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    # Mock ClientSession
    mock_session = MockClientSession()

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      # Start the session context
      await session_context.start()

      # Verify task is running
      assert session_context._task is not None
      assert not session_context._task.done()

      # Close the session context
      await session_context.close()

      # Wait a bit for cleanup
      await asyncio.sleep(0.1)

      # Verify close_event was set
      assert session_context._close_event.is_set()

      # Verify task completed (may take a moment)
      # The task should finish after close_event is set
      assert session_context._task.done()

  @pytest.mark.asyncio
  async def test_session_exception_does_not_break_event_loop(self):
    """Test that session exceptions don't break the event loop."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    # Mock ClientSession that raises exception during use
    mock_session = MockClientSession()

    async def failing_operation():
      raise RuntimeError('Session operation failed')

    mock_session.failing_operation = failing_operation

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      # Start the session context
      session = await session_context.start()

      # Use session and trigger exception
      with pytest.raises(RuntimeError, match='Session operation failed'):
        await session.failing_operation()

      # Close the session context - should not break event loop
      await session_context.close()

      # Verify event loop is still healthy by running another task
      result = await asyncio.sleep(0.01)
      assert result is None

  @pytest.mark.asyncio
  async def test_async_context_manager(self):
    """Test using SessionContext as async context manager."""
    mock_client = MockClient()
    mock_session = MockClientSession()

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      async with SessionContext(
          mock_client, timeout=5.0, sse_read_timeout=None
      ) as session:
        assert session == mock_session
        # Verify initialize was called by checking _initialized flag
        assert session._initialized

  @pytest.mark.asyncio
  async def test_timeout_during_connection(self):
    """Test timeout during client connection."""
    # Client that takes longer than timeout
    mock_client = MockClient(delay_on_enter=10.0)
    session_context = SessionContext(
        mock_client, timeout=0.1, sse_read_timeout=None
    )

    with pytest.raises(ConnectionError) as exc_info:
      await session_context.start()

    assert 'Failed to create MCP session' in str(exc_info.value)

  @pytest.mark.asyncio
  async def test_timeout_during_initialization(self):
    """Test timeout during session initialization."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=0.1, sse_read_timeout=None
    )

    # Mock ClientSession with slow initialize
    mock_session = MockClientSession()

    async def slow_initialize():
      await asyncio.sleep(1.0)
      return mock_session

    mock_session.initialize = slow_initialize

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      with pytest.raises(ConnectionError) as exc_info:
        await session_context.start()

      assert 'Failed to create MCP session' in str(exc_info.value)

  @pytest.mark.asyncio
  async def test_timeout_during_initialization_with_flag_on(self):
    """Test timeout during session initialization with flag ON.

    Verifies that session initialization uses `anyio.fail_after` under the
    graceful error handling feature flag.
    """
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=0.1, sse_read_timeout=None
    )

    # Mock ClientSession with slow initialize
    mock_session = MockClientSession()

    async def slow_initialize():
      await asyncio.sleep(1.0)
      return mock_session

    mock_session.initialize = slow_initialize

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      with temporary_feature_override(
          FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
      ):
        with pytest.raises(ConnectionError) as exc_info:
          await session_context.start()

      assert 'Failed to create MCP session' in str(exc_info.value)

  @pytest.mark.asyncio
  async def test_timeout_during_initialization_with_flag_off(self):
    """Test timeout during session initialization with flag OFF.

    Verifies that session initialization falls back to `asyncio.wait_for`
    when graceful error handling is disabled.
    """
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=0.1, sse_read_timeout=None
    )

    # Mock ClientSession with slow initialize
    mock_session = MockClientSession()

    async def slow_initialize():
      await asyncio.sleep(1.0)
      return mock_session

    mock_session.initialize = slow_initialize

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      with temporary_feature_override(
          FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False
      ):
        with pytest.raises(ConnectionError) as exc_info:
          await session_context.start()

      assert 'Failed to create MCP session' in str(exc_info.value)

  @pytest.mark.asyncio
  async def test_uses_anyio_fail_after_when_flag_on(self):
    """Test that session initialization structurally uses anyio.fail_after.

    Asserts that the session runner enters `anyio.fail_after` context
    with the timeout limit when graceful error handling is enabled.
    """
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=2.5, sse_read_timeout=None
    )
    mock_session = MockClientSession()

    with (
        patch(
            'google.adk.tools.mcp_tool.session_context.ClientSession'
        ) as mock_session_class,
        patch('anyio.fail_after') as mock_fail_after,
    ):
      mock_session_class.return_value = mock_session
      # Configure mock_fail_after synchronous context manager to do nothing
      mock_fail_after.return_value.__enter__ = Mock()
      mock_fail_after.return_value.__exit__ = Mock(return_value=False)

      with temporary_feature_override(
          FeatureName._MCP_GRACEFUL_ERROR_HANDLING, True
      ):
        await session_context.start()

        # Verify anyio.fail_after was called with the correct timeout
        mock_fail_after.assert_called_once_with(2.5)

      await session_context.close()

  @pytest.mark.asyncio
  async def test_stdio_client_with_read_timeout(self):
    """Test stdio client includes read_timeout_seconds parameter."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None, is_stdio=True
    )

    mock_session = MockClientSession()

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      await session_context.start()

      # Verify ClientSession was called with read_timeout_seconds for stdio
      call_args = mock_session_class.call_args
      assert 'read_timeout_seconds' in call_args.kwargs
      assert call_args.kwargs['read_timeout_seconds'] == timedelta(seconds=5.0)

      await session_context.close()

  @pytest.mark.asyncio
  async def test_non_stdio_client_without_read_timeout(self):
    """Test non-stdio client does not include read_timeout_seconds."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None, is_stdio=False
    )

    mock_session = MockClientSession()

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      await session_context.start()

      # Verify ClientSession was called with read_timeout_seconds=None for non-stdio
      # when sse_read_timeout is None
      call_args = mock_session_class.call_args
      assert 'read_timeout_seconds' in call_args.kwargs
      assert call_args.kwargs['read_timeout_seconds'] is None

      await session_context.close()

  @pytest.mark.asyncio
  async def test_sse_read_timeout_passed_to_client_session(self):
    """Test that sse_read_timeout is passed to ClientSession for non-stdio."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=300.0, is_stdio=False
    )

    mock_session = MockClientSession()

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      await session_context.start()

      # Verify ClientSession was called with sse_read_timeout
      call_args = mock_session_class.call_args
      assert 'read_timeout_seconds' in call_args.kwargs
      assert call_args.kwargs['read_timeout_seconds'] == timedelta(
          seconds=300.0
      )

      await session_context.close()

  @pytest.mark.asyncio
  async def test_close_multiple_times(self):
    """Test that close() can be called multiple times safely."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    mock_session = MockClientSession()

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      await session_context.start()

      # Close multiple times
      await session_context.close()
      await session_context.close()
      await session_context.close()

      # Should not raise exception
      assert session_context._close_event.is_set()

  @pytest.mark.asyncio
  async def test_close_before_start(self):
    """Test that close() works even if start() was never called."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    # Close before starting should not raise
    await session_context.close()

    assert session_context._close_event.is_set()

  @pytest.mark.asyncio
  async def test_close_before_start_ends(self):
    """Test that close() before start() ends the task."""
    # Client has enough time to delay the start task
    mock_client = MockClient(delay_on_enter=10.0)
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    start_task = asyncio.create_task(session_context.start())
    await asyncio.sleep(0.1)
    assert not start_task.done()

    # Call close before start() ends the task
    await session_context.close()
    await asyncio.sleep(0.1)

    assert start_task.done()
    assert isinstance(
        start_task.exception(), ConnectionError
    ) and 'task cancelled' in str(start_task.exception())

  @pytest.mark.asyncio
  async def test_close_before_start_called(self):
    """Test that close() before start() called sets the close event."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    # Call close() before start() called
    await session_context.close()
    await asyncio.sleep(0.1)

    assert session_context._task is None
    assert session_context._close_event.is_set()

    with pytest.raises(ConnectionError) as exc_info:
      await session_context.start()

    assert 'session already closed' in str(exc_info.value)
    assert session_context._task is None

  @pytest.mark.asyncio
  async def test_session_property(self):
    """Test that session property returns the managed session."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    # Initially None
    assert session_context.session is None

    mock_session = MockClientSession()

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = mock_session

      await session_context.start()

      # Should return the session
      assert session_context.session == mock_session

      await session_context.close()

  @pytest.mark.asyncio
  async def test_client_cleanup_on_exception(self):
    """Test that client is properly cleaned up even when exception occurs."""
    test_exception = RuntimeError('Test error')
    mock_client = MockClient(raise_on_enter=test_exception)
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    with pytest.raises(ConnectionError):
      await session_context.start()

    # Wait a bit for cleanup
    await asyncio.sleep(0.1)

    # Verify task completed
    assert session_context._task.done()

  @pytest.mark.asyncio
  async def test_close_handles_cancelled_error(self):
    """Test that close() handles CancelledError gracefully."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    mock_session = MockClientSession()

    with (
        patch(
            'google.adk.tools.mcp_tool.session_context.ClientSession'
        ) as mock_session_class,
        patch(
            'google.adk.tools.mcp_tool.session_context.logger'
        ) as mock_logger,
    ):
      mock_session_class.return_value = mock_session

      await session_context.start()

      # Cancel the task
      if session_context._task:
        session_context._task.cancel()

      # Close should handle CancelledError gracefully
      await session_context.close()

      # Should not raise exception
      assert session_context._close_event.is_set()

      # Verify no warning logs were generated
      mock_logger.warning.assert_not_called()

  @pytest.mark.asyncio
  async def test_close_handles_exception_during_cleanup(self):
    """Test that close() handles exceptions during cleanup gracefully."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    # Create a mock session that raises during exit
    class FailingMockSession(MockClientSession):

      async def __aexit__(self, exc_type, exc_val, exc_tb):
        raise RuntimeError('Cleanup failed')

    failing_session = FailingMockSession()

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = failing_session

      await session_context.start()

      # Close should handle the exception gracefully
      await session_context.close()

      # Should not raise exception
      assert session_context._close_event.is_set()


class TestSessionContextIsTaskAlive:
  """Tests for the SessionContext._is_task_alive property."""

  def test_is_task_alive_false_before_start(self):
    """Before start(), there is no task and the property returns False."""
    session_context = SessionContext(
        MockClient(), timeout=5.0, sse_read_timeout=None
    )
    assert session_context._is_task_alive is False

  @pytest.mark.asyncio
  async def test_is_task_alive_true_while_session_running(self):
    """After start(), the background task is alive until close()."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = MockClientSession()
      await session_context.start()
      try:
        assert session_context._is_task_alive is True
      finally:
        await session_context.close()

      assert session_context._is_task_alive is False


class TestSessionContextRunGuarded:
  """Tests for SessionContext._run_guarded.

  This is the heart of the 5-minute-hang fix: the method races a
  coroutine against the background session task and surfaces transport
  crashes immediately.
  """

  @pytest.mark.asyncio
  async def test_run_guarded_raises_when_task_not_started(self):
    """If start() was never called, _run_guarded refuses to run the coro."""
    session_context = SessionContext(
        MockClient(), timeout=5.0, sse_read_timeout=None
    )

    async def coro():
      return 'should never run'

    with pytest.raises(ConnectionError, match='task has not been started'):
      await session_context._run_guarded(coro())

  @pytest.mark.asyncio
  async def test_run_guarded_returns_result_on_success(self):
    """When the coroutine completes first, its result is returned."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = MockClientSession()
      await session_context.start()
      try:

        async def coro():
          return 'expected_result'

        result = await session_context._run_guarded(coro())
        assert result == 'expected_result'
      finally:
        await session_context.close()

  @pytest.mark.asyncio
  async def test_run_guarded_propagates_coro_exception(self):
    """A coroutine-level exception propagates as-is (not wrapped).

    This is intentional: callers (McpTool) need to distinguish a
    tool-level failure (McpError) from a transport-level failure
    (ConnectionError).
    """
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = MockClientSession()
      await session_context.start()
      try:

        async def coro():
          raise ValueError('tool error')

        with pytest.raises(ValueError, match='tool error'):
          await session_context._run_guarded(coro())
      finally:
        await session_context.close()

  @pytest.mark.asyncio
  async def test_run_guarded_raises_when_task_died_before_call(self):
    """If the background task already died, surface ConnectionError immediately."""
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = MockClientSession()
      await session_context.start()
      # Simulate a transport crash by closing the session.
      await session_context.close()

      async def coro():
        return 'should not run'

      with pytest.raises(ConnectionError, match='already terminated'):
        await session_context._run_guarded(coro())

  @pytest.mark.asyncio
  async def test_run_guarded_cancels_coro_when_task_dies_first(self):
    """If the background task dies mid-flight, cancel the coro and raise.

    This is the regression test for the 5-minute hang: when the MCP
    transport crashes (e.g. AGW returns 403), the background task ends
    quickly, and the in-flight call must be cancelled rather than
    waiting for sse_read_timeout.
    """
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = MockClientSession()
      await session_context.start()

      coro_started = asyncio.Event()
      coro_was_cancelled = False

      async def slow_coro():
        nonlocal coro_was_cancelled
        coro_started.set()
        try:
          # Pretend we're awaiting a 5-minute SSE read.
          await asyncio.sleep(300)
          return 'should never reach here'
        except asyncio.CancelledError:
          coro_was_cancelled = True
          raise

      async def kill_background_task():
        await coro_started.wait()
        # Simulate a transport crash by closing the session, which ends
        # the background task quickly.
        await session_context.close()

      killer = asyncio.create_task(kill_background_task())

      try:
        with pytest.raises(ConnectionError, match='connection lost'):
          await session_context._run_guarded(slow_coro())

        assert coro_was_cancelled is True
      finally:
        await killer


class TestSessionContextFlagOffPreservesPreFixBehavior:
  """Pin down that flag=OFF reproduces pre-fix behavior exactly.

  These tests guard against accidental changes leaking into the flag=OFF
  path, which is the default. An earlier unconditional version of this
  fix caused existing callers to hit a 3-minute hang because behavior
  changes were applied to the default path. We must keep flag=OFF
  byte-for-byte equivalent to pre-fix.
  """

  @pytest.mark.asyncio
  async def test_inner_wait_for_is_used_when_flag_off(self):
    """The inner asyncio.wait_for around enter_async_context must run.

    Pre-fix code wrapped client entry in `asyncio.wait_for(..., timeout)`.
    Callers that depend on that inner timeout firing for hanging mocks
    rely on this behavior. With the flag OFF we must restore it.
    """
    delayed_client = MockClient(delay_on_enter=10.0)
    session_context = SessionContext(
        delayed_client, timeout=0.2, sse_read_timeout=None
    )

    with temporary_feature_override(
        FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False
    ):
      with pytest.raises(ConnectionError):
        # The inner wait_for should fire at ~timeout=0.2s, surfacing as
        # ConnectionError from start(). If the inner wait_for is missing
        # (the AnyIO fix being applied unconditionally), this test would
        # block until the OUTER timeout cancels - which doesn't exist
        # here because we're calling start() directly.
        await asyncio.wait_for(session_context.start(), timeout=2.0)

    # And confirm: this would NOT raise quickly with the flag ON
    # because the inner wait_for is removed. We don't actually run the
    # flag-on case here because there's no outer timeout in this direct
    # call - that's tested at the McpTool integration level.

  @pytest.mark.asyncio
  async def test_no_extra_none_check_when_flag_off(self):
    """The 'session is None' raise must NOT happen when flag is off.

    Pre-fix code returned `self._session` directly, even if it was
    somehow None. Our new None check is gated to preserve that.
    """
    mock_client = MockClient()
    session_context = SessionContext(
        mock_client, timeout=5.0, sse_read_timeout=None
    )

    with patch(
        'google.adk.tools.mcp_tool.session_context.ClientSession'
    ) as mock_session_class:
      mock_session_class.return_value = MockClientSession()
      with temporary_feature_override(
          FeatureName._MCP_GRACEFUL_ERROR_HANDLING, False
      ):
        # In normal flow, _session is set; the gated None check is moot.
        # This test exists primarily to document and guard the flag-OFF
        # code path.
        result = await session_context.start()
        try:
          assert result is not None
        finally:
          await session_context.close()


class TestFormatException:
  """Test suite for _format_exception helper."""

  def test_format_exception_normal(self):
    exc = ValueError('normal error')
    assert _format_exception(exc) == 'normal error'

  def test_format_exception_http_status_error(self):
    request = httpx.Request('GET', 'http://test')
    response = httpx.Response(403, request=request, text='Forbidden access')
    exc = httpx.HTTPStatusError(
        '403 Forbidden', request=request, response=response
    )

    formatted = _format_exception(exc)
    assert '403 Forbidden' in formatted
    assert 'Forbidden access' in formatted

  def test_format_exception_group(self):
    class MockExceptionGroup(Exception):

      def __init__(self, message, exceptions):
        super().__init__(message)
        self.exceptions = exceptions

    request = httpx.Request('GET', 'http://test')
    response = httpx.Response(403, request=request, text='Forbidden access')
    exc1 = httpx.HTTPStatusError(
        '403 Forbidden', request=request, response=response
    )
    exc2 = ValueError('another error')

    eg = MockExceptionGroup('Group', [exc1, exc2])
    formatted = _format_exception(eg)

    assert '403 Forbidden' in formatted
    assert 'Forbidden access' in formatted
    assert 'another error' in formatted
