Description
When a session is loaded via VertexAiSessionService.get_session, each returned Event.id is set to the Vertex event resource name (api_event_obj.name.split('/')[-1], a server-assigned numeric id) instead of the original ADK Event.id that was generated during the run and streamed to the caller.
The original id is still persisted — it's inside the raw_event blob — but _from_api_event deep-copies raw_event and then overwrites the id before validating. So the same logical event ends up with two different ids depending on how you observe it:
| Observed via |
Event.id |
live run / streaming (run_async, :query/streamQuery) |
the ADK Event.id (e.g. a UUID like c452feb0-b9ad-48b1-b863-c7dd2f085378) |
get_session (reload) |
the Vertex resource name (e.g. 658731057016733696) |
Where
src/google/adk/sessions/vertex_ai_session_service.py, _from_api_event — the raw_event branch (and the same in the fallback branch):
raw_event_dict = _get_raw_event(api_event_obj) # contains the original ADK id
if raw_event_dict:
event_dict = copy.deepcopy(raw_event_dict)
event_dict.update({
'id': api_event_obj.name.split('/')[-1], # <-- overwrites the original id
'invocation_id': getattr(api_event_obj, 'invocation_id', None),
'author': getattr(api_event_obj, 'author', None),
})
return Event.model_validate(event_dict)
raw_event_dict['id'] holds the original id and is discarded. Confirmed on main and on the released 1.31.1.
Impact
Any feature that correlates a streamed event with its persisted form by Event.id breaks, because the two id-spaces don't reconcile.
Our concrete case: a resumable SSE layer keys a per-event resume cursor on Event.id. After a page reload we render history from get_session and reconnect using the last rendered event's id as the resume cursor. Because the reloaded id (numeric) is in a different id-space than the live stream (UUID), the cursor can't be resolved against the in-flight buffer and dedupe-by-id fails — a mid-turn reload re-renders the already-shown prefix of the in-flight turn as duplicates.
More generally this makes Event.id unusable as a stable cross-observation key, which is surprising given invocation_id, author, and the full content are faithfully round-tripped from raw_event.
Minimal repro (no live backend needed)
The overwrite is visible by driving the conversion function directly:
import datetime
from types import SimpleNamespace
from google.adk.events.event import Event
from google.adk.sessions.vertex_ai_session_service import _from_api_event
original = Event(id="c452feb0-b9ad-48b1-b863-c7dd2f085378",
invocation_id="e-1", author="model")
raw_event = original.model_dump(exclude_none=True, mode="json", by_alias=True)
# raw_event["id"] == "c452feb0-..." (the original id is persisted)
api_event = SimpleNamespace(
raw_event=raw_event,
name="projects/p/locations/l/reasoningEngines/r/sessions/s/events/658731057016733696",
invocation_id="e-1", author="model",
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
loaded = _from_api_event(api_event)
print(loaded.id) # -> "658731057016733696" (original UUID discarded, though it's in raw_event)
Expected
get_session should return each event with its original Event.id (the value already present in raw_event), so ids are stable across streaming and reload. The Vertex resource name is still available separately on api_event_obj.name for callers who need it.
Proposed fix
When raw_event is present, preserve its id rather than overwriting it (fall back to the resource name only when raw_event is absent, for older data):
event_dict = copy.deepcopy(raw_event_dict)
event_dict.update({
'invocation_id': getattr(api_event_obj, 'invocation_id', None),
'author': getattr(api_event_obj, 'author', None),
})
event_dict.setdefault('id', api_event_obj.name.split('/')[-1]) # only if raw_event lacked one
return Event.model_validate(event_dict)
Workaround
Stash the original id in custom_metadata on append and restore it on read — custom_metadata is a declared field that round-trips through raw_event and (unlike the top-level id) is not overwritten by _from_api_event. (Related: #6055, where an undeclared custom_metadata attribute was dropped on persistence — a declared field avoids that.)
Environment
google-adk 1.31.1 (behavior identical on main)
VertexAiSessionService (Vertex AI Agent Engine sessions)
Description
When a session is loaded via
VertexAiSessionService.get_session, each returnedEvent.idis set to the Vertex event resource name (api_event_obj.name.split('/')[-1], a server-assigned numeric id) instead of the original ADKEvent.idthat was generated during the run and streamed to the caller.The original id is still persisted — it's inside the
raw_eventblob — but_from_api_eventdeep-copiesraw_eventand then overwrites theidbefore validating. So the same logical event ends up with two different ids depending on how you observe it:Event.idrun_async,:query/streamQuery)Event.id(e.g. a UUID likec452feb0-b9ad-48b1-b863-c7dd2f085378)get_session(reload)658731057016733696)Where
src/google/adk/sessions/vertex_ai_session_service.py,_from_api_event— theraw_eventbranch (and the same in the fallback branch):raw_event_dict['id']holds the original id and is discarded. Confirmed onmainand on the released1.31.1.Impact
Any feature that correlates a streamed event with its persisted form by
Event.idbreaks, because the two id-spaces don't reconcile.Our concrete case: a resumable SSE layer keys a per-event resume cursor on
Event.id. After a page reload we render history fromget_sessionand reconnect using the last rendered event's id as the resume cursor. Because the reloaded id (numeric) is in a different id-space than the live stream (UUID), the cursor can't be resolved against the in-flight buffer and dedupe-by-id fails — a mid-turn reload re-renders the already-shown prefix of the in-flight turn as duplicates.More generally this makes
Event.idunusable as a stable cross-observation key, which is surprising giveninvocation_id,author, and the full content are faithfully round-tripped fromraw_event.Minimal repro (no live backend needed)
The overwrite is visible by driving the conversion function directly:
Expected
get_sessionshould return each event with its originalEvent.id(the value already present inraw_event), so ids are stable across streaming and reload. The Vertex resource name is still available separately onapi_event_obj.namefor callers who need it.Proposed fix
When
raw_eventis present, preserve itsidrather than overwriting it (fall back to the resource name only whenraw_eventis absent, for older data):Workaround
Stash the original id in
custom_metadataon append and restore it on read —custom_metadatais a declared field that round-trips throughraw_eventand (unlike the top-levelid) is not overwritten by_from_api_event. (Related: #6055, where an undeclaredcustom_metadataattribute was dropped on persistence — a declared field avoids that.)Environment
google-adk1.31.1 (behavior identical onmain)VertexAiSessionService(Vertex AI Agent Engine sessions)