Skip to content

FirestoreSessionService ignores app: / user: state prefixes, so app and user state are never shared across sessions聽#1645

Description

@innoprej

馃敶 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:

  1. appendEvent only routes keys starting with _app_ / _user_ to the app-state / user-state collections. Keys written through the normal ADK API (app:theme, user:name) fall through to the per-session state field, so they are only visible in that one session.
  2. The app-state and user-state collections are write-only. getSession, createSession and listSessions never read them, so even a value stored under _app_ / _user_ never reaches another session.
  3. temp: keys are persisted in the session document. BaseSessionService.appendEvent skips them.
  4. Removal checks value == null, but ADK marks removals with the State.REMOVED sentinel, so a removed key is kept and the sentinel object is written to the session's state map instead of the key being deleted.

Code references (main @ 189d463):

  • contrib/firestore-session-service/.../FirestoreSessionService.java appendEvent (prefix split around L657-L689, removal check L666)
  • getSession builds the session only from the session document's state field (around L262-L272)
  • Compare core/.../InMemorySessionService.java appendEvent (L316-L338) and mergeWithGlobalState (L398)

Steps to Reproduce:

  1. Configure a runner with FirestoreSessionService.
  2. In session A, a tool or callback sets context.state().put("user:name", "Jae").
  3. 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

  1. self-assigned this
    on Oct 12, 2026
  2. added theissue type on Oct 12, 2026
  3. hemasekhar-p commented on Oct 12, 2026

    @hemasekhar-p
    Contributor

    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?

  4. added
    waiting on reporterWaiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.
    on Oct 12, 2026
  5. innoprej commented on Oct 12, 2026

    @innoprej
    ContributorAuthor

    Thanks for the quick fix, @hemasekhar-p!

    I checked out #1650 (1329546d) and ran FirestoreSessionServiceTest. The write side looks right to me: app:/user: keys now go to the app-state/user-state documents, temp: keys in the delta are skipped, and State.REMOVED deletes the key (FieldValue.delete() in the shared documents). Your four new or updated appendEvent tests pass on the PR and fail on main (189d463a).

    It doesn't resolve this issue yet, though, because nothing reads those documents back. getSession, createSession and listSessions never look at app-state/user-state (getSession builds the state from the session document only, listSessions returns an empty state), so the steps in the issue still end with user:name missing in session B. And with #1650 alone, an event that only sets app:/user: keys no longer updates the session document's state. So if a tool sets user:name and nothing later in that turn changes a session key, the next getSession of the same session (the runner's next turn) doesn't return it. On main that case worked, because the key fell through to the session state.

    What I ran, with mocked Firestore in the same test class (no emulator):

    Behaves like InMemorySessionService? main #1650
    Session B, a new session and listSessions see app:theme / user:name stored in the shared documents no no
    Session A, loaded again after a tool set user:name (no other state change in that turn), still has it yes no

    To fix the read side, getSession, createSession and listSessions would need to merge the two documents back with the prefixes, like InMemorySessionService.mergeWithGlobalState (here) or _merge_state in the Python Firestore service (here). appendEvent should probably also apply app:/user: delta values to the in-memory session.state(), as BaseSessionService.appendEvent does. #1650 now only sends them to the shared documents, so a value that arrives only through EventActions.stateDelta() is no longer visible later in the same invocation.

    One smaller gap the table doesn't cover: Context.state() writes through to session.state() (Context), and appendEvent still writes the whole map with update("state", session.state()) (here). So temp: keys (and app:/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-memory session.state (here), as suggested above, but filters these prefixes out before it writes the session document (here). InMemorySessionService keeps 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.

  6. hemasekhar-p commented on Oct 14, 2026

    @hemasekhar-p
    Contributor

    @innoprej, thank you for your feedback, i have addressed the comments and updated the PR. hope the updated PR will resolve complete issue?

  7. added
    waiting on reporterWaiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.
    and removed
    waiting on reporterWaiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.
    on Oct 14, 2026
  8. innoprej commented on Oct 14, 2026

    @innoprej
    ContributorAuthor

    Thanks for the update, @hemasekhar-p! I ran the mocked tests in FirestoreSessionServiceTest and FirestoreDatabaseRunnerTest against 6b460465 (no emulator). The read side works now: getSession, createSession and listSessions return the shared app:/user: values, a session reloaded after a turn that only set user: 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 session state field. The set(..., 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, if user:prefs was {lang, theme} before, the next read would still return theme, while InMemorySessionService replaces the value. The Python service writes these documents whole for this reason (here). A top-level mask avoids this: SetOptions.mergeFieldPaths with FieldPath.of(key) for each key gives [prefs] and still accepts FieldValue.delete() (mergeFields(String...) reads a key like settings.v2 as a nested path and fails), so the change looks small.

    Second, when an event changes a session key, appendEvent writes all of session.state(), which now includes the merged app:/user: values, to the session document (here). So a shared key deleted in another session can come back:

    1. Session A sets app:theme and user:name.
    2. Session B is created (it gets both values) and sets counter. B's session document now stores {app:theme, counter, user:name}.
    3. Session A removes both keys with State.REMOVED. The shared documents are now empty, and a new session sees neither key.
    4. Reloading session B still returns both old values.

    InMemorySessionService gives 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 the temp: case from my last comment, and the existing tests still passed. On its own, though, it also drops app:/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 to createSession, which Python splits out (here).

    The first point is Firestore-specific and only touches the two set calls, so could it go into #1650? The second matches InMemorySessionService, so I'm fine with #1650 closing #1645 and tracking it in a follow-up issue, which I can open with the mocked tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

waiting on reporterWaiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions