Files
google--adk-python/tests/unittests/sessions/test_vertex_ai_session_service.py
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

1525 lines
49 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import copy
import datetime
import re
import types
from typing import Any
from typing import List
from typing import Optional
from typing import Tuple
from unittest import mock
from dateutil.parser import isoparse
from fastapi.openapi import models as openapi_models
from google.adk.auth import auth_schemes
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.models.cache_metadata import CacheMetadata
from google.adk.sessions.base_session_service import GetSessionConfig
from google.adk.sessions.session import Session
from google.adk.sessions.vertex_ai_session_service import _extract_short_session_id
from google.adk.sessions.vertex_ai_session_service import _validate_session_id
from google.adk.sessions.vertex_ai_session_service import VertexAiSessionService
from google.api_core import exceptions as api_core_exceptions
from google.genai import types as genai_types
from google.genai.errors import ClientError
import pydantic
import pytest
MOCK_SESSION_JSON_1 = {
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/1'
),
'create_time': '2024-12-12T12:12:12.123456Z',
'update_time': '2024-12-12T12:12:12.123456Z',
'session_state': {
'key': {'value': 'test_value'},
},
'user_id': 'user',
}
MOCK_SESSION_JSON_2 = {
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/2'
),
'update_time': '2024-12-13T12:12:12.123456Z',
'user_id': 'user',
}
MOCK_SESSION_JSON_3 = {
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/3'
),
'update_time': '2024-12-14T12:12:12.123456Z',
'user_id': 'user2',
}
MOCK_EVENT_JSON = [
{
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/1/events/123'
),
'invocation_id': '123',
'author': 'user',
'timestamp': '2024-12-12T12:12:12.123456Z',
'content': {
'parts': [
{'text': 'test_content'},
],
},
'actions': {
'state_delta': {
'key': {'value': 'test_value'},
},
'transfer_agent': 'agent',
},
'event_metadata': {
'partial': False,
'turn_complete': True,
'interrupted': False,
'branch': '',
'long_running_tool_ids': ['tool1'],
},
'raw_event': {},
},
]
MOCK_EVENT_JSON_2 = [
{
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/2/events/123'
),
'invocation_id': '222',
'author': 'user',
'timestamp': '2024-12-12T12:12:12.123456Z',
},
]
MOCK_EVENT_JSON_3 = [
{
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/2/events/456'
),
'invocation_id': '333',
'author': 'user',
'timestamp': '2024-12-12T12:12:13.123456Z',
},
]
MOCK_SESSION_JSON_PAGE1 = {
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/page1'
),
'update_time': '2024-12-15T12:12:12.123456Z',
'user_id': 'user_with_pages',
}
MOCK_SESSION_JSON_PAGE2 = {
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/page2'
),
'update_time': '2024-12-16T12:12:12.123456Z',
'user_id': 'user_with_pages',
}
MOCK_SESSION_JSON_5 = {
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/5'
),
'update_time': '2024-12-12T12:15:12.123456Z',
'user_id': 'user_with_many_events',
}
def _generate_mock_events_for_session_5(num_events):
events = []
start_time = isoparse('2024-12-12T12:12:12.123456Z')
for i in range(num_events):
event_time = start_time + datetime.timedelta(microseconds=i * 1000)
events.append({
'name': (
'projects/test-project/locations/test-location/'
f'reasoningEngines/123/sessions/5/events/{i}'
),
'invocation_id': f'invocation_{i}',
'author': 'user_with_many_events',
'timestamp': event_time.isoformat().replace('+00:00', 'Z'),
})
return events
MANY_EVENTS_COUNT = 200
MOCK_EVENTS_JSON_5 = _generate_mock_events_for_session_5(MANY_EVENTS_COUNT)
MOCK_EVENT_WITH_OVERRIDE_JSON = [{
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/override/events/1'
),
'invocationId': 'override_invoke',
'author': 'user_with_override',
'timestamp': '2024-12-12T12:12:12.123456Z',
'content': {
'parts': [
{'text': 'top_level_content'},
],
},
'actions': {
'transferToAgent': 'top_level_agent',
},
'eventMetadata': {
'partial': True,
'turnComplete': False,
'interrupted': False,
'branch': 'top_level_branch',
},
'errorCode': '111',
'errorMessage': 'top_level_error',
'rawEvent': {
'invocationId': 'wrong_invocation_id',
'author': 'wrong_author',
'content': {
'parts': [
{'text': 'raw_event_content'},
],
},
'actions': {
'transferToAgent': 'raw_event_agent',
},
'partial': False,
'turnComplete': True,
'interrupted': True,
'branch': 'raw_event_branch',
'errorCode': '222',
'errorMessage': 'raw_event_error',
},
}]
MOCK_EVENT_WITH_OVERRIDE_JSON_2 = [{
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/override/events/1'
),
'invocationId': 'override_invoke',
'author': 'user_with_override',
'content': {},
'actions': {},
'timestamp': '2024-12-12T12:12:12.123456Z',
'rawEvent': {
'invocationId': 'wrong_invocation_id',
'author': 'wrong_author',
'content': {
'parts': [
{'text': 'raw_event_content'},
],
},
'actions': {
'skipSummarization': None,
'stateDelta': {},
'artifactDelta': {},
'transferToAgent': 'raw_event_agent',
'escalate': None,
'requestedAuthConfigs': {},
},
'errorCode': '222',
'errorMessage': 'raw_event_error',
'partial': False,
'turnComplete': True,
'interrupted': True,
'branch': 'raw_event_branch',
'customMetadata': None,
'longRunningToolIds': None,
},
}]
MOCK_SESSION_WITH_OVERRIDE_JSON = {
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/override'
),
'update_time': '2024-12-12T12:12:12.123456Z',
'user_id': 'user_with_override',
}
MOCK_SESSION = Session(
app_name='123',
user_id='user',
id='1',
state=MOCK_SESSION_JSON_1['session_state'],
last_update_time=isoparse(MOCK_SESSION_JSON_1['update_time']).timestamp(),
events=[
Event(
id='123',
invocation_id='123',
author='user',
timestamp=isoparse(MOCK_EVENT_JSON[0]['timestamp']).timestamp(),
content=genai_types.Content(
parts=[genai_types.Part(text='test_content')]
),
actions=EventActions(
transfer_to_agent='agent',
state_delta={'key': {'value': 'test_value'}},
),
partial=False,
turn_complete=True,
interrupted=False,
branch='',
long_running_tool_ids={'tool1'},
),
],
)
MOCK_SESSION_2 = Session(
app_name='123',
user_id='user',
id='2',
last_update_time=isoparse(MOCK_SESSION_JSON_2['update_time']).timestamp(),
events=[
Event(
id='123',
invocation_id='222',
author='user',
timestamp=isoparse(MOCK_EVENT_JSON_2[0]['timestamp']).timestamp(),
),
Event(
id='456',
invocation_id='333',
author='user',
timestamp=isoparse(MOCK_EVENT_JSON_3[0]['timestamp']).timestamp(),
),
],
)
class PydanticNamespace(types.SimpleNamespace):
def model_dump(self, exclude_none=True, mode='python'):
d = {}
for k, v in self.__dict__.items():
if exclude_none and v is None:
continue
if isinstance(v, PydanticNamespace):
d[k] = v.model_dump(exclude_none=exclude_none, mode=mode)
elif isinstance(v, list):
d[k] = [
i.model_dump(exclude_none=exclude_none, mode=mode)
if isinstance(i, PydanticNamespace)
else i
for i in v
]
else:
d[k] = v
return d
def _convert_to_object(data):
if isinstance(data, dict):
kwargs = {}
for key, value in data.items():
if key in [
'timestamp',
'update_time',
'create_time',
] and isinstance(value, str):
kwargs[key] = isoparse(value)
elif key in [
'session_state',
'state_delta',
'artifact_delta',
'custom_metadata',
'requested_auth_configs',
'rawEvent',
'raw_event',
]:
kwargs[key] = value
else:
kwargs[key] = _convert_to_object(value)
return PydanticNamespace(**kwargs)
elif isinstance(data, list):
return [_convert_to_object(item) for item in data]
else:
return data
async def to_async_iterator(data):
for item in data:
yield item
class MockAsyncClient:
"""Mocks the API Client."""
def __init__(self) -> None:
"""Initializes MockClient."""
self.session_dict: dict[str, Any] = {}
self.event_dict: dict[str, Tuple[List[Any], Optional[str]]] = {}
self.agent_engines = mock.AsyncMock()
self.agent_engines.sessions.get.side_effect = self._get_session
self.agent_engines.sessions.list.side_effect = self._list_sessions
self.agent_engines.sessions.delete.side_effect = self._delete_session
self.agent_engines.sessions.create.side_effect = self._create_session
self.agent_engines.sessions.events.list.side_effect = self._list_events
self.agent_engines.sessions.events.append.side_effect = self._append_event
self.last_create_session_config: dict[str, Any] = {}
self.last_list_sessions_config: dict[str, Any] = {}
async def __aenter__(self):
"""Enters the asynchronous context."""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Exits the asynchronous context."""
pass
async def _get_session(self, name: str):
session_id = name.split('/')[-1]
if session_id in self.session_dict:
return _convert_to_object(self.session_dict[session_id])
raise api_core_exceptions.NotFound(f'Session not found: {session_id}')
async def _list_sessions(self, name: str, config: dict[str, Any]):
self.last_list_sessions_config = config
filter_val = config.get('filter', '')
user_id_match = re.search(r'user_id="((?:\\.|[^"])*)"', filter_val)
if user_id_match:
user_id = user_id_match.group(1)
if user_id == 'user_with_pages':
return to_async_iterator([
_convert_to_object(MOCK_SESSION_JSON_PAGE1),
_convert_to_object(MOCK_SESSION_JSON_PAGE2),
])
return to_async_iterator([
_convert_to_object(session)
for session in self.session_dict.values()
if session['user_id'] == user_id
])
# No user filter, return all sessions
return to_async_iterator(
[_convert_to_object(session) for session in self.session_dict.values()]
)
async def _delete_session(self, name: str):
session_id = name.split('/')[-1]
self.session_dict.pop(session_id)
async def _create_session(
self, name: str, user_id: str, config: dict[str, Any]
):
self.last_create_session_config = config
if 'session_id' in config:
new_session_id = config['session_id']
else:
new_session_id = '4'
self.session_dict[new_session_id] = {
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/'
+ new_session_id
),
'user_id': user_id,
'session_state': config.get('session_state', {}),
'update_time': '2024-12-12T12:12:12.123456Z',
}
return _convert_to_object({
'name': (
'projects/test_project/locations/test_location/'
'reasoningEngines/123/sessions/'
+ new_session_id
+ '/operations/111'
),
'done': True,
'response': self.session_dict[new_session_id],
})
async def _list_events(self, name: str, **kwargs):
session_id = name.split('/')[-1]
events = []
if session_id in self.event_dict:
events_tuple = self.event_dict[session_id]
events.extend(events_tuple[0])
if events_tuple[1] == 'my_token':
events.extend(MOCK_EVENT_JSON_3)
config = kwargs.get('config', {})
filter_str = config.get('filter', None)
if filter_str:
match = re.search(r'timestamp>="([^"]+)"', filter_str)
if match:
after_timestamp_str = match.group(1)
after_timestamp = isoparse(after_timestamp_str)
events = [
event
for event in events
if isoparse(event['timestamp']) >= after_timestamp
]
return to_async_iterator([_convert_to_object(event) for event in events])
async def _append_event(
self,
name: str,
author: str,
invocation_id: str,
timestamp: Any,
config: dict[str, Any],
):
session_id = name.split('/')[-1]
event_list, token = self.event_dict.get(session_id, ([], None))
event_id = str(len(event_list) + 1000) # generate unique ID
event_timestamp_str = timestamp.isoformat().replace('+00:00', 'Z')
event_json = {
'name': f'{name}/events/{event_id}',
'invocation_id': invocation_id,
'author': author,
'timestamp': event_timestamp_str,
}
event_json.update(config)
if session_id in self.session_dict:
self.session_dict[session_id]['update_time'] = event_timestamp_str
if session_id in self.event_dict:
self.event_dict[session_id][0].append(event_json)
else:
self.event_dict[session_id] = ([event_json], None)
class MockAsyncClientWithPagination:
"""Mock client that simulates pagination requiring an open client connection.
This mock tracks whether the client context is active and raises RuntimeError
if iteration occurs outside the context, simulating the real httpx behavior.
"""
def __init__(self, session_data: dict, events_pages: list[list[dict]]):
self._session_data = session_data
self._events_pages = events_pages
self._context_active = False
self.agent_engines = mock.AsyncMock()
self.agent_engines.sessions.get.side_effect = self._get_session
self.agent_engines.sessions.events.list.side_effect = self._list_events
async def __aenter__(self):
self._context_active = True
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
self._context_active = False
async def _get_session(self, name: str):
return _convert_to_object(self._session_data)
async def _list_events(self, name: str, **kwargs):
return self._paginated_events_iterator()
async def _paginated_events_iterator(self):
for page in self._events_pages:
for event in page:
if not self._context_active:
raise RuntimeError(
'Cannot send a request, as the client has been closed.'
)
yield _convert_to_object(event)
def _generate_events_for_page(session_id: str, start_idx: int, count: int):
events = []
start_time = isoparse('2024-12-12T12:12:12.123456Z')
for i in range(count):
idx = start_idx + i
event_time = start_time + datetime.timedelta(microseconds=idx * 1000)
events.append({
'name': (
'projects/test-project/locations/test-location/'
f'reasoningEngines/123/sessions/{session_id}/events/{idx}'
),
'invocation_id': f'invocation_{idx}',
'author': 'pagination_user',
'timestamp': event_time.isoformat().replace('+00:00', 'Z'),
})
return events
@pytest.mark.asyncio
async def test_get_session_pagination_keeps_client_open():
"""Regression test: event iteration must occur inside the api_client context.
This test verifies that get_session() keeps the API client open while
iterating through paginated events. Before the fix, the events_iterator
was consumed outside the async with block, causing RuntimeError when
fetching subsequent pages.
"""
session_data = {
'name': (
'projects/test-project/locations/test-location/'
'reasoningEngines/123/sessions/pagination_test'
),
'update_time': '2024-12-12T12:12:12.123456Z',
'user_id': 'pagination_user',
}
page1_events = _generate_events_for_page('pagination_test', 0, 100)
page2_events = _generate_events_for_page('pagination_test', 100, 100)
page3_events = _generate_events_for_page('pagination_test', 200, 50)
mock_client = MockAsyncClientWithPagination(
session_data=session_data,
events_pages=[page1_events, page2_events, page3_events],
)
session_service = mock_vertex_ai_session_service()
with mock.patch.object(
session_service, '_get_api_client', return_value=mock_client
):
session = await session_service.get_session(
app_name='123', user_id='pagination_user', session_id='pagination_test'
)
assert session is not None
assert len(session.events) == 250
assert session.events[0].invocation_id == 'invocation_0'
assert session.events[249].invocation_id == 'invocation_249'
def mock_vertex_ai_session_service(
project: Optional[str] = 'test-project',
location: Optional[str] = 'test-location',
agent_engine_id: Optional[str] = None,
express_mode_api_key: Optional[str] = None,
):
"""Creates a mock Vertex AI Session service for testing."""
return VertexAiSessionService(
project=project,
location=location,
agent_engine_id=agent_engine_id,
express_mode_api_key=express_mode_api_key,
)
@pytest.fixture
def mock_api_client_instance():
"""Creates a mock API client instance for testing."""
api_client = MockAsyncClient()
api_client.session_dict = {
'1': MOCK_SESSION_JSON_1,
'2': MOCK_SESSION_JSON_2,
'3': MOCK_SESSION_JSON_3,
'page1': MOCK_SESSION_JSON_PAGE1,
'page2': MOCK_SESSION_JSON_PAGE2,
}
api_client.event_dict = {
'1': (copy.deepcopy(MOCK_EVENT_JSON), None),
'2': (copy.deepcopy(MOCK_EVENT_JSON_2), 'my_token'),
}
return api_client
@pytest.fixture
def mock_get_api_client(mock_api_client_instance):
"""Mocks the _get_api_client method to return a mock API client."""
with mock.patch(
'google.adk.sessions.vertex_ai_session_service.VertexAiSessionService._get_api_client',
return_value=mock_api_client_instance,
):
yield
@pytest.mark.asyncio
async def test_initialize_with_project_location_and_api_key_error():
with pytest.raises(ValueError) as excinfo:
mock_vertex_ai_session_service(
project='test-project',
location='test-location',
express_mode_api_key='test-api-key',
)
assert (
'Cannot specify project or location and express_mode_api_key. Either use'
' project and location, or just the express_mode_api_key.'
in str(excinfo.value)
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_get_session_returns_none_when_invalid_argument(
mock_api_client_instance,
):
session_service = mock_vertex_ai_session_service()
# Simulate the API raising a session not found exception.
mock_api_client_instance.agent_engines.sessions.get.side_effect = ClientError(
code=404,
response_json={
'message': (
'Session (projectNumber: 123, reasoningEngineId: 123, sessionId:'
' 123) not found.'
)
},
response=None,
)
session = await session_service.get_session(
app_name='123', user_id='user', session_id='missing'
)
assert session is None
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
@pytest.mark.parametrize('agent_engine_id', [None, '123'])
async def test_get_empty_session(agent_engine_id):
session_service = mock_vertex_ai_session_service(agent_engine_id)
with pytest.raises(api_core_exceptions.NotFound) as excinfo:
await session_service.get_session(
app_name='123', user_id='user', session_id='0'
)
assert str(excinfo.value) == '404 Session not found: 0'
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
@pytest.mark.parametrize('agent_engine_id', [None, '123'])
async def test_get_another_user_session(agent_engine_id):
session_service = mock_vertex_ai_session_service(agent_engine_id)
with pytest.raises(ValueError) as excinfo:
await session_service.get_session(
app_name='123', user_id='user2', session_id='1'
)
assert str(excinfo.value) == 'Session 1 does not belong to user user2.'
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_get_and_delete_session():
session_service = mock_vertex_ai_session_service()
assert (
await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
== MOCK_SESSION
)
await session_service.delete_session(
app_name='123', user_id='user', session_id='1'
)
with pytest.raises(api_core_exceptions.NotFound) as excinfo:
await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert str(excinfo.value) == '404 Session not found: 1'
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_delete_session_rejects_other_users_session():
"""delete_session must not delete a session owned by a different user."""
session_service = mock_vertex_ai_session_service()
# session '1' belongs to 'user'; 'user2' must not be allowed to delete it.
with pytest.raises(ValueError) as excinfo:
await session_service.delete_session(
app_name='123', user_id='user2', session_id='1'
)
assert 'does not belong to user user2' in str(excinfo.value)
# Session must still exist.
assert (
await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
== MOCK_SESSION
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_session_id_path_traversal_rejected():
"""Session IDs containing path-traversal characters must be rejected."""
session_service = mock_vertex_ai_session_service()
for bad_id in ['..', '../foo', '..?force=true', 'a/b', '']:
with pytest.raises(ValueError):
await session_service.delete_session(
app_name='123', user_id='user', session_id=bad_id
)
with pytest.raises(ValueError):
await session_service.get_session(
app_name='123', user_id='user', session_id=bad_id
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_get_session_with_page_token():
session_service = mock_vertex_ai_session_service()
assert (
await session_service.get_session(
app_name='123', user_id='user', session_id='2'
)
== MOCK_SESSION_2
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_get_session_with_after_timestamp_filter():
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123',
user_id='user',
session_id='2',
config=GetSessionConfig(
after_timestamp=isoparse('2024-12-12T12:12:13.0Z').timestamp()
),
)
assert session is not None
assert len(session.events) == 1
assert session.events[0].id == '456'
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_get_session_keeps_events_newer_than_update_time(
mock_api_client_instance: MockAsyncClient,
) -> None:
future_event_time = isoparse(
MOCK_SESSION_JSON_1['update_time']
) + datetime.timedelta(seconds=1)
event = mock_api_client_instance.event_dict['1'][0][0]
event['timestamp'] = future_event_time.isoformat().replace('+00:00', 'Z')
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert session is not None
assert len(session.events) == 1
assert session.events[0].timestamp == future_event_time.timestamp()
assert session.events[0].timestamp > session.last_update_time, (
'Event timestamp should exceed session update_time to guard against'
' filtering.'
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
@pytest.mark.parametrize(
'mock_event_json',
[MOCK_EVENT_WITH_OVERRIDE_JSON, MOCK_EVENT_WITH_OVERRIDE_JSON_2],
)
async def test_get_session_from_raw_event(
mock_api_client_instance: MockAsyncClient,
mock_event_json,
) -> None:
mock_api_client_instance.session_dict['6'] = MOCK_SESSION_WITH_OVERRIDE_JSON
mock_api_client_instance.event_dict['6'] = (
copy.deepcopy(mock_event_json),
None,
)
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user_with_override', session_id='6'
)
assert session is not None
assert len(session.events) == 1
event = session.events[0]
assert event.content.parts[0].text == 'raw_event_content'
assert event.actions.transfer_to_agent == 'raw_event_agent'
assert not event.partial
assert event.turn_complete
assert event.interrupted
assert event.branch == 'raw_event_branch'
assert event.error_code == '222'
assert event.error_message == 'raw_event_error'
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_get_session_with_many_events(mock_api_client_instance):
mock_api_client_instance.session_dict['5'] = MOCK_SESSION_JSON_5
mock_api_client_instance.event_dict['5'] = (
copy.deepcopy(MOCK_EVENTS_JSON_5),
None,
)
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user_with_many_events', session_id='5'
)
assert session is not None
assert len(session.events) == MANY_EVENTS_COUNT
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_get_session_with_num_recent_events_zero():
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123',
user_id='user',
session_id='2',
config=GetSessionConfig(num_recent_events=0),
)
assert session is not None
assert len(session.events) == 0
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_list_sessions():
session_service = mock_vertex_ai_session_service()
sessions = await session_service.list_sessions(app_name='123', user_id='user')
assert len(sessions.sessions) == 2
assert sessions.sessions[0].id == '1'
assert sessions.sessions[1].id == '2'
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_list_sessions_with_pagination():
session_service = mock_vertex_ai_session_service()
sessions = await session_service.list_sessions(
app_name='123', user_id='user_with_pages'
)
assert len(sessions.sessions) == 2
assert sessions.sessions[0].id == 'page1'
assert sessions.sessions[1].id == 'page2'
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_list_sessions_all_users():
session_service = mock_vertex_ai_session_service()
sessions = await session_service.list_sessions(app_name='123', user_id=None)
assert len(sessions.sessions) == 5
assert {s.id for s in sessions.sessions} == {
'1',
'2',
'3',
'page1',
'page2',
}
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
@pytest.mark.parametrize(
('payload', 'expected_filter'),
[
(
'attacker" OR user_id!=""',
'user_id="attacker\\" OR user_id!=\\"\\""',
),
('\\', 'user_id="\\\\"'),
('', 'user_id=""'),
],
)
async def test_list_sessions_quotes_user_id_filter(
mock_api_client_instance, payload, expected_filter
):
session_service = mock_vertex_ai_session_service()
sessions = await session_service.list_sessions(
app_name='123', user_id=payload
)
assert sessions.sessions == []
assert mock_api_client_instance.last_list_sessions_config == {
'filter': expected_filter
}
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_create_session():
session_service = mock_vertex_ai_session_service()
state = {'key': 'value'}
session = await session_service.create_session(
app_name='123', user_id='user', state=state
)
assert session.state == state
assert session.app_name == '123'
assert session.user_id == 'user'
assert session.last_update_time is not None
session_id = session.id
assert session == await session_service.get_session(
app_name='123', user_id='user', session_id=session_id
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
@pytest.mark.parametrize('session_id', ['1', 'abc123'])
async def test_create_session_with_custom_session_id(
mock_api_client_instance: MockAsyncClient, session_id: str
):
session_service = mock_vertex_ai_session_service()
mock_api_client_instance.event_dict[session_id] = (
[],
None,
)
session = await session_service.create_session(
app_name='123', user_id='user', session_id=session_id
)
assert session.id == session_id
assert session.app_name == '123'
assert session.user_id == 'user'
assert session.last_update_time is not None
assert session == await session_service.get_session(
app_name='123', user_id='user', session_id=session_id
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_create_session_with_custom_config(mock_api_client_instance):
session_service = mock_vertex_ai_session_service()
expire_time = '2025-12-12T12:12:12.123456Z'
await session_service.create_session(
app_name='123', user_id='user', expire_time=expire_time
)
assert (
mock_api_client_instance.last_create_session_config['expire_time']
== expire_time
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_create_session_with_ttl(mock_api_client_instance):
session_service = mock_vertex_ai_session_service()
ttl = '7200s'
await session_service.create_session(app_name='123', user_id='user', ttl=ttl)
assert mock_api_client_instance.last_create_session_config['ttl'] == ttl
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_create_session_with_ttl_and_expire_time_raises_value_error(
mock_api_client_instance,
):
session_service = mock_vertex_ai_session_service()
with pytest.raises(
ValueError,
match="Cannot specify both 'ttl' and 'expire_time' simultaneously.",
):
await session_service.create_session(
app_name='123',
user_id='user',
ttl='7200s',
expire_time='2025-12-12T12:12:12.123456Z',
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_create_session_with_ttl_none_and_expire_time_none_does_not_raise(
mock_api_client_instance,
):
session_service = mock_vertex_ai_session_service()
# None means "not set"; passing both as None must not raise.
await session_service.create_session(
app_name='123', user_id='user', ttl=None, expire_time=None
)
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_append_event():
session_service = mock_vertex_ai_session_service()
session_before_append = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
event_to_append = Event(
invocation_id='new_invocation',
author='model',
timestamp=1734005533.0,
content=genai_types.Content(parts=[genai_types.Part(text='new_content')]),
actions=EventActions(
transfer_to_agent='another_agent',
state_delta={'new_key': 'new_value'},
skip_summarization=True,
requested_auth_configs={
'test_auth': AuthConfig(
auth_scheme=auth_schemes.OAuth2(
flows=openapi_models.OAuthFlows(
implicit=openapi_models.OAuthFlowImplicit(
authorizationUrl='http://test.com/auth',
scopes={},
)
)
),
),
},
),
error_code='1',
error_message='test_error',
branch='test_branch',
custom_metadata={'custom': 'data'},
long_running_tool_ids={'tool2'},
input_transcription=genai_types.Transcription(
text='test_input_transcription'
),
output_transcription=genai_types.Transcription(
text='test_output_transcription'
),
model_version='test_model_version',
avg_logprobs=0.5,
logprobs_result=genai_types.LogprobsResult(
chosen_candidates=[
genai_types.LogprobsResultCandidate(
log_probability=0.5,
token='test_token',
token_id=0,
)
]
),
cache_metadata=CacheMetadata(
cache_name='test_cache_name',
expire_time=(
datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(minutes=30)
).timestamp(),
fingerprint='test_fingerprint',
invocations_used=1,
contents_count=1,
),
citation_metadata=genai_types.CitationMetadata(
citations=[
genai_types.Citation(
uri='http://test.com',
title='test_title',
)
]
),
)
await session_service.append_event(session_before_append, event_to_append)
retrieved_session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert len(retrieved_session.events) == 2
event_to_append.id = retrieved_session.events[1].id
assert retrieved_session.events[1] == event_to_append
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_append_event_strips_unsupported_part_metadata(
mock_api_client_instance: MockAsyncClient,
) -> None:
"""part_metadata must not reach the Sessions API (#6014).
``Part.part_metadata`` is a Gemini Developer API-only field; the Vertex AI
Agent Engine Sessions ``appendEvent`` API rejects it with 400 INVALID_ARGUMENT
("Unknown name \"part_metadata\""). It must be dropped from both the
``content`` and ``raw_event`` payloads, while the part text is preserved.
"""
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
event_to_append = Event(
invocation_id='inv_part_metadata',
author='user',
timestamp=1734005533.0,
content=genai_types.Content(
parts=[
genai_types.Part(
text='hello', part_metadata={'source': 'portal'}
),
genai_types.Part(text='world', part_metadata={'n': 1}),
],
),
)
await session_service.append_event(session, event_to_append)
appended = mock_api_client_instance.event_dict['1'][0][-1]
for part in appended['content']['parts']:
assert 'part_metadata' not in part
assert 'partMetadata' not in part
for part in appended['raw_event']['content']['parts']:
assert 'part_metadata' not in part
assert 'partMetadata' not in part
assert [p['text'] for p in appended['content']['parts']] == ['hello', 'world']
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_append_event_with_part_metadata_round_trips(
mock_api_client_instance: MockAsyncClient,
) -> None:
"""Reconstruction side of #6014: an event carrying part_metadata appends and
reads back without error. part_metadata is dropped (unsupported on Vertex),
but the session round-trips and the part text is preserved.
"""
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
event_to_append = Event(
invocation_id='inv_part_metadata_rt',
author='user',
timestamp=1734005533.0,
content=genai_types.Content(
role='user',
parts=[
genai_types.Part(text='hello', part_metadata={'source': 'portal'})
],
),
)
await session_service.append_event(session, event_to_append)
retrieved = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
appended = next(
e for e in retrieved.events if e.invocation_id == 'inv_part_metadata_rt'
)
assert appended.content is not None
assert appended.content.parts[0].text == 'hello'
assert appended.content.parts[0].part_metadata is None
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_append_event_with_compaction():
"""Compaction data round-trips through append_event and get_session."""
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert session is not None
compaction = EventCompaction(
start_timestamp=1000.0,
end_timestamp=2000.0,
compacted_content=genai_types.Content(
parts=[genai_types.Part(text='compacted summary')]
),
)
event_to_append = Event(
invocation_id='compaction_invocation',
author='model',
timestamp=1734005534.0,
actions=EventActions(compaction=compaction),
)
await session_service.append_event(session, event_to_append)
retrieved_session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert retrieved_session is not None
appended_event = retrieved_session.events[-1]
assert appended_event.actions.compaction is not None
assert appended_event.actions.compaction.start_timestamp == 1000.0
assert appended_event.actions.compaction.end_timestamp == 2000.0
assert appended_event.actions.compaction.compacted_content.parts[0].text == (
'compacted summary'
)
# custom_metadata should remain None when only compaction was stored
assert appended_event.custom_metadata is None
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_append_event_with_compaction_and_custom_metadata():
"""Both compaction and user custom_metadata survive the round-trip."""
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert session is not None
compaction = EventCompaction(
start_timestamp=100.0,
end_timestamp=200.0,
compacted_content=genai_types.Content(
parts=[genai_types.Part(text='summary')]
),
)
event_to_append = Event(
invocation_id='compaction_and_meta_invocation',
author='model',
timestamp=1734005535.0,
actions=EventActions(compaction=compaction),
custom_metadata={'user_key': 'user_value'},
)
await session_service.append_event(session, event_to_append)
retrieved_session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert retrieved_session is not None
appended_event = retrieved_session.events[-1]
# Compaction is restored
assert appended_event.actions.compaction is not None
assert appended_event.actions.compaction.start_timestamp == 100.0
assert appended_event.actions.compaction.end_timestamp == 200.0
# User custom_metadata is preserved without the internal _compaction key
assert appended_event.custom_metadata == {'user_key': 'user_value'}
assert '_compaction' not in (appended_event.custom_metadata or {})
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_append_event_with_usage_metadata():
"""usage_metadata round-trips through append_event and get_session."""
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert session is not None
event_to_append = Event(
invocation_id='usage_invocation',
author='model',
timestamp=1734005536.0,
usage_metadata=genai_types.GenerateContentResponseUsageMetadata(
prompt_token_count=150,
candidates_token_count=50,
total_token_count=200,
),
)
await session_service.append_event(session, event_to_append)
retrieved_session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert retrieved_session is not None
appended_event = retrieved_session.events[-1]
assert appended_event.usage_metadata is not None
assert appended_event.usage_metadata.prompt_token_count == 150
assert appended_event.usage_metadata.candidates_token_count == 50
assert appended_event.usage_metadata.total_token_count == 200
# custom_metadata should remain None when only usage_metadata was stored
assert appended_event.custom_metadata is None
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_append_event_with_usage_metadata_and_custom_metadata():
"""Both usage_metadata and user custom_metadata survive the round-trip."""
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert session is not None
event_to_append = Event(
invocation_id='usage_and_meta_invocation',
author='model',
timestamp=1734005537.0,
usage_metadata=genai_types.GenerateContentResponseUsageMetadata(
prompt_token_count=300,
total_token_count=400,
),
custom_metadata={'my_key': 'my_value'},
)
await session_service.append_event(session, event_to_append)
retrieved_session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert retrieved_session is not None
appended_event = retrieved_session.events[-1]
# usage_metadata is restored
assert appended_event.usage_metadata is not None
assert appended_event.usage_metadata.prompt_token_count == 300
assert appended_event.usage_metadata.total_token_count == 400
# User custom_metadata is preserved without internal keys
assert appended_event.custom_metadata == {'my_key': 'my_value'}
assert '_usage_metadata' not in (appended_event.custom_metadata or {})
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_append_event_with_usage_metadata_and_compaction():
"""usage_metadata, compaction, and user custom_metadata all coexist."""
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert session is not None
compaction = EventCompaction(
start_timestamp=500.0,
end_timestamp=600.0,
compacted_content=genai_types.Content(
parts=[genai_types.Part(text='compacted')]
),
)
event_to_append = Event(
invocation_id='all_three_invocation',
author='model',
timestamp=1734005538.0,
actions=EventActions(compaction=compaction),
usage_metadata=genai_types.GenerateContentResponseUsageMetadata(
prompt_token_count=1000,
candidates_token_count=250,
total_token_count=1250,
),
custom_metadata={'extra': 'info'},
)
await session_service.append_event(session, event_to_append)
retrieved_session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert retrieved_session is not None
appended_event = retrieved_session.events[-1]
# Compaction is restored
assert appended_event.actions.compaction is not None
assert appended_event.actions.compaction.start_timestamp == 500.0
assert appended_event.actions.compaction.end_timestamp == 600.0
# usage_metadata is restored
assert appended_event.usage_metadata is not None
assert appended_event.usage_metadata.prompt_token_count == 1000
assert appended_event.usage_metadata.candidates_token_count == 250
assert appended_event.usage_metadata.total_token_count == 1250
# User custom_metadata is preserved without internal keys
assert appended_event.custom_metadata == {'extra': 'info'}
assert '_compaction' not in (appended_event.custom_metadata or {})
assert '_usage_metadata' not in (appended_event.custom_metadata or {})
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_append_event_fallback_for_older_sdk(mock_api_client_instance):
"""Tests that append_event falls back to custom_metadata when SDK fails on raw_event."""
session_service = mock_vertex_ai_session_service()
session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
assert session is not None
compaction = EventCompaction(
start_timestamp=1000.0,
end_timestamp=2000.0,
compacted_content=genai_types.Content(
parts=[genai_types.Part(text='compacted summary')]
),
)
event_to_append = Event(
invocation_id='fallback_invocation',
author='model',
timestamp=1734005534.0,
actions=EventActions(compaction=compaction),
)
mock_client = mock_api_client_instance
async def side_effect(name, author, invocation_id, timestamp, config):
if 'raw_event' in config:
# Trigger a real ValidationError since Pydantic V2 doesn't allow easy
# instantiation
class DummyModel(pydantic.BaseModel):
a: int
DummyModel(a='not an int')
return await mock_client._append_event(
name, author, invocation_id, timestamp, config
)
mock_client.agent_engines.sessions.events.append.side_effect = side_effect
await session_service.append_event(session, event_to_append)
# Verify that it was written and restored correctly via custom_metadata
retrieved_session = await session_service.get_session(
app_name='123', user_id='user', session_id='1'
)
appended_event = retrieved_session.events[-1]
assert appended_event.actions.compaction is not None
assert appended_event.actions.compaction.start_timestamp == 1000.0
def test_extract_short_session_id_short_id():
assert _extract_short_session_id('123') == '123'
assert _extract_short_session_id('session-123_abc') == 'session-123_abc'
def test_extract_short_session_id_strips_full_resource_name():
resource_name = 'projects/123/locations/us-east4/reasoningEngines/456/sessions/session-123'
assert _extract_short_session_id(resource_name) == 'session-123'
assert (
_extract_short_session_id(resource_name, expected_engine_id='456')
== 'session-123'
)
def test_extract_short_session_id_mismatch():
resource_name = 'projects/123/locations/us-east4/reasoningEngines/wrong/sessions/session-123'
with pytest.raises(ValueError, match='Session resource name mismatch'):
_extract_short_session_id(resource_name, expected_engine_id='right')
def test_validate_session_id_rejects_invalid_chars():
with pytest.raises(ValueError, match='Invalid session_id'):
_validate_session_id('invalid@id')
with pytest.raises(ValueError, match='Invalid session_id'):
_validate_session_id('invalid/id')
@pytest.mark.asyncio
@pytest.mark.usefixtures('mock_get_api_client')
async def test_get_session_strips_full_resource_name(
mock_api_client_instance,
):
session_service = mock_vertex_ai_session_service()
mock_api_client_instance.session_dict['session-123'] = {
'name': (
'projects/123/locations/us-east4/reasoningEngines/123/sessions/session-123'
),
'update_time': '2023-01-01T00:00:00Z',
'user_id': 'user',
}
resource_name = 'projects/123/locations/us-east4/reasoningEngines/123/sessions/session-123'
session = await session_service.get_session(
app_name='123', user_id='user', session_id=resource_name
)
assert session.id == 'session-123'
mock_api_client_instance.agent_engines.sessions.get.assert_called_once_with(
name='reasoningEngines/123/sessions/session-123'
)