From 4101af1b11296fd11cd70012e79dcdda25982655 Mon Sep 17 00:00:00 2001 From: kyo-zzz <1147385157@qq.com> Date: Sat, 24 Oct 2026 02:44:20 +0800 Subject: [PATCH] fix(sessions): snapshot appended state deltas in in-memory sessions --- .../adk/sessions/in_memory_session_service.py | 2 +- tests/unittests/sessions/_conformance.py | 27 +++++- .../sessions/test_session_service.py | 91 +++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/google/adk/sessions/in_memory_session_service.py b/src/google/adk/sessions/in_memory_session_service.py index b593d55b416..ac045af85dd 100644 --- a/src/google/adk/sessions/in_memory_session_service.py +++ b/src/google/adk/sessions/in_memory_session_service.py @@ -365,7 +365,7 @@ async def append_event(self, session: Session, event: Event) -> Event: if event.actions and event.actions.state_delta: state_deltas = _session_util.extract_state_delta( - event.actions.state_delta + _copy_state(event.actions.state_delta) ) app_state_delta = state_deltas['app'] user_state_delta = state_deltas['user'] diff --git a/tests/unittests/sessions/_conformance.py b/tests/unittests/sessions/_conformance.py index 11104321b55..5f9711619e2 100644 --- a/tests/unittests/sessions/_conformance.py +++ b/tests/unittests/sessions/_conformance.py @@ -145,8 +145,31 @@ async def _make_per_agent_database( BACKENDS = [ - _Backend('in_memory', _make_in_memory), - _Backend('in_memory_light_copy', _make_in_memory_light_copy), + _Backend( + 'in_memory', + _make_in_memory, + divergences={ + 'test_append_event_does_not_retain_caller_event': ( + 'append stores the event object passed by the caller: events' + ' may hold values that cannot be copied, and append must not' + ' pay copy costs; reads return deep copies instead' + ), + }, + ), + _Backend( + 'in_memory_light_copy', + _make_in_memory_light_copy, + divergences={ + 'test_append_event_does_not_retain_caller_event': ( + 'append stores the event object passed by the caller: events' + ' may hold values that cannot be copied, and append must not' + ' pay copy costs; reads return copies instead' + ), + 'test_append_event_does_not_retain_caller_state': ( + 'light copy keeps state values aliased to the caller, by design' + ), + }, + ), _Backend('database', _make_database), _Backend('sqlite', _make_sqlite), _Backend( diff --git a/tests/unittests/sessions/test_session_service.py b/tests/unittests/sessions/test_session_service.py index 3907b7bb491..39269236368 100644 --- a/tests/unittests/sessions/test_session_service.py +++ b/tests/unittests/sessions/test_session_service.py @@ -1873,6 +1873,54 @@ async def test_append_event_complete(session_service): ) +@pytest.mark.asyncio +async def test_append_event_does_not_retain_caller_state(session_service): + """A completed append preserves the applied state delta. + + Mutating the caller's state delta after append_event returns must not + change what the service returns later: storage keeps the caller's snapshot + at append time, not live references into the caller's values. + """ + session = await session_service.create_session(app_name='a', user_id='u') + event = Event( + author='assistant', + content=types.Content( + role='model', parts=[types.Part(text='original answer')] + ), + actions=EventActions(state_delta={'cart': {'items': ['coffee']}}), + ) + await session_service.append_event(session=session, event=event) + event.actions.state_delta['cart']['items'].append('tea') + + stored = await session_service.get_session( + app_name='a', user_id='u', session_id=session.id + ) + assert stored.state['cart'] == {'items': ['coffee']} + + +@pytest.mark.asyncio +async def test_append_event_does_not_retain_caller_event(session_service): + """A completed append preserves the stored event content. + + Mutating the caller's event after append_event returns must not change + what the service returns later. + """ + session = await session_service.create_session(app_name='a', user_id='u') + event = Event( + author='assistant', + content=types.Content( + role='model', parts=[types.Part(text='original answer')] + ), + ) + await session_service.append_event(session=session, event=event) + event.content.parts[0].text = 'changed after append' + + stored = await session_service.get_session( + app_name='a', user_id='u', session_id=session.id + ) + assert stored.events[-1].content.parts[0].text == 'original answer' + + # pylint: disable=redefined-outer-name @pytest.mark.asyncio async def test_append_event_with_requested_tool_confirmations(session_service): @@ -3496,6 +3544,49 @@ async def test_in_memory_get_session_events_share_nothing_with_storage(): ) +@pytest.mark.asyncio +@pytest.mark.parametrize('light_copy', [False, True]) +async def test_in_memory_append_event_state_uses_configured_copy_depth( + light_copy, +): + """A completed append copies the caller's state delta to the configured depth. + + Light copy exists to skip the recursive copy, so under it state values stay + shared with the caller; without it they are deep-copied. The stored event + itself stays aliased to the caller's event in both modes: events may hold + values that cannot be copied, and append must not pay copy costs. + """ + override_feature_enabled( + FeatureName.IN_MEMORY_SESSION_SERVICE_LIGHT_COPY, light_copy + ) + try: + service = InMemorySessionService() + session = await service.create_session( + app_name='my_app', user_id='u1', session_id='s1' + ) + event = Event( + author='agent', + content=types.Content( + role='model', parts=[types.Part(text='original')] + ), + actions=EventActions(state_delta={'cart': {'items': ['coffee']}}), + ) + await service.append_event(session, event) + event.actions.state_delta['cart']['items'].append('tea') + + stored = service.sessions['my_app']['u1']['s1'] + assert stored.events[0] is event + assert (stored.state['cart'] is event.actions.state_delta['cart']) == ( + light_copy + ) + if not light_copy: + assert stored.state['cart'] == {'items': ['coffee']} + finally: + override_feature_enabled( + FeatureName.IN_MEMORY_SESSION_SERVICE_LIGHT_COPY, False + ) + + @pytest.mark.asyncio async def test_in_memory_get_session_keeps_shared_state_values_shared(): """Two state keys holding one value still hold one value after a read."""