Repository navigation
FirestoreSessionService ignores app: / user: state prefixes, so app and user state are never shared across sessions聽#1645
Description
馃敶 Required Information
Describe the Bug:
FirestoreSessionService does not implement ADK's scoped state. ADK's State class defines app:, user: and temp: prefixes (State.APP_PREFIX, USER_PREFIX, TEMP_PREFIX), and InMemorySessionService stores app:/user: keys in shared app/user stores and merges them back into every session. FirestoreSessionService behaves differently:
appendEventonly routes keys starting with_app_/_user_to theapp-state/user-statecollections. Keys written through the normal ADK API (app:theme,user:name) fall through to the per-sessionstatefield, so they are only visible in that one session.- The
app-stateanduser-statecollections are write-only.getSession,createSessionandlistSessionsnever read them, so even a value stored under_app_/_user_never reaches another session. temp:keys are persisted in the session document.BaseSessionService.appendEventskips them.- Removal checks
value == null, but ADK marks removals with theState.REMOVEDsentinel, so a removed key is kept and the sentinel object is written to the session'sstatemap instead of the key being deleted.
Code references (main @ 189d463):
contrib/firestore-session-service/.../FirestoreSessionService.javaappendEvent(prefix split around L657-L689, removal check L666)getSessionbuilds the session only from the session document'sstatefield (around L262-L272)- Compare
core/.../InMemorySessionService.javaappendEvent(L316-L338) andmergeWithGlobalState(L398)
Steps to Reproduce:
- Configure a runner with
FirestoreSessionService. - In session A, a tool or callback sets
context.state().put("user:name", "Jae"). - Create session B for the same app and user, and read
user:name.
The same can be shown with the module's existing mocked tests (no Firestore needed): appending an event with state delta {"app:theme": "dark", "user:name": "Jae"} never calls set on the app-state / user-state documents, and the session document is updated with state = {app:theme=dark, user:name=Jae}. Appending {"temp:scratch": "x", "gone": State.REMOVED} writes both temp:scratch and the REMOVED sentinel into the session's state.
Expected Behavior:
Same semantics as InMemorySessionService (and the Python ADK database session services): app: keys shared by all sessions of the app, user: keys shared by all sessions of the app and user, merged back into Session.state() on getSession / createSession / listSessions, temp: keys not persisted, and State.REMOVED deleting the key.
Observed Behavior:
In session B, user:name is missing. App/user state behaves like session state. temp: values persist, and removed keys are not deleted.
Environment Details:
- ADK Library Version: main @ 189d463 (1.11.1-SNAPSHOT),
google-adk-firestore-session-service - OS: Linux
- TS Version: N/A
Model Information:
- N/A (session service bug, model independent)
馃煛 Optional Information
Regression: No. The _app_ / _user_ handling has been there since the Firestore service was added.
Additional Context:
A fix would (a) split on State.APP_PREFIX / State.USER_PREFIX, (b) merge the app-state / user-state documents back into the session state with the prefixes on read, (c) skip State.TEMP_PREFIX, and (d) treat State.REMOVED as a delete (FieldValue.delete() in the shared stores). Existing data stored under _app_ / _user_ keys (if anyone relied on that convention) may need a note in the changelog. I'm happy to send a PR if this direction looks right.
Activity
hemasekhar-p commentedon Oct 12, 2026
Hi @innoprej, thank you for reporting this issue. I have been able to reproduce this behavior using your inputs and have provided a fix in this PR. I hope this will resolves your problem?
innoprej commentedon Oct 12, 2026
Thanks for the quick fix, @hemasekhar-p!
I checked out #1650 (
1329546d) and ranFirestoreSessionServiceTest. The write side looks right to me:app:/user:keys now go to theapp-state/user-statedocuments,temp:keys in the delta are skipped, andState.REMOVEDdeletes the key (FieldValue.delete()in the shared documents). Your four new or updatedappendEventtests pass on the PR and fail onmain(189d463a).It doesn't resolve this issue yet, though, because nothing reads those documents back.
getSession,createSessionandlistSessionsnever look atapp-state/user-state(getSessionbuilds the state from the session document only,listSessionsreturns an empty state), so the steps in the issue still end withuser:namemissing in session B. And with #1650 alone, an event that only setsapp:/user:keys no longer updates the session document'sstate. So if a tool setsuser:nameand nothing later in that turn changes a session key, the nextgetSessionof the same session (the runner's next turn) doesn't return it. Onmainthat case worked, because the key fell through to the session state.What I ran, with mocked Firestore in the same test class (no emulator):
InMemorySessionService?mainlistSessionsseeapp:theme/user:namestored in the shared documentsuser:name(no other state change in that turn), still has itTo fix the read side,
getSession,createSessionandlistSessionswould need to merge the two documents back with the prefixes, likeInMemorySessionService.mergeWithGlobalState(here) or_merge_statein the Python Firestore service (here).appendEventshould probably also applyapp:/user:delta values to the in-memorysession.state(), asBaseSessionService.appendEventdoes. #1650 now only sends them to the shared documents, so a value that arrives only throughEventActions.stateDelta()is no longer visible later in the same invocation.One smaller gap the table doesn't cover:
Context.state()writes through tosession.state()(Context), andappendEventstill writes the whole map withupdate("state", session.state())(here). Sotemp:keys (andapp:/user:keys) set through the context state end up in the session document whenever that event or a later one in the same invocation changes a session key. The Python Firestore service keeps the two apart: it applies the delta to the in-memorysession.state(here), as suggested above, but filters these prefixes out before it writes the session document (here).InMemorySessionServicekeeps them too, since it stores the runner's session object, so this may be better handled separately.Would you like to add the read side to #1650, or should I send a follow-up PR after it's merged? Since #1650 says
Closes: #1645, could this issue stay open until the read side lands? I'm happy to share the mocked tests I used.hemasekhar-p commentedon Oct 14, 2026
@innoprej, thank you for your feedback, i have addressed the comments and updated the PR. hope the updated PR will resolve complete issue?
innoprej commentedon Oct 14, 2026
Thanks for the update, @hemasekhar-p! I ran the mocked tests in
FirestoreSessionServiceTestandFirestoreDatabaseRunnerTestagainst6b460465(no emulator). The read side works now:getSession,createSessionandlistSessionsreturn the sharedapp:/user:values, a session reloaded after a turn that only setuser:keys keeps them, and current shared values override older copies in the session document. The existing service and runner tests still pass (34 + 4), and the four points listed in #1645 look fixed to me.Two things remain. First, map values in the shared documents are merged rather than replaced; before #1650,
app:/user:values were written as part of the whole sessionstatefield. Theset(..., SetOptions.merge())calls (here) send a mask of nested paths: with the real Java client (nothing sent to a server),user:prefs = {lang: ko}gets the mask[prefs.lang], and fields outside the mask are left unchanged (update_mask). So, by that rule, ifuser:prefswas{lang, theme}before, the next read would still returntheme, whileInMemorySessionServicereplaces the value. The Python service writes these documents whole for this reason (here). A top-level mask avoids this:SetOptions.mergeFieldPathswithFieldPath.of(key)for each key gives[prefs]and still acceptsFieldValue.delete()(mergeFields(String...)reads a key likesettings.v2as a nested path and fails), so the change looks small.Second, when an event changes a session key,
appendEventwrites all ofsession.state(), which now includes the mergedapp:/user:values, to the session document (here). So a shared key deleted in another session can come back:app:themeanduser:name.counter. B's session document now stores{app:theme, counter, user:name}.State.REMOVED. The shared documents are now empty, and a new session sees neither key.InMemorySessionServicegives the same result here (even when B's event changes no state), because it stores the runner's session object with the merged values (here). Python filters these prefixes out before writing the session document (here). In a local copy, that filter fixed these steps and thetemp:case from my last comment, and the existing tests still passed. On its own, though, it also dropsapp:/user:values that exist only in a session document on that session's next state write: data saved by the old code, and initial state passed tocreateSession, which Python splits out (here).The first point is Firestore-specific and only touches the two
setcalls, so could it go into #1650? The second matchesInMemorySessionService, so I'm fine with #1650 closing #1645 and tracking it in a follow-up issue, which I can open with the mocked tests.