Refactor: extract a shared extractStateDelta helper and adopt it in InMemorySessionService (stacked on #466) - #609
Open
AmaadMartin wants to merge 6 commits into
Conversation
added 6 commits
August 3, 2026 20:08
Relocate the app:/user:/session splitter out of base_session_service.ts into its own sessions/state_utils.ts module and rename it extractStateDelta, matching adk-python's sessions/_session_util.extract_state_delta. Because @google/adk-integrations consumes it across the package boundary, export it explicitly from common.ts rather than letting it ride on the wholesale 'export *' of base_session_service.js. Its unit tests move with it, from base_session_service_test.ts to state_utils_test.ts, and gain the edge cases the adk-python suite pins.
…endEvent Replace the hand-rolled per-key app:/user: routing loop with the shared helper. The session bucket is deliberately unused here: session state is applied by super.appendEvent(), which keeps the prefixes on the keys. Lazy allocation of the appState/userState entries is preserved, so appending an event that carries no scoped keys still leaves those maps untouched. Adds appendEvent coverage for state accumulated across successive events and for the session-state contract the helper must not disturb.
The assertion is an exact toEqual on the state getSession returns, not on the stored session state, so say so.
appendEvent calls super.appendEvent() twice, so pointing at one of them by position is misleading.
Three carry-over defects from moving the helper out of
base_session_service.ts:
- The parameter was widened from 'Record<string, unknown> | undefined'
to an optional one. No caller omits it, so the '?' only made a
meaningless no-arg call legal on a newly published symbol.
- The '{@link mergeStates}' cross-reference was downgraded to an inert
code span. TypeDoc resolves it across modules and docs:check runs with
--treatWarningsAsErrors, so the link is safe to keep.
- Three fixture literals in the migrated 'routes each key by prefix'
case were rewritten in transit, obscuring that the move preserved it.
The argument can no longer be omitted, so 'absent' described a call the signature rejects.
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: Three session backends independently reimplement the same rule for
splitting a state map into
app:/user:/ session buckets while droppingtemp:keys.adk-pythonalready has this exactly once, assessions/_session_util.extract_state_delta.Solution: A pure, behaviour-preserving refactor that leaves the rule in one
place and has every backend delegate to it.
Collision check (required, and it found something)
Screened every open PR whose title or branch mentioned state/delta/session or
extract/helper/dedup/split/refactor, then diffed the eight that touch
core/src/sessions/: #607, #492, #477, #466, #386, #376, #293, #132.feat/firestore-session-service) heavily overlaps. It alreadyextracts the splitter — as
splitStateDeltainbase_session_service.ts—and already converts both
DatabaseSessionServicecall sites, plus it adds athird consumer (
FirestoreSessionService) in theintegrationspackage.Branching this work from
mainwould have produced a second, near-identicalhelper and a textual conflict in
database_session_service.ts. So this PRis stacked on Feat: port FirestoreSessionService from adk-python to @google/adk-integrations #466 and contains only the remainder.
in_memory_session_service.ts, but onlylistSessions— no overlap with theappendEventloop changed here.the state-delta split logic (
grepforextractStateDelta|state_utils|splitStateDelta|APP_PREFIXover each diff).What this PR adds on top of #466
core/src/sessions/state_utils.ts— out of
base_session_service.ts, and renames itextractStateDeltatomatch the
adk-pythonreference (_session_util.extract_state_delta), whichis the shared implementation this work converges on. The return type is now a
named
StateDeltasinterface instead of an inline object type.InMemorySessionService.appendEvent—the one backend Feat: port FirestoreSessionService from adk-python to @google/adk-integrations #466 leaves on a hand-rolled loop. This is the only
behavioural code path this PR touches that Feat: port FirestoreSessionService from adk-python to @google/adk-integrations #466 does not.
@google/adkconsumers only as a side effect ofcommon.tsdoingexport * from './sessions/base_session_service.js'. Since@google/adk-integrationsgenuinely imports it across the package boundary,this PR keeps it public but declares it explicitly:
export {extractStateDelta}/export type {StateDeltas}.Everything else is import churn:
database_session_service.ts(2 sites) andfirestore_session_service.ts(2 sites) switch to the new name/module.Notes on the in-memory call site (the one non-mechanical edit)
The in-memory loop is not a verbatim instance of the pattern, so two
properties are preserved explicitly rather than by construction:
sessionbucket is intentionally unused here. Session state isapplied by
super.appendEvent()→BaseSessionService.updateSessionState,which writes every non-
temp:key with its prefix intact. Wiring thehelper's
sessionbucket in would stripapp:/user:keys out ofsession.stateand change behaviour. Only{app, user}are destructured, anda comment at the call site says why.
Object.keys(...).length > 0guards, soappending an event with no scoped keys still leaves
this.appState[appName]and
this.userState[appName][userId]unallocated. Disclosure: this is notobservable through the public API — both readers pass the value to
mergeStates(), which defaultsundefinedand{}identically — so no testcan distinguish guard-present from guard-absent without reaching into private
fields, which this repo forbids. The guards are kept because dropping them
would allocate an empty object per app and per user on every
appendEvent,which the original code avoided. Both guards are still exercised in both
directions by the existing
updates app state/updates user statetests(each has one delta empty and the other non-empty).
Parity vs. local convention
Local TS convention wins on module layout and naming style; parity wins on
observable behaviour. The bucket keys (
app/user/session), the precedenceorder (
app:→user:→temp:→ session), andtemp:-dropping all matchadk-pythonexactly. Verified field-by-field againstsrc/google/adk/sessions/_session_util.py.Deviation from the task spec, stated explicitly
The spec said do not export the helper from
common.ts, on the reasoning thatno out-of-package consumer existed and that a follow-up "will add the export
when an out-of-package consumer actually exists". On this stacked base that
consumer does exist (
FirestoreSessionServiceinintegrations), so theexport is added now, explicitly, exactly as the spec anticipated. Nothing else
in the spec was reduced or skipped.
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.
New
core/test/sessions/state_utils_test.ts(13 cases) ports theadk-pythonTestExtractStateDeltacases one-for-one and adds the TypeScript-specific ones:empty map,
undefined, each bucket in isolation,temp:dropping, the mixedmap, purity against a frozen input, a prefix-only key (
{'app:': 1}→{app: {'': 1}}), two repeated-prefix cases, and value-carried-by-reference.The four cases from #466's
base_session_service_test.tsmoved here with thecode rather than being deleted, and the move is verbatim — every fixture
literal and assertion is unchanged, modulo the
splitStateDelta→extractStateDeltarename. Two are renamed only to disambiguate newly-addedneighbours (
strips only the leading prefix→...when a prefix repeats, whichnow sits beside a new
...when another prefix follows it); their inputs andassertions are identical.
base_session_service_test.tshad no other content,so the file is removed. The move can be checked mechanically:
Three cases added to
core/test/sessions/in_memory_session_service_test.tsforthe newly-refactored call site: app state accumulated across successive events,
user state accumulated across successive events, and an exact
toEqualon thestate
getSessionreturns (the existing cases only use non-exhaustivetoHaveProperty, so nothing pinned the absence of spurious keys).The 26
DatabaseSessionServiceand 31 pre-existingInMemorySessionServicecases, and all 65 integrations cases, pass unchanged — the signal that this
refactor is behaviour-preserving. No existing test was edited, skipped, or
weakened.
Coverage.
state_utils.tsis at 100% statements / branches / functions /lines:
Proof the tests can fail (coverage is not proof). Each mutation was applied
to the source, the suite re-run, and the source restored:
else if (!key.startsWith(State.TEMP_PREFIX))→elsedrops a temporary key from every bucket:expected { Object (app, user, ...) } to deeply equal { app: {}, user: {}, session: {} }app[key.slice(State.APP_PREFIX.length)]→app[key]routes an app-prefixed key…:expected { app: { 'app:theme': 'dark' }, …(2) } to deeply equal { app: { theme: 'dark' }, …(2) }user[key.slice(State.USER_PREFIX.length)]→slice(State.APP_PREFIX.length)routes a user-prefixed key…:expected { app: {}, …(2) } to deeply equal { app: {}, user: { lang: 'en' }, …(1) }{...this.appState[appName], ...appDelta}→{...appDelta}accumulates app state across successive events:expected { 'app:second': 'b' } to match object { 'app:first': 'a', 'app:second': 'b' }accumulates user state across successive events:expected { 'user:second': 'b' } to match object { 'user:first': 'a', …(1) }{app: appDelta, user: userDelta}→ buckets swappedexposes exactly the prefixed and plain keys on the retrieved session:expected { 'app:appKey': 'appValue', …(4) } to deeply equal { 'app:appKey': 'appValue', …(2) }Manual End-to-End (E2E) Tests:
No new E2E test. This is an internal refactor with no I/O, no new public
behaviour and no cross-process boundary; the
DatabaseSessionServicesuitealready drives the real MikroORM/SQLite path (
:memory:) end to end throughboth converted call sites, and the
FirestoreSessionServicesuite drives thethird. To reproduce locally:
CI status: absent. This PR's base is
feat/firestore-session-service, notmain, so thepull_request: branches: [main]workflows never trigger. It wasvalidated locally instead, on the exact commit pushed:
npm run build— clean (also proves the newextractStateDeltaexportresolves for the
integrationspackage, which imports it from@google/adk).npx vitest run --project unit:core <3 files>— 73 passed.npx vitest run --project unit:integrations— 65 passed.npx eslint <all 7 touched files>— clean, no suppressions added anywhere inthe diff (no
any,@ts-expect-error,eslint-disable, or coverage-ignore).npx prettier --check <all 7 touched files>— clean.npm run docs:check(typedoc --treatWarningsAsErrors) — clean, so the newpublic export is fully documented.
npm run ts:check— reports no error in any file this PR touches. Therepo has pre-existing repo-wide
ts:checkfailures in unrelated files (Fix: make ts:check green over a stated slice and gate CI on it #370tracks making it green); this PR neither adds to them nor depends on them.
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.