Commit 0adbab8
fix(firestore): serialize session state as JSON to avoid reserved field name errors
Merge #5549
Fixes #5539
## Problem
\`FirestoreSessionService\` stores session state as a raw Firestore map, writing each state key as a direct document field path. Firestore rejects field names matching \`__.*__\` with:
\`\`\`
400 field name '__session_metadata__' is reserved
\`\`\`
The ADK Developer UI creates sessions with \`__session_metadata__\` in the initial state (used to store display names). This makes the UI incompatible with \`FirestoreSessionService\` -- the first chat message always fails with \`500 Internal Server Error\`.
## Fix
Serialize the session state dict as a JSON string before writing to Firestore, and deserialize on read. This avoids Firestore field name restrictions entirely since the entire state is stored as a single opaque string field.
Changes:
- \`create_session\`: write \`"state": json.dumps(session_state)\` instead of \`"state": session_state\`
- \`get_session\`: read back with \`json.loads(raw_state) if isinstance(raw_state, str) else raw_state\` for backward compat with existing sessions stored in old map format
- \`list_sessions\`: same backward-compat read
- \`append_event\`: write updated state as \`json.dumps(session_only_state)\`
## Backward Compatibility
Existing sessions stored with the old dict format continue to work. The read path checks whether the stored value is a string (new format) or dict (old format) and handles both.
## Why JSON string vs map
Firestore's \`__.*__\` restriction applies to all field paths including nested map sub-fields, so there is no way to store \`__session_metadata__\` as a Firestore map key at any level. Serializing the whole state as a JSON string sidesteps the restriction completely without any key escaping logic.
## Testing Plan
**Reproducing the original bug**
The issue reporter's minimal repro already confirms the failure path. To verify locally before this fix:
\`\`\`python
import asyncio
from google.adk.integrations.firestore.firestore_session_service import FirestoreSessionService
from google.cloud import firestore
async def main():
service = FirestoreSessionService(client=firestore.AsyncClient(), root_collection="adk-session")
await service.create_session(
app_name="test_app",
user_id="test_user",
session_id="test_session",
state={"__session_metadata__": {"displayName": "hello"}},
)
asyncio.run(main())
# Before fix: raises google.api_core.exceptions.InvalidArgument: 400 field name '__session_metadata__' is reserved
# After fix: succeeds
\`\`\`
**What I verified manually**
1. \`create_session\` with \`__session_metadata__\` in state writes successfully to Firestore
2. \`get_session\` on the same session returns state with \`__session_metadata__\` intact
3. \`append_event\` that updates session state (e.g. sets a new key) writes and reads back correctly
4. Backward compat: sessions created before this fix (state stored as a Firestore map with non-reserved keys) still load correctly because the read path falls back to treating the field as a dict when it is not a string
5. ADK Developer UI flow works end-to-end: open UI, select agent backed by Firestore, send first message, session is created without 500 error
**Unit test coverage**
The existing unit tests in \`tests/unittests/integrations/firestore/test_firestore_session_service.py\` cover create/get/append_event for normal state keys. Running those tests after this change all pass (the mock Firestore client stores and returns the JSON string transparently).
A specific regression test for the reserved-key case would look like:
\`\`\`python
async def test_create_session_with_reserved_key(firestore_session_service):
session = await firestore_session_service.create_session(
app_name="test_app",
user_id="test_user",
state={"__session_metadata__": {"displayName": "My Session"}},
)
assert session.state["__session_metadata__"]["displayName"] == "My Session"
loaded = await firestore_session_service.get_session(
app_name="test_app",
user_id="test_user",
session_id=session.id,
)
assert loaded.state["__session_metadata__"]["displayName"] == "My Session"
\`\`\`
Happy to add this as a formal test to the test file if that would help get this through review faster.
Co-authored-by: George Weale <gweale@google.com>
COPYBARA_INTEGRATE_REVIEW=#5549 from nileshpatil6:fix/firestore-reserved-state-keys 2eb917c
PiperOrigin-RevId: 9339350521 parent a181a39 commit 0adbab8
2 files changed
Lines changed: 16 additions & 7 deletions
File tree
- src/google/adk/integrations/firestore
- tests/unittests/integrations/firestore
Lines changed: 13 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
21 | 22 | | |
22 | 23 | | |
23 | 24 | | |
| |||
202 | 203 | | |
203 | 204 | | |
204 | 205 | | |
205 | | - | |
| 206 | + | |
206 | 207 | | |
207 | 208 | | |
208 | 209 | | |
| |||
313 | 314 | | |
314 | 315 | | |
315 | 316 | | |
316 | | - | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
317 | 321 | | |
318 | 322 | | |
319 | 323 | | |
| |||
406 | 410 | | |
407 | 411 | | |
408 | 412 | | |
409 | | - | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
410 | 419 | | |
411 | 420 | | |
412 | 421 | | |
| |||
555 | 564 | | |
556 | 565 | | |
557 | 566 | | |
558 | | - | |
| 567 | + | |
559 | 568 | | |
560 | 569 | | |
561 | 570 | | |
| |||
Lines changed: 3 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| 17 | + | |
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
| |||
117 | 118 | | |
118 | 119 | | |
119 | 120 | | |
120 | | - | |
| 121 | + | |
121 | 122 | | |
122 | 123 | | |
123 | 124 | | |
| |||
324 | 325 | | |
325 | 326 | | |
326 | 327 | | |
327 | | - | |
328 | | - | |
| 328 | + | |
329 | 329 | | |
330 | 330 | | |
331 | 331 | | |
| |||
0 commit comments