From ed4a6d1851cbe0f9b4db2a06645a5064b4392227 Mon Sep 17 00:00:00 2001 From: Luyang Wang Date: Fri, 7 Aug 2026 20:19:49 -0400 Subject: [PATCH] fix: apply state_delta when resuming without a new_message `Runner.run_async` accepts a `state_delta` argument and documents it as "Optional state changes to apply to the session", but the delta was only ever applied while appending the user message event, gated on `if new_message:`. Resuming an invocation by `invocation_id` with no `new_message` is a supported call, so in that case a caller-supplied `state_delta` was accepted and then silently discarded - no warning, no error. Apply the delta via a content-less event when there is no user message to carry it. This mirrors the existing rewind path, which already appends `Event(author='user', actions=EventActions(state_delta=...))` with no content. --- src/google/adk/runners.py | 33 +++++++++++++++++++++ tests/unittests/test_runners.py | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index cb69b845510..401e7fd3b72 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -631,6 +631,11 @@ async def _run_node_async( ) if yield_user_message and user_event: yield user_event + elif state_delta: + # No user message to carry the delta (e.g. resuming by + # invocation_id), so append it as a content-less event instead of + # dropping it. + await self._append_state_delta_event(ic, state_delta) # Run before_run callbacks await ic.plugin_manager.run_before_run_callback(invocation_context=ic) @@ -905,6 +910,29 @@ async def _append_user_event( session=ic.session, event=event ) + async def _append_state_delta_event( + self, + ic: InvocationContext, + state_delta: dict[str, Any], + ) -> Event: + """Append a content-less event that only carries `state_delta`. + + Used when a caller supplies `state_delta` without a `new_message` (for + example when resuming an invocation by id). Without this, the delta would + be silently dropped, because it is otherwise only applied while appending + the user message event. + """ + event = Event( + invocation_id=ic.invocation_id, + author='user', + actions=EventActions(state_delta=state_delta), + ) + _apply_run_config_custom_metadata(event, ic.run_config) + ic.stamp_event_branch_context(event) + return await self.session_service.append_event( + session=ic.session, event=event + ) + def _find_original_user_content( self, session: Session, invocation_id: str ) -> types.Content | None: @@ -2147,6 +2175,11 @@ async def _setup_context_for_resumed_invocation( run_config=run_config, state_delta=state_delta, ) + elif state_delta: + # Resuming without a new message: there is no user message event to + # carry the delta, so append it as a content-less event instead of + # dropping it. + await self._append_state_delta_event(invocation_context, state_delta) # Step 4: Populate agent states for the current invocation. invocation_context.populate_invocation_agent_states() # Step 5: Set agent to run for the invocation. diff --git a/tests/unittests/test_runners.py b/tests/unittests/test_runners.py index 86a9b09a9d8..8123350650f 100644 --- a/tests/unittests/test_runners.py +++ b/tests/unittests/test_runners.py @@ -770,6 +770,58 @@ def test_run_passes_state_delta(): assert user_event.actions.state_delta == state_delta +@pytest.mark.asyncio +async def test_run_async_applies_state_delta_when_resuming_without_new_message(): + """Resuming by invocation_id should still apply a caller-supplied delta.""" + + session_service = InMemorySessionService() + runner = Runner( + app_name=TEST_APP_ID, + agent=MockAgent("test_agent"), + session_service=session_service, + artifact_service=InMemoryArtifactService(), + auto_create_session=True, + ) + runner.resumability_config = ResumabilityConfig(is_resumable=True) + + # Seed the session with an invocation to resume. + async with aclosing( + runner.run_async( + user_id=TEST_USER_ID, + session_id=TEST_SESSION_ID, + new_message=types.Content( + role="user", parts=[types.Part(text="hello")] + ), + ) + ) as agen: + async for _ in agen: + pass + + session = await session_service.get_session( + app_name=TEST_APP_ID, user_id=TEST_USER_ID, session_id=TEST_SESSION_ID + ) + invocation_id = session.events[0].invocation_id + + state_delta = {"resumed_key": "resumed_value"} + + async with aclosing( + runner.run_async( + user_id=TEST_USER_ID, + session_id=TEST_SESSION_ID, + invocation_id=invocation_id, + state_delta=state_delta, + ) + ) as agen: + async for _ in agen: + pass + + session = await session_service.get_session( + app_name=TEST_APP_ID, user_id=TEST_USER_ID, session_id=TEST_SESSION_ID + ) + + assert session.state["resumed_key"] == "resumed_value" + + @pytest.mark.asyncio async def test_run_async_propagates_invocation_id(): """run_async should propagate invocation_id to the invocation context and events."""