From 0c19ae9fcb3ac0b0e5e62a94704210127508a02f Mon Sep 17 00:00:00 2001 From: Vishal Bulbule Date: Fri, 18 Sep 2026 20:29:52 +0530 Subject: [PATCH 1/7] fix(firestore): replace nested app and user state instead of merging it create_session and append_event read the app and user state documents, apply the delta to the full dict, then wrote it back with merge=True. Firestore deep-merges nested maps on a merge write, so updating a dict value such as user:profile kept keys the new value had dropped. Write the documents back whole so they match the in-memory, SQLite and database session services. Fixes #7192 --- .../firestore/firestore_session_service.py | 15 ++-- .../test_firestore_session_service.py | 85 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/google/adk/integrations/firestore/firestore_session_service.py b/src/google/adk/integrations/firestore/firestore_session_service.py index 404fbdc064f..e765e80b5ed 100644 --- a/src/google/adk/integrations/firestore/firestore_session_service.py +++ b/src/google/adk/integrations/firestore/firestore_session_service.py @@ -245,14 +245,15 @@ async def _create_txn( (user_snap.to_dict() or {}) if user_snap.exists else {} ) - # 2. Writes + # 2. Writes. Documents are written whole: a merge write deep-merges + # nested maps and would keep keys that the new value dropped. if app_state_delta: current_app.update(app_state_delta) - transaction.set(app_ref, current_app, merge=True) + transaction.set(app_ref, current_app) if user_state_delta: current_user.update(user_state_delta) - transaction.set(user_ref, current_user, merge=True) + transaction.set(user_ref, current_user) transaction.set(session_ref, session_data) return current_app, current_user @@ -544,16 +545,16 @@ async def _append_txn(transaction: firestore.AsyncTransaction) -> int: else None ) - # 2. Writes + # 2. Writes. Documents are written whole, as in create_session. if app_updates and app_snap is not None: current_app = (app_snap.to_dict() or {}) if app_snap.exists else {} current_app.update(app_updates) - transaction.set(app_ref, current_app, merge=True) + transaction.set(app_ref, current_app) if user_updates and user_snap is not None: - current_user = user_snap.to_dict() if user_snap.exists else {} + current_user = (user_snap.to_dict() or {}) if user_snap.exists else {} current_user.update(user_updates) - transaction.set(user_ref, current_user, merge=True) + transaction.set(user_ref, current_user) new_revision = current_revision + 1 diff --git a/tests/unittests/integrations/firestore/test_firestore_session_service.py b/tests/unittests/integrations/firestore/test_firestore_session_service.py index 7b13139288d..aeadd5e11af 100644 --- a/tests/unittests/integrations/firestore/test_firestore_session_service.py +++ b/tests/unittests/integrations/firestore/test_firestore_session_service.py @@ -493,6 +493,91 @@ async def test_create_session_keeps_app_and_user_state_native( assert isinstance(persisted_state["session_key"], str) +def _stored_snapshot(data): + snapshot = mock.MagicMock() + snapshot.exists = True + snapshot.to_dict.return_value = data + return snapshot + + +@pytest.mark.asyncio +async def test_append_event_replaces_nested_app_and_user_state( + mock_firestore_client, +): + """A dict-valued app/user delta replaces the stored dict, dropping old keys.""" + service = FirestoreSessionService(client=mock_firestore_client) + session = Session(id="test_session", app_name="test_app", user_id="test_user") + old_value = {"name": "alice", "role": "admin"} + new_value = {"name": "bob"} + + root_coll = mock_firestore_client.collection.return_value + app_ref = root_coll.document.return_value + users_coll = app_ref.collection.return_value + user_ref = users_coll.document.return_value + session_doc_ref = user_ref.collection.return_value.document.return_value + session_doc_ref.get = mock.AsyncMock( + return_value=_stored_snapshot({"revision": 0}) + ) + app_ref.get = mock.AsyncMock( + return_value=_stored_snapshot({"cfg": old_value}) + ) + user_ref.get = mock.AsyncMock( + return_value=_stored_snapshot({"cfg": old_value}) + ) + + with mock.patch("google.cloud.firestore.async_transactional", lambda x: x): + await service.append_event( + session, + Event( + invocation_id="test_inv", + author="user", + actions=EventActions( + state_delta={"app:cfg": new_value, "user:cfg": new_value} + ), + ), + ) + + transaction = mock_firestore_client.transaction.return_value + writes = {call.args[0]: call for call in transaction.set.call_args_list} + for ref in (app_ref, user_ref): + assert writes[ref].args[1] == {"cfg": new_value} + assert not writes[ref].kwargs.get("merge") + + +@pytest.mark.asyncio +async def test_create_session_replaces_nested_app_and_user_state( + mock_firestore_client, +): + """Initial app and user state replace stored dict values instead of merging.""" + service = FirestoreSessionService(client=mock_firestore_client) + old_value = {"name": "alice", "role": "admin"} + + root_coll = mock_firestore_client.collection.return_value + app_ref = root_coll.document.return_value + user_ref = app_ref.collection.return_value.document.return_value + app_ref.get = mock.AsyncMock( + return_value=_stored_snapshot({"cfg": old_value}) + ) + user_ref.get = mock.AsyncMock( + return_value=_stored_snapshot({"cfg": old_value}) + ) + + with mock.patch("google.cloud.firestore.async_transactional", lambda x: x): + session = await service.create_session( + app_name="test_app", + user_id="test_user", + state={"app:cfg": {"name": "bob"}, "user:cfg": {"name": "bob"}}, + ) + + transaction = mock_firestore_client.transaction.return_value + writes = {call.args[0]: call for call in transaction.set.call_args_list} + for ref in (app_ref, user_ref): + assert writes[ref].args[1] == {"cfg": {"name": "bob"}} + assert not writes[ref].kwargs.get("merge") + assert session.state["app:cfg"] == {"name": "bob"} + assert session.state["user:cfg"] == {"name": "bob"} + + @pytest.mark.asyncio async def test_append_event_with_temp_state(mock_firestore_client): service = FirestoreSessionService(client=mock_firestore_client) From 1af3c1f1d456fde283b325b7761c75c4f0797fc5 Mon Sep 17 00:00:00 2001 From: Vishal Bulbule Date: Fri, 18 Sep 2026 20:30:49 +0530 Subject: [PATCH 2/7] fix(firestore): export the session and memory services from the package The integration package's __init__ exported nothing, so `from google.adk.integrations.firestore import FirestoreSessionService` raised ImportError and only the full module path worked. It was the only package under integrations/ without exports. Export both services lazily, as the model_armor integration does, so importing the package still does not require google-cloud-firestore. Also make the module docstring the first statement so it is the real module docstring. Fixes #7192 --- .../adk/integrations/firestore/__init__.py | 37 ++++++++++++++++++- .../test_firestore_memory_service.py | 7 ++++ .../test_firestore_session_service.py | 9 +++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/google/adk/integrations/firestore/__init__.py b/src/google/adk/integrations/firestore/__init__.py index 7c76d28c93d..122121eae2c 100644 --- a/src/google/adk/integrations/firestore/__init__.py +++ b/src/google/adk/integrations/firestore/__init__.py @@ -12,6 +12,41 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Firestore integrations for ADK. + +This module provides session and memory services backed by Google Cloud +Firestore. They require the optional ``google-cloud-firestore`` package. +""" + from __future__ import annotations -"""Firestore integrations for ADK.""" +import typing + +if typing.TYPE_CHECKING: + from .firestore_memory_service import FirestoreMemoryService + from .firestore_session_service import FirestoreSessionService + +# Map attribute names to relative module paths. +_lazy_imports = { + "FirestoreMemoryService": ".firestore_memory_service", + "FirestoreSessionService": ".firestore_session_service", +} + +__all__ = [ + "FirestoreMemoryService", + "FirestoreSessionService", +] + + +def __getattr__(name: str) -> typing.Any: + if name in _lazy_imports: + import importlib + + module_path = _lazy_imports[name] + module = importlib.import_module(module_path, __name__) + return getattr(module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return list(_lazy_imports.keys()) diff --git a/tests/unittests/integrations/firestore/test_firestore_memory_service.py b/tests/unittests/integrations/firestore/test_firestore_memory_service.py index afa7f75cacf..74719ef134d 100644 --- a/tests/unittests/integrations/firestore/test_firestore_memory_service.py +++ b/tests/unittests/integrations/firestore/test_firestore_memory_service.py @@ -39,6 +39,13 @@ def mock_firestore_client(): return client +def test_memory_service_is_importable_from_package(): + """FirestoreMemoryService can be imported from the integration package.""" + from google.adk.integrations import firestore as firestore_integration + + assert firestore_integration.FirestoreMemoryService is FirestoreMemoryService + + def test_extract_keywords(mock_firestore_client): service = FirestoreMemoryService(client=mock_firestore_client) text = "The quick brown fox jumps over the lazy dog." diff --git a/tests/unittests/integrations/firestore/test_firestore_session_service.py b/tests/unittests/integrations/firestore/test_firestore_session_service.py index aeadd5e11af..a92f8ddc6bd 100644 --- a/tests/unittests/integrations/firestore/test_firestore_session_service.py +++ b/tests/unittests/integrations/firestore/test_firestore_session_service.py @@ -82,6 +82,15 @@ def mock_firestore_client(): return client +def test_session_service_is_importable_from_package(): + """FirestoreSessionService can be imported from the integration package.""" + from google.adk.integrations import firestore as firestore_integration + + assert ( + firestore_integration.FirestoreSessionService is FirestoreSessionService + ) + + @pytest.mark.asyncio async def test_create_session(mock_firestore_client): From 90c96fbe35068877438ab98ef7665ef419e17997 Mon Sep 17 00:00:00 2001 From: Vishal Bulbule Date: Fri, 18 Sep 2026 20:31:47 +0530 Subject: [PATCH 3/7] fix(firestore): pass session query filters by keyword list_sessions and get_session with after_timestamp called where("field", op, value), which makes google-cloud-firestore emit "UserWarning: Detected filter using positional arguments" on every call. Use where(filter=FieldFilter(...)) as FirestoreMemoryService already does. Fixes #7192 --- .../firestore/firestore_session_service.py | 7 ++++--- .../test_firestore_session_service.py | 20 ++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/google/adk/integrations/firestore/firestore_session_service.py b/src/google/adk/integrations/firestore/firestore_session_service.py index e765e80b5ed..c08ef779e91 100644 --- a/src/google/adk/integrations/firestore/firestore_session_service.py +++ b/src/google/adk/integrations/firestore/firestore_session_service.py @@ -42,6 +42,7 @@ try: from google.cloud import firestore + from google.cloud.firestore_v1.base_query import FieldFilter except ImportError as e: raise ImportError( "FirestoreSessionService requires google-cloud-firestore. " @@ -325,7 +326,7 @@ async def get_session( after_dt = datetime.fromtimestamp( config.after_timestamp, tz=timezone.utc ) - query = query.where("timestamp", ">=", after_dt) + query = query.where(filter=FieldFilter("timestamp", ">=", after_dt)) if config.num_recent_events is not None: query = query.limit_to_last(config.num_recent_events) @@ -369,12 +370,12 @@ async def list_sessions( """Lists sessions from Firestore.""" if user_id: query = self._get_sessions_ref(app_name, user_id).where( - "appName", "==", app_name + filter=FieldFilter("appName", "==", app_name) ) docs = await query.get() else: query = self.client.collection_group(self.sessions_collection).where( - "appName", "==", app_name + filter=FieldFilter("appName", "==", app_name) ) docs = await query.get() diff --git a/tests/unittests/integrations/firestore/test_firestore_session_service.py b/tests/unittests/integrations/firestore/test_firestore_session_service.py index a92f8ddc6bd..e39124b9f8a 100644 --- a/tests/unittests/integrations/firestore/test_firestore_session_service.py +++ b/tests/unittests/integrations/firestore/test_firestore_session_service.py @@ -35,6 +35,12 @@ import pytest +def _where_filter(where_mock): + """Returns (field, op, value) of the FieldFilter passed as `filter=`.""" + field_filter = where_mock.call_args.kwargs["filter"] + return field_filter.field_path, field_filter.op_string, field_filter.value + + @pytest.fixture def mock_firestore_client(): client = mock.MagicMock() @@ -854,9 +860,9 @@ async def mock_get_all(refs): assert session.last_update_time == 1234567890.0 mock_firestore_client.collection_group.assert_called_once_with("sessions") - mock_firestore_client.collection_group.return_value.where.assert_called_once_with( - "appName", "==", app_name - ) + where = mock_firestore_client.collection_group.return_value.where + where.assert_called_once() + assert _where_filter(where) == ("appName", "==", app_name) @pytest.mark.asyncio @@ -918,9 +924,9 @@ async def mock_get_all(refs): assert response.sessions[0].app_name == app_name mock_firestore_client.collection_group.assert_called_once_with("sessions") - mock_firestore_client.collection_group.return_value.where.assert_called_once_with( - "appName", "==", app_name - ) + where = mock_firestore_client.collection_group.return_value.where + where.assert_called_once() + assert _where_filter(where) == ("appName", "==", app_name) @pytest.mark.asyncio @@ -1093,7 +1099,7 @@ async def test_get_session_after_timestamp_cursor_is_utc_aware( ) events_collection_ref.where.assert_called_once() - field, operator, cursor = events_collection_ref.where.call_args.args + field, operator, cursor = _where_filter(events_collection_ref.where) assert (field, operator) == ("timestamp", ">=") assert cursor.utcoffset() == timedelta(0), f"cursor is not UTC: {cursor!r}" assert _wire_epoch(cursor) == after_timestamp From 017fdb6d8c2c72f0350e0bf763b42b02d768c87d Mon Sep 17 00:00:00 2001 From: Vishal Bulbule Date: Fri, 18 Sep 2026 20:32:33 +0530 Subject: [PATCH 4/7] fix(firestore): store each event's own timestamp append_event stored the event document's timestamp as firestore.SERVER_TIMESTAMP. get_session orders events and applies GetSessionConfig.after_timestamp on that field, so both followed the write time instead of Event.timestamp and returned the wrong events for a cursor. Store Event.timestamp as an aware UTC datetime instead. Existing documents keep their server timestamps, which are the same type and close to the event time, so ordering across old and new events is unaffected. Fixes #7192 --- .../firestore/firestore_session_service.py | 5 +- .../test_firestore_session_service.py | 48 +++++++++++++++---- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/google/adk/integrations/firestore/firestore_session_service.py b/src/google/adk/integrations/firestore/firestore_session_service.py index c08ef779e91..04cda61c02a 100644 --- a/src/google/adk/integrations/firestore/firestore_session_service.py +++ b/src/google/adk/integrations/firestore/firestore_session_service.py @@ -590,7 +590,10 @@ async def _append_txn(transaction: firestore.AsyncTransaction) -> int: event_ref, { "event_data": event_data, - "timestamp": firestore.SERVER_TIMESTAMP, + # Event time, not write time: after_timestamp filters on it. + "timestamp": datetime.fromtimestamp( + event.timestamp, tz=timezone.utc + ), "appName": session.app_name, "userId": session.user_id, }, diff --git a/tests/unittests/integrations/firestore/test_firestore_session_service.py b/tests/unittests/integrations/firestore/test_firestore_session_service.py index e39124b9f8a..8cea7e2bca6 100644 --- a/tests/unittests/integrations/firestore/test_firestore_session_service.py +++ b/tests/unittests/integrations/firestore/test_firestore_session_service.py @@ -41,6 +41,14 @@ def _where_filter(where_mock): return field_filter.field_path, field_filter.op_string, field_filter.value +def _stored_snapshot(data): + """Returns a snapshot of an existing document holding `data`.""" + snapshot = mock.MagicMock() + snapshot.exists = True + snapshot.to_dict.return_value = data + return snapshot + + @pytest.fixture def mock_firestore_client(): client = mock.MagicMock() @@ -289,6 +297,37 @@ async def test_append_event(mock_firestore_client): assert session.last_update_time == event.timestamp +@pytest.mark.asyncio +async def test_append_event_stores_the_event_timestamp(mock_firestore_client): + """The stored event time is Event.timestamp, not the Firestore write time.""" + service = FirestoreSessionService(client=mock_firestore_client) + session = Session(id="test_session", app_name="test_app", user_id="test_user") + event = Event( + invocation_id="test_inv", author="user", timestamp=1700000000.123456 + ) + + root_coll = mock_firestore_client.collection.return_value + user_ref = ( + root_coll.document.return_value.collection.return_value.document.return_value + ) + session_doc_ref = user_ref.collection.return_value.document.return_value + session_doc_ref.get = mock.AsyncMock( + return_value=_stored_snapshot({"revision": 0}) + ) + event_ref = session_doc_ref.collection.return_value.document.return_value + + with mock.patch("google.cloud.firestore.async_transactional", lambda x: x): + await service.append_event(session, event) + + transaction = mock_firestore_client.transaction.return_value + writes = { + call.args[0]: call.args[1] for call in transaction.set.call_args_list + } + assert writes[event_ref]["timestamp"] == datetime.fromtimestamp( + 1700000000.123456, tz=timezone.utc + ) + + @pytest.mark.asyncio async def test_append_event_session_not_found(mock_firestore_client): service = FirestoreSessionService(client=mock_firestore_client) @@ -508,13 +547,6 @@ async def test_create_session_keeps_app_and_user_state_native( assert isinstance(persisted_state["session_key"], str) -def _stored_snapshot(data): - snapshot = mock.MagicMock() - snapshot.exists = True - snapshot.to_dict.return_value = data - return snapshot - - @pytest.mark.asyncio async def test_append_event_replaces_nested_app_and_user_state( mock_firestore_client, @@ -1065,7 +1097,7 @@ async def test_get_session_after_timestamp_cursor_is_utc_aware( ): """The after_timestamp cursor must be an aware UTC datetime. - Events are written with an aware UTC server timestamp, so a naive local + Events are written with an aware UTC timestamp, so a naive local cursor is compared against them shifted by the host's UTC offset: it replays events west of UTC and silently drops them east of it. """ From 1c557beed17511b44c07bf3d127c904b2c714ba6 Mon Sep 17 00:00:00 2001 From: Vishal Bulbule Date: Fri, 18 Sep 2026 20:33:08 +0530 Subject: [PATCH 5/7] feat(firestore): support get_user_state FirestoreSessionService inherited the base get_user_state, which raises NotImplementedError, while the in-memory, database, SQLite and Redis session services return the user's state without needing a session. Read the user_states document the service already maintains and return an empty dict when the user has no stored state. Fixes #7192 --- .../firestore/firestore_session_service.py | 15 ++++++++++ .../test_firestore_session_service.py | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/google/adk/integrations/firestore/firestore_session_service.py b/src/google/adk/integrations/firestore/firestore_session_service.py index 04cda61c02a..a4e49e4726f 100644 --- a/src/google/adk/integrations/firestore/firestore_session_service.py +++ b/src/google/adk/integrations/firestore/firestore_session_service.py @@ -443,6 +443,21 @@ def _iter_sessions_data() -> Iterator[dict[str, Any]]: sessions.sort(key=lambda s: (s.last_update_time, s.user_id, s.id)) return ListSessionsResponse(sessions=sessions) + async def get_user_state( + self, *, app_name: str, user_id: str + ) -> dict[str, Any]: + """Gets the user-scoped state from Firestore.""" + user_ref = ( + self.client.collection(self.user_state_collection) + .document(app_name) + .collection("users") + .document(user_id) + ) + user_doc = await user_ref.get() + if not user_doc.exists: + return {} + return user_doc.to_dict() or {} + async def delete_session( self, *, app_name: str, user_id: str, session_id: str ) -> None: diff --git a/tests/unittests/integrations/firestore/test_firestore_session_service.py b/tests/unittests/integrations/firestore/test_firestore_session_service.py index 8cea7e2bca6..ec90d3c3809 100644 --- a/tests/unittests/integrations/firestore/test_firestore_session_service.py +++ b/tests/unittests/integrations/firestore/test_firestore_session_service.py @@ -961,6 +961,36 @@ async def mock_get_all(refs): assert _where_filter(where) == ("appName", "==", app_name) +@pytest.mark.asyncio +async def test_get_user_state_returns_stored_user_state(mock_firestore_client): + """get_user_state returns the user_states document for the app and user.""" + service = FirestoreSessionService(client=mock_firestore_client) + root_coll = mock_firestore_client.collection.return_value + users_coll = root_coll.document.return_value.collection.return_value + users_coll.document.return_value.get = mock.AsyncMock( + return_value=_stored_snapshot({"theme": "dark"}) + ) + + state = await service.get_user_state(app_name="test_app", user_id="alice") + + assert state == {"theme": "dark"} + mock_firestore_client.collection.assert_called_with("user_states") + root_coll.document.assert_called_with("test_app") + users_coll.document.assert_called_with("alice") + + +@pytest.mark.asyncio +async def test_get_user_state_is_empty_when_nothing_is_stored( + mock_firestore_client, +): + """get_user_state returns an empty dict for a user without stored state.""" + service = FirestoreSessionService(client=mock_firestore_client) + + state = await service.get_user_state(app_name="test_app", user_id="alice") + + assert state == {} + + @pytest.mark.asyncio async def test_create_session_already_exists(mock_firestore_client): service = FirestoreSessionService(client=mock_firestore_client) From b230d67ee0b0e83f7eb77da86fb061c0ae7a5e2f Mon Sep 17 00:00:00 2001 From: Vishal Bulbule Date: Fri, 18 Sep 2026 20:34:33 +0530 Subject: [PATCH 6/7] fix(firestore): stop duplicating memories when a session is re-added add_session_to_memory wrote every event to a new auto-ID document, so adding the same session again, for example after each turn, stored every memory again. search_memory hid this by de-duplicating results, but the collection kept growing. Derive each document ID from the app, user, session and event IDs so a re-added event overwrites its entry, matching InMemoryMemoryService, and record the session ID on each entry. Fixes #7192 --- .../firestore/firestore_memory_service.py | 47 ++++++++++- .../test_firestore_memory_service.py | 84 +++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/google/adk/integrations/firestore/firestore_memory_service.py b/src/google/adk/integrations/firestore/firestore_memory_service.py index 286aa769b2f..fb93c0e3251 100644 --- a/src/google/adk/integrations/firestore/firestore_memory_service.py +++ b/src/google/adk/integrations/firestore/firestore_memory_service.py @@ -15,6 +15,8 @@ from __future__ import annotations import asyncio +from collections.abc import Sequence +import hashlib import logging import re from typing import Optional @@ -32,6 +34,7 @@ if TYPE_CHECKING: from google.cloud import firestore + from ...events.event import Event from ...sessions.session import Session logger = logging.getLogger("google_adk." + __name__) @@ -40,6 +43,18 @@ DEFAULT_MEMORIES_COLLECTION = "memories" +def _memory_doc_id( + *, app_name: str, user_id: str, session_id: Optional[str], event_id: str +) -> str: + """Returns a stable memory document ID for an event. + + Hashed because app names and user IDs may contain characters that are not + allowed in document IDs, such as "/". + """ + key = "\x00".join((app_name, user_id, session_id or "", event_id)) + return hashlib.sha256(key.encode("utf-8")).hexdigest() + + class FirestoreMemoryService(BaseMemoryService): # type: ignore[misc] """Memory service that uses Google Cloud Firestore as the backend. @@ -82,10 +97,26 @@ def __init__( @override async def add_session_to_memory(self, session: Session) -> None: """Extracts keywords from session events and stores them in the memories collection.""" + await self._write_memories( + app_name=session.app_name, + user_id=session.user_id, + session_id=session.id, + events=session.events, + ) + + async def _write_memories( + self, + *, + app_name: str, + user_id: str, + session_id: Optional[str], + events: Sequence[Event], + ) -> None: + """Writes one memory document per event that has text keywords.""" batch = self.client.batch() count = 0 - for event in session.events: + for event in events: if not event.content or not event.content.parts: continue @@ -97,12 +128,20 @@ async def add_session_to_memory(self, session: Session) -> None: if not keywords: continue - doc_ref = self.client.collection(self.memories_collection).document() + doc_ref = self.client.collection(self.memories_collection).document( + _memory_doc_id( + app_name=app_name, + user_id=user_id, + session_id=session_id, + event_id=event.id, + ) + ) batch.set( doc_ref, { - "appName": session.app_name, - "userId": session.user_id, + "appName": app_name, + "userId": user_id, + "sessionId": session_id, "keywords": list(keywords), "author": event.author, "content": event.content.model_dump( diff --git a/tests/unittests/integrations/firestore/test_firestore_memory_service.py b/tests/unittests/integrations/firestore/test_firestore_memory_service.py index 74719ef134d..f10719ac4d8 100644 --- a/tests/unittests/integrations/firestore/test_firestore_memory_service.py +++ b/tests/unittests/integrations/firestore/test_firestore_memory_service.py @@ -298,6 +298,90 @@ async def test_add_session_to_memory(mock_firestore_client): assert data["timestamp"] == 1234567890.0 +def _text_event(text, event_id): + return Event( + id=event_id, + invocation_id="test_inv", + author="user", + content=types.Content(parts=[types.Part.from_text(text=text)]), + ) + + +def _written_doc_ids(client): + memories = client.collection.return_value + return [call.args[0] for call in memories.document.call_args_list] + + +@pytest.mark.asyncio +async def test_add_session_to_memory_twice_overwrites_instead_of_duplicating( + mock_firestore_client, +): + """Re-adding a session writes to the same memory documents as before.""" + from google.adk.sessions.session import Session + + service = FirestoreMemoryService(client=mock_firestore_client) + mock_firestore_client.batch.return_value.commit = mock.AsyncMock() + session = Session( + id="s1", + app_name="test_app", + user_id="test_user", + events=[ + _text_event("quick brown fox", "e1"), + _text_event("lazy dog", "e2"), + ], + ) + + await service.add_session_to_memory(session) + first_ids = _written_doc_ids(mock_firestore_client) + await service.add_session_to_memory(session) + all_ids = _written_doc_ids(mock_firestore_client) + + assert len(set(first_ids)) == 2 + assert all_ids == first_ids + first_ids + + +@pytest.mark.asyncio +async def test_add_session_to_memory_records_the_session_id( + mock_firestore_client, +): + """Each memory document stores the ID of the session it came from.""" + from google.adk.sessions.session import Session + + service = FirestoreMemoryService(client=mock_firestore_client) + batch = mock_firestore_client.batch.return_value + batch.commit = mock.AsyncMock() + session = Session( + id="s1", + app_name="test_app", + user_id="test_user", + events=[_text_event("quick brown fox", "e1")], + ) + + await service.add_session_to_memory(session) + + assert batch.set.call_args.args[1]["sessionId"] == "s1" + + +@pytest.mark.asyncio +async def test_same_session_id_for_different_users_does_not_collide( + mock_firestore_client, +): + """Users sharing a session ID get separate memory documents.""" + from google.adk.sessions.session import Session + + service = FirestoreMemoryService(client=mock_firestore_client) + mock_firestore_client.batch.return_value.commit = mock.AsyncMock() + event = _text_event("quick brown fox", "e1") + + for user_id in ("alice", "bob"): + await service.add_session_to_memory( + Session(id="s1", app_name="test_app", user_id=user_id, events=[event]) + ) + + alice_id, bob_id = _written_doc_ids(mock_firestore_client) + assert alice_id != bob_id + + @pytest.mark.asyncio async def test_add_session_to_memory_no_events(mock_firestore_client): service = FirestoreMemoryService(client=mock_firestore_client) From 1eca87297dbfc4ea995711d4c613cb4389786dc2 Mon Sep 17 00:00:00 2001 From: Vishal Bulbule Date: Fri, 18 Sep 2026 20:35:05 +0530 Subject: [PATCH 7/7] feat(firestore): support add_events_to_memory FirestoreMemoryService only supported add_session_to_memory, so callers that save just the latest turn got NotImplementedError, while InMemoryMemoryService accepts event deltas. Implement it with the same writer, so an event added as a delta and later with its full session under the same session ID keeps a single memory entry. Fixes #7192 --- .../firestore/firestore_memory_service.py | 23 +++++++ .../test_firestore_memory_service.py | 62 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/google/adk/integrations/firestore/firestore_memory_service.py b/src/google/adk/integrations/firestore/firestore_memory_service.py index fb93c0e3251..c330c43e62b 100644 --- a/src/google/adk/integrations/firestore/firestore_memory_service.py +++ b/src/google/adk/integrations/firestore/firestore_memory_service.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from collections.abc import Sequence import hashlib import logging @@ -104,6 +105,28 @@ async def add_session_to_memory(self, session: Session) -> None: events=session.events, ) + @override + async def add_events_to_memory( + self, + *, + app_name: str, + user_id: str, + events: Sequence[Event], + session_id: Optional[str] = None, + custom_metadata: Optional[Mapping[str, object]] = None, + ) -> None: + """Adds events, such as the latest turn, to the memories collection. + + Re-adding an event with the same session ID overwrites its entry. + """ + _ = custom_metadata + await self._write_memories( + app_name=app_name, + user_id=user_id, + session_id=session_id, + events=events, + ) + async def _write_memories( self, *, diff --git a/tests/unittests/integrations/firestore/test_firestore_memory_service.py b/tests/unittests/integrations/firestore/test_firestore_memory_service.py index f10719ac4d8..acebc1816fc 100644 --- a/tests/unittests/integrations/firestore/test_firestore_memory_service.py +++ b/tests/unittests/integrations/firestore/test_firestore_memory_service.py @@ -382,6 +382,68 @@ async def test_same_session_id_for_different_users_does_not_collide( assert alice_id != bob_id +@pytest.mark.asyncio +async def test_add_events_to_memory_writes_the_given_events( + mock_firestore_client, +): + """add_events_to_memory stores each given event under the app and user.""" + service = FirestoreMemoryService(client=mock_firestore_client) + batch = mock_firestore_client.batch.return_value + batch.commit = mock.AsyncMock() + + await service.add_events_to_memory( + app_name="test_app", + user_id="test_user", + session_id="s1", + events=[_text_event("quick brown fox", "e1")], + ) + + data = batch.set.call_args.args[1] + assert data["appName"] == "test_app" + assert data["userId"] == "test_user" + assert data["sessionId"] == "s1" + assert "quick" in data["keywords"] + + +@pytest.mark.asyncio +async def test_add_events_to_memory_without_session_id(mock_firestore_client): + """Events added without a session ID are stored with no session ID.""" + service = FirestoreMemoryService(client=mock_firestore_client) + batch = mock_firestore_client.batch.return_value + batch.commit = mock.AsyncMock() + + await service.add_events_to_memory( + app_name="test_app", + user_id="test_user", + events=[_text_event("quick brown fox", "e1")], + ) + + assert batch.set.call_args.args[1]["sessionId"] is None + batch.commit.assert_called_once() + + +@pytest.mark.asyncio +async def test_add_events_then_session_does_not_duplicate( + mock_firestore_client, +): + """An event added as a delta and later with its session keeps one entry.""" + from google.adk.sessions.session import Session + + service = FirestoreMemoryService(client=mock_firestore_client) + mock_firestore_client.batch.return_value.commit = mock.AsyncMock() + event = _text_event("quick brown fox", "e1") + + await service.add_events_to_memory( + app_name="test_app", user_id="test_user", session_id="s1", events=[event] + ) + await service.add_session_to_memory( + Session(id="s1", app_name="test_app", user_id="test_user", events=[event]) + ) + + delta_id, session_id = _written_doc_ids(mock_firestore_client) + assert delta_id == session_id + + @pytest.mark.asyncio async def test_add_session_to_memory_no_events(mock_firestore_client): service = FirestoreMemoryService(client=mock_firestore_client)