Fix: align the listSessions() state contract across all session services (stacked on #376) - #477
Open
AmaadMartin wants to merge 3 commits into
Conversation
added 3 commits
August 1, 2026 12:44
…e lists
InMemorySessionService.listSessions() hardcoded state: {} on every returned
session, while DatabaseSessionService merged app/user/session state and
VertexAiSessionService returned the remote session state. Swapping one service
for another therefore silently changed what listSessions() returned, and the
in-memory service was the outlier against adk-python, whose in-memory
_list_sessions_impl calls _merge_state per session.
Merge the stored app state and the state of each session's own owner into the
listed session, reusing the existing mergeStates helper (which cloneDeeps the
session state, so callers cannot mutate the store). Keying user state off
session.userId rather than the request userId keeps the all-users listing
correct, matching how DatabaseSessionService builds its per-owner user state
map.
Also correct the ListSessionsResponse JSDoc, which claimed neither events nor
state are populated. Events are indeed always empty, but state is populated by
every implementation in both languages.
… services Nine new cases in the in-memory suite cover the merged app/user/session view, equality with getSession, app state on every session of the app, user-state isolation between users, per-owner user state when userId is omitted, events staying empty, caller ownership of the returned state object, the paginated path, and the no-app-no-user-state case. One case each on the database and Vertex suites pins the behaviour those services already had, so the shared contract cannot regress on one service without the others noticing.
… clause The parenthesised satisfies FilterQuery<StorageSession> & FilterQuery<StorageUserState> asserted a type the em.count and em.find call sites already check, and it caught nothing they do not: tsc reports the same error count with and without it, and a typo'd key is rejected by neither form because MikroORM's FilterQuery is permissive. The comment above it restated what the shared binding and the comment at the user-state query already say. Collapse to a single line. No behaviour change.
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Link to an existing issue (if applicable):
N/A
Or, if no issue exists, describe the change:
Problem:
ListSessionsResponse.sessions[].statemeans three different things depending on whichBaseSessionServiceyou instantiate, so a caller cannot swap one implementation for another without silently changing whatlistSessions()returns:statereturned bylistSessions()InMemorySessionService{}— the.map()hardcodedstate: {}DatabaseSessionServicemergeStates(appState, uState, ss.state)per sessionVertexAiSessionServicesessionState({}when absent)This is the exact swap the dev server performs based on the session URI (
core/src/sessions/registry.ts), soGET /apps/:appName/users/:userId/sessionsreturned state-bearing sessions on asqlite://backend and stateless stubs on the in-memory one.It also diverges from adk-python, where the in-memory service behaves like the adk-js database service:
src/google/adk/sessions/in_memory_session_service.py_list_sessions_impl()clearseventsand then callsself._merge_state(app_name, uid, copied_session)for every returned session — keyed on each session's own owneruid, not on the requested user.database_session_service.pyandvertex_ai_session_service.pydo the same, andtests/unittests/sessions/test_session_service.pyasserts merged state across the in-memory / database / sqlite parameterisation. Merged-state-on-list is an intentional, tested contract in Python.The JSDoc on
ListSessionsResponseclaimed "The events and states are not set within each Session object." The "events" half is true everywhere; the "states" half was false for two of the three JS implementations and for every Python one. The doc, not the database service, was the thing that was wrong.Solution: adopt one contract and make all three implementations plus the doc agree on it:
Per the cross-language rule that parity wins for anything observable across the boundary,
sessions[].stateis observable API surface, so adk-python decides it. That makes the adk-js in-memory service the outlier:InMemorySessionService.listSessions()— now returns merged state. One line, reusing the existingmergeStates()helper thatcreateSession()andgetSession()in the same file already call. This is the only production behaviour change.DatabaseSessionService.listSessions()— unchanged, already merged.VertexAiSessionService.listSessions()— unchanged. Agent Engine has no separate app/user state stores in either language, so the remotesessionStateis the merged view.eventsstays[]in all three; nothing new is loaded.mergeStates()cloneDeeps the session state it is handed, so the returned state is caller-owned — mutating it cannot reach the store. No extracloneDeepwas added, which keeps the isolation depth identical to the siblinggetSession()/createSession()paths.Where this deviates from the plan, and why. The design called for
mergeStates(this.appState[appName], this.userState[appName]?.[userId], session.state), using the request'suserId. This PR keys user state offsession.userIdinstead. The two are identical when auserIdis supplied (sessions are bucketed by user), but this PR is stacked on #376, which makesuserIdoptional; with the request'suserIdthe all-users listing would merge no user state at all, while the database service on the same base builds a per-owneruserStateMapkeyed onss.userId.session.userIdis also literally what adk-python does (self._merge_state(app_name, uid, copied_session)inside theuser_id is Noneloop). A new test, "merges each session owner's user state when userId is omitted", pins it and mirrors the database service's test of the same name.Behaviour change:
InMemorySessionService.listSessions()starts returning populatedstatewhere it previously returned{}. Non-breaking at the type level — no signature or type changed, so nothing fails to compile, and no export list moved. Risk is low: no adk-js caller reads.stateoff a listed session (dev/src/server/adk_api_server.ts:425serialises the response straight to JSON,dev/src/server/adk_api_client.ts:106-135only unwraps.sessions), so this adds fields to a JSON payload rather than removing any, and brings the in-memory dev-server response in line with what the same endpoint already returned for the database backend.Collision check (required before implementation):
gh pr list --repo AmaadMartin/adk-js --state open --limit 300returned 300 open PRs. Cross-referencing every fork branch againstcore/src/sessions/in_memory_session_service.tsandcore/src/sessions/base_session_service.tsfound exactly two open PRs touching either file:fix/list-sessions-optional-user-id— overlaps. It rewrites the sameInMemorySessionService.listSessions()body to makeuserIdoptional, but keepsstate: {}; it does not implement the state contract. Per the overlap rule this PR is stacked on that branch rather than branched frommain, so the two do not fight over the same.map(), and the merge here is written to be correct under its all-users listing.feat/firestore-session-service— not a collision. It only appends a newsplitStateDelta()helper tobase_session_service.ts; it does not touchListSessionsResponseor its JSDoc.One cleanup on the stacked base. A complexity review of the full diff-against-
mainflagged thewherebinding #376 added atdatabase_session_service.ts:265-269— a parenthesisedsatisfies FilterQuery<StorageSession> & FilterQuery<StorageUserState>plus a two-line comment — as ceremony. Verified:npx tsc --noEmitreports the same 276 pre-existing errors with and without it, and a deliberately typo'd key (userIdd) is caught by neither form, because MikroORM'sFilterQueryis permissive enough that the assertion adds no checking theem.count/em.findcall sites do not already perform. Collapsed to one line (const where = userId === undefined ? {appName} : {appName, userId};); all 30 database tests still pass. Net -4 lines. Flagging it because it is the one file in this PR's delta that the state contract did not require.Out of scope (unchanged here, deliberately):
InMemorySessionService.createSession()still does not routeapp:/user:-prefixed initial state into the app/user stores the way the database service does — an independent defect, which is why the tests below establish app/user state throughappendEvent(). The identical stale docstring on adk-python'sListSessionsResponselives in a different repo. WideningListSessionsRequest.userIdis #376's job, not this PR's.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Eleven new
it()cases, all purely additive — this PR's own two commits edit, rename, skip or delete no existing test (no adk-js test asserted empty state fromlistSessions, so nothing needed rewriting).One disclosure about the stacked base, since a reviewer diffing against
mainsees it inside this PR: stacked commit9ba55cac(#376) did edit an existing test. It dropped the trailinglistAllblock fromshould filter sessions by userId in listSessions. That block re-issued the identical request as thelistU1block two lines above it ({appName: 'app1', userId: 'u1'}) and asserted onlysessions.length === 1— a verbatim duplicate with a strictly weaker assertion, and mis-parameterised besides: the namelistAllsays it meant to exercise all-users listing, but it passeduserId: 'u1'. #376 replaced it withlists sessions for all users in an app when userId is omitted, which does what the old name claimed. No coverage was lost. That edit belongs to #376's review, not this one.core/test/sessions/in_memory_session_service_test.ts(nine, inside the existingdescribe('listSessions')):returns merged app, user and session state for each listed session— exacttoEqualon{sessionKey, app:appKey, user:userKey}.returns the same state from listSessions as getSession— the swap-safety invariant.app state is visible on every listed session of the app.does not leak another user's user: state into listed sessions—u2must not seeu1'suser:key.merges each session owner's user state when userId is omitted— per-owner merge on the all-users path (the Fix: make ListSessionsRequest.userId optional (adk-python parity) #376 interaction).listed sessions carry state but never events— guards against over-correcting into loading events.mutating returned state does not affect stored session state— caller ownership.merged state is returned on the paginated path— thelimit !== undefinedbranch, not just the unpaginated one.returns session state when no app or user state exists— the undefined internal maps handled bymergeStates' parameter defaults.core/test/sessions/database_session_service_test.ts:listSessions returns merged app, user and session state.core/test/sessions/vertex_ai_session_service_test.ts:listSessions state matches getSession state for the same session.The last two pin behaviour those services already had, so the shared contract cannot regress on one service without the others noticing.
Proof every new test can fail. Each was run against mutated source; all mutations were reverted afterwards.
in_memory_session_service.ts:state: mergeStates(...)→state: {}(the pre-fix code)AssertionError: expected {} to deeply equal { sessionKey: 'sv', …(2) },expected undefined to be 'av',expected undefined to be 'A'in_memory_session_service.ts:state: mergeStates(...)→state: session.state(hand out the live reference)mutating returned state does not affect stored session state, fails:AssertionError: expected { sessionKey: 'mutated', …(1) } to deeply equal { sessionKey: 'sv' }database_session_service.ts:const merged = mergeStates(appState, uState, ss.state)→const merged = {}listSessions returns merged app, user and session statefails:AssertionError: expected {} to deeply equal { 'app:appKey': 'av', …(2) }vertex_ai_session_service.ts:state: sessionObj.sessionState→state: {}listSessions state matches getSession state for the same sessionfails:AssertionError: expected {} to deeply equal { sessionKey: 'sv', 'app:k': 'av' }Two mutations were needed on the in-memory service precisely because coverage alone would not have distinguished them:
state: {}leaves the isolation test green, andstate: session.stateleaves the merge tests green.Coverage.
core/src/sessions/in_memory_session_service.tsmeasures 97.18% statements / 95.38% branches / 100% functions. The new lines are fully covered; the only uncovered lines (248-250, 253-255) are pre-existingappendEvent()warning guards this PR does not touch.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Start the dev API server against the in-memory backend, create a session, append an event carrying
app:,user:and plain state deltas, thenGET /apps/<app>/users/<user>/sessionsand compare against the same probe run with asqlite://session service. Before this change the in-memory response carried"state": {}; after it, both backends return the same merged state over real HTTP:(Key order differs only because the in-memory session's own state already holds the literal prefixed keys written by
updateSessionState, while the database service routes them into its app/user tables; the merged contents are identical.)Local validation on the pushed commit. This PR is stacked on #376, so its base is not
mainand thepull_request: branches: [main]workflows do not trigger — CI is absent, not green. Everything was therefore run locally on the exact pushed commit:npx vitest run --project unit:core core/test/sessions/in_memory_session_service_test.ts core/test/sessions/database_session_service_test.ts core/test/sessions/vertex_ai_session_service_test.ts→ 3 files, 130 tests passednpx vitest run --project unit:dev dev/test/server/adk_api_server_test.ts→ 51 passed;dev/test/server/adk_api_client_test.ts→ 20 passed (the only production consumers oflistSessions)npm run build→ cleannpm run lint→ cleannpm run format:check→ All matched files use Prettier code style!npm run docs:check(typedoc--treatWarningsAsErrors) → clean, so the reworded JSDoc resolvesnpx tsc --noEmit→ 276 errors before and after this change, i.e. no new type errors. (The tree has a pre-existing type-check backlog; several open PRs address it.)Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.