Fix: route app:/user: initial state to the scope stores in InMemorySessionService (stacked on #609) - #623
Open
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 4, 2026 00:12
InMemorySessionService.createSession pushed every non-temp: key of the supplied initial state into the per-session state map, so an `app:` or `user:` prefixed key never reached this.appState / this.userState. Two defects followed: a scoped key set at creation time was invisible to sibling sessions, and mergeStates then overwrote it from the scope stores, silently discarding a value that was meant to update an existing one. Split the initial state with extractStateDelta and write the app/user buckets to the scope stores before mergeStates re-applies the prefixes, matching DatabaseSessionService.createSession and adk-python's InMemorySessionService._create_session_impl. The scope-store write is now identical in createSession and appendEvent, so it moves into a private applyScopedDeltas().
Seven cases in the existing createSession block: app:/user: sharing and non-sharing, unprefixed keys staying session-local, temp: keys dropped rather than promoted, a later app: value overwriting an earlier one, and initial app: state coexisting with app state set by an event.
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:
InMemorySessionService.createSessiondoes not honour theapp:/user:state-scope prefixes. It runs the supplied initial state throughtrimTempState, which only stripstemp:keys, and stores everything elseverbatim in the per-session state map. The two scope stores (
appState,userState) are only ever written byappendEvent. Two defects follow fromthat single root cause:
createSession({state: {'app:config': 'dark-mode'}})traps the value in onesession.
mergeStatesclones the session state and then overwrites theapp:/user:entries from the authoritative scope stores, so ifapp:configalready exists the caller's new value is returned as the old one and dropped
— a silent write loss.
This makes state scoping depend on which backend is plugged in.
DatabaseSessionService.createSessionalready splits by prefix, and so doesadk-python(src/google/adk/sessions/in_memory_session_service.py,_create_session_impl→_session_util.extract_state_delta). The in-memoryservice is the default for
InMemoryRunner, localadk web, and nearly everysample and test, so it is the one backend where the documented contract does not
hold.
Solution: Split the incoming state with the existing
extractStateDeltahelper and write the
app/userbuckets into the scope stores, keeping onlythe session bucket on the session itself.
The ordering is load-bearing: the scope-store write must happen before the
mergeStatescall at the end of the method, or defect 2 survives —mergeStateswould re-apply the stale value over the fresh one. There is a comment at the
call site saying so, and a test that fails if the write is moved (see the
mutation log below).
The resulting scope-store write is now byte-identical to the one already in
appendEvent, so rather than copy ten lines it moves into a privateapplyScopedDeltas()used by both. That is the whole of the non-test diff:52 lines changed in one file.
This is an intentional behavioural change, not an internal refactor. Callers
who pass
app:- oruser:-prefixed initial state toInMemorySessionServicetoday get session-private storage; after this change that state is shared across
the app / user exactly as
DatabaseSessionServiceandadk-pythonalreadyshare it. Callers who want session-private state have always had the unprefixed
form. Public API surface is unchanged — nothing added, renamed or removed.
Collision check (required — and it found something)
Screened every open PR whose title or branch mentioned session / state / scope /
prefix / memory, then diffed the ones touching
core/src/sessions/.feat/shared-state-delta-helper) overlaps and this PR is stacked onit. Refactor: extract a shared extractStateDelta helper and adopt it in InMemorySessionService (stacked on #466) #609 (itself stacked on Feat: port FirestoreSessionService from adk-python to @google/adk-integrations #466) extracts
extractStateDeltaintocore/src/sessions/state_utils.ts, exports it fromcommon.ts, and adopts itin both
DatabaseSessionServicecall sites and inInMemorySessionService.appendEvent. It explicitly does not touchcreateSession— it leavestrimTempStatein that method's import list.Branching this work from
mainwould have produced a second, near-identicalhelper and a textual conflict in three files. Base branch is therefore
feat/shared-state-delta-helper, and this PR contains only the remainder:the
createSessionfix and its tests.(
temp:visibility, transactions,listSessions, ORM casts); none touchInMemorySessionService.createSession's state handling.Cross-language parity
Parity wins here, since the destination of a state key is observable across the
backend boundary. The mapping now matches
DatabaseSessionServiceandadk-pythonexactly:app:kappState[appName]['k']— shared by all sessions of the appuser:kuserState[appName][userId]['k']— shared by all sessions of that usertemp:kkLocal TypeScript convention wins for the things that do not cross the boundary:
applyScopedDeltasis aprivatemethod with no_prefix and has nocounterpart in the Python source.
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.
Seven new cases were appended to the existing
describe('createSession')block incore/test/sessions/in_memory_session_service_test.ts. No existing test wasmodified, weakened, skipped or deleted; the four pre-existing
createSessioncases still pass unchanged.
shares app: state from initial state with other sessions of the same app— defect 1, the headline regression.shares user: state from initial state with other sessions of the same userdoes not share user: state from initial state with a different user— pins that user scope stays per-user.keeps unprefixed initial state scoped to the session that created it— pins that plain keys did not start leaking.drops temp: keys from initial state instead of promoting them— pins thattemp:was not misrouted into app scope by a mis-ordered branch chain.lets app: initial state overwrite an existing app value— defect 2, the silent write loss.merges initial app: state with app state set by an event— proves the two write paths cooperate rather than clobber.Proof each test can fail (both mutations run against the final tree, then
reverted):
Mutation A — revert
createSessiontoconst filteredState = state ? trimTempState(state) : undefined(i.e. the bug this PR fixes). 5 failed | 36 passed:Cases 3 and 4 correctly keep passing under mutation A — they pin behaviour that
is already right today and must not regress.
Mutation B — keep the split, but move
applyScopedDeltas(...)to after themergeStates(...)call, which is the subtle way to "fix" this and still shipdefect 2. 2 failed | 39 passed:
Coverage. The new code (
applyScopedDeltasand the rewrittencreateSessionprologue, lines 53–92) is at 100% statement and 100% branch coverage,
measured with
--coverage.include='core/src/sessions/in_memory_session_service.ts'and the uncovered-line set read out of the v8 JSON report. The lines that report
uncovered in that file — 176, 178, 292–294, 297–299 and branches on 173, 175,
177, 223, 229, 291, 296 — are all pre-existing
listSessionspagination andappendEventguard paths that this change does not touch.state_utils.tsremains at 100%/100%.No integration test was added, deliberately: there is no integration suite for
the in-memory session service (
tests/integration/sessions/holds onlyvertex_ai_session_service_test.ts), and the behaviour is fully observablethrough the public
InMemorySessionServiceAPI at the unit level.Manual End-to-End (E2E) Tests:
CI does not run on this PR because its base is
feat/shared-state-delta-helper,not
main(the workflow triggers onpull_request: branches: [main]), so it wasvalidated locally on the exact pushed commit instead:
core/test/sessions/database_session_service_test.ts(26 tests) andcore/test/sessions/state_utils_test.ts(13 tests) are included in that run andstay green, which is what pins that the shared helper's behaviour is unchanged
for the other backend.
To reproduce the fix by hand:
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.