Google ADK state_delta Not Applied on Resume: Runner.run_async Reproduction and Workaround
Tested on macOS 26.5.2 arm64, Python 3.10.2, google-adk 2.6.2, ResumabilityConfig(is_resumable=True), and InMemorySessionService. No API key or external model call. The workaround was not validated against database-backed or managed SessionService implementations.
Who Should Read This
- ● Python developers building resumable Google ADK agents, approval flows, and long-running workflows.
- ● Platform teams resuming invocations from external UIs, webhooks, or background jobs with state-only updates.
- ● Engineers debugging unchanged session.state even though Runner does not raise an exception.
If you use Google ADK resumability, there is a failure mode that is harder to notice than an exception: Runner.run_async() resumes successfully, the invocation continues, but the state_delta passed with that resume never reaches session.state.
I reproduced the behavior locally on google-adk==2.6.2 without an API key or an external model call. The boundary was consistent across both dispatch paths I tested: the Node path using LlmAgent and the legacy path using a plain BaseAgent. When the resume has no new_message, the delta is not applied. With a new_message, the same delta is persisted.
That distinction matters for approval callbacks, background-job resumes, and long-running workflows where an external system may need to update state without inventing another user message.
Short answer
My local environment:
- macOS 26.5.2 arm64
- Python 3.10.2
google-adk==2.6.2ResumabilityConfig(is_resumable=True)InMemorySessionService- an offline Echo model stub
The four-case matrix was:
| Runner path | new_message on resume | Was state_delta applied? | Result |
|---|---|---|---|
Node / LlmAgent | No | No | Failure reproduced |
Node / LlmAgent | Yes | Yes | Control passes |
legacy / BaseAgent | No | No | Failure reproduced |
legacy / BaseAgent | Yes | Yes | Control passes |

Figure 2. The same delta is lost on both resume paths without new_message, and applied on both control paths when a user message exists.
Actual output from the local reproduction:
node-no-message new_message=False applied=False state={}
node-with-message new_message=True applied=True state={'resumed_key': 'resumed_value'}
legacy-no-message new_message=False applied=False state={}
legacy-with-message new_message=True applied=True state={'resumed_key': 'resumed_value'}
The problematic call is structurally simple:
async for _ in runner.run_async(
user_id=user_id,
session_id=session_id,
invocation_id=invocation_id,
state_delta={"approved": True},
):
pass
On ADK 2.6.2 in this test, the call can continue without an exception while the delta is not persisted. The important wording is the delta is ignored. Existing session state is not proven to be erased or reset by this test.
Why this is easy to misdiagnose
At first, this looks like a SessionService problem. You might suspect that InMemorySessionService did not persist state, that the wrong invocation_id was resumed, or that a callback later overwrote the state.
The controls narrow that down. The SessionService, agent, delta, and resumability configuration are the same; the only meaningful difference is whether a new_message exists on resume. With no message the final state is {}. With a message the final state contains:
{"resumed_key": "resumed_value"}
That makes this different from “ADK state does not work on resume.” State updates do work on the control path. The failure is tied to how state_delta reaches the event-persistence path.
What the ADK 2.6.2 source path shows
I inspected the locally installed 2.6.2 Runner implementation. On the Node execution path, appending the user event is gated by the presence of new_message:
if new_message:
user_event = await self._append_user_event(
ic, new_message, state_delta=state_delta
)
_append_user_event() is where the delta is attached to EventActions:
Event(
invocation_id=ic.invocation_id,
author="user",
actions=EventActions(state_delta=state_delta),
content=content,
)
The event is then persisted through:
self.session_service.append_event(...)
That explains the control result: when a user message exists, the delta rides on the user event and reaches the SessionService. When the invocation is resumed without a new_message, that event path is skipped and there is no independent persistence path in this version to carry the supplied delta.

Figure 3. Left: the failing resume path. Right: the temporary event-level workaround tested locally. The workaround is not an upstream fix.
This matches Google ADK issue #6644. The issue states that Runner.run_async accepts state_delta as an optional state change, but the delta is silently discarded when resuming by invocation_id without new_message. The report covers both the Node and legacy dispatch paths.
As of August 9, 2026, #6644 is still open and the GitHub issue page shows no linked branch or pull request. Treat any workaround below as temporary, not as a released Google fix.
Do not use a fake new_message as the default fix
The controls make one tempting workaround obvious: if a message makes the state update work, why not send an empty or synthetic user message every time you resume?
I would not make that the production default.
new_message is not a state-update flag. It becomes part of the session event history and can affect callbacks, model context, auditing, and later workflow behavior. In a human-in-the-loop flow, inventing a user message only to trigger state persistence can make the event history semantically false.
A safer temporary approach is to separate two operations that your application actually means: persist the state change, then resume the invocation.
Temporary workaround I verified locally
ADK session state is updated through events carrying EventActions(state_delta=...). I therefore tested an explicit event-level path: append a content-less event containing the delta through the configured SessionService, then resume the invocation without passing a fake user message.
Core example:
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
session = await session_service.get_session(
app_name=app_name,
user_id=user_id,
session_id=session_id,
)
await session_service.append_event(
session=session,
event=Event(
invocation_id=invocation_id,
author="user",
actions=EventActions(
state_delta={"resumed_key": "resumed_value"}
),
),
)
async for _ in runner.run_async(
user_id=user_id,
session_id=session_id,
invocation_id=invocation_id,
):
pass
I ran that workaround on both local paths:
node applied=True state={'resumed_key': 'resumed_value'}
legacy applied=True state={'resumed_key': 'resumed_value'}
So in this 2.6.2 test, explicitly persisting a content-less event with EventActions(state_delta=...) avoided the loss seen when relying on Runner.run_async(state_delta=...) during a message-less resume.
This is still a workaround. I only verified it with InMemorySessionService. If you use a database-backed or managed SessionService, re-test event persistence, idempotency, concurrency, ordering, and audit semantics before using the same pattern in production.
Where this bug matters most
A normal chat application may never notice the issue because each turn naturally contains a new user message. The higher-risk cases are workflows where resume input comes from somewhere other than natural-language chat:
- a human approves an action in an external UI;
- a background job finishes and resumes an invocation;
- a webhook changes workflow state;
- an operator updates approval metadata without adding a user message;
- a long-running process uses
invocation_idas its resume handle.
For example:
state_delta={
"approval_status": "approved",
"reviewer": "human-42",
}
If that resume has no new_message, application code may see run_async() complete and assume the approval state was saved. A later node can then read the old value or no value at all.
For state that gates payments, deletion, sending, deployment, or other high-impact tools, add a write-after-read assertion instead of treating a successful Runner call as proof of persistence:
session = await session_service.get_session(...)
assert session.state.get("approval_status") == "approved"
Is every Google ADK version affected?
This article does not claim that.
The XBSTACK reproduction is scoped to google-adk==2.6.2. The upstream report also demonstrates the issue against 2.6.2-era code. A later release may change the behavior.
If you are reading this on a newer version, check two things before copying any workaround:
- Check whether issue #6644 is closed and whether a fix is included in your installed release.
- Run the four-case matrix locally. It requires no external model API, so it is cheap to keep as a regression test.
If the no-message cases begin returning the expected state on a newer version, remove the workaround rather than preserving obsolete event logic indefinitely.
Minimal troubleshooting checklist
When state_delta appears not to work during a Google ADK resume:
- Record the exact
google-adkversion. - Confirm the call is resuming with
invocation_id. - Confirm resumability is enabled for the app.
- Check whether
new_messageis absent. - Re-read
session.stateafter the run; do not rely only on the lack of an exception. - Run the same delta once with and once without
new_message. - Check the current status of #6644 and your release notes.
- Prefer an upstream fixed release when available; otherwise evaluate an explicit state-event workaround in your own SessionService.
FAQ
Why does run_async() succeed while the state remains unchanged?
Because the failure is in the state persistence path, not in invocation validation. ADK can find and resume the invocation while the 2.6.2 message-less path fails to attach the supplied delta to a persisted user event.
Does sending a new_message fix it?
In my four-case test, the same delta is applied when a new_message exists. That is useful as a control, but manufacturing a fake message just to trigger persistence changes your event history and is not the workaround I recommend by default.
Is SessionService.append_event() the official fix?
No. It is a temporary event-level workaround I verified on 2.6.2 with InMemorySessionService. The upstream issue remains open as of August 9, 2026.
Is the bug model-specific?
The reproduction does not require Gemini, OpenAI, LiteLLM, or any external model API. The Node-path test uses an offline Echo stub, and the legacy-path test uses a plain BaseAgent, so the tested failure boundary is independent of a provider call.
Final recommendation
The most important lesson here is not simply “ADK has a state bug.” The narrower production lesson is that resumability and state persistence need separate regression tests.
A run can resume without throwing while a state mutation is not persisted. For approval systems, long-running workflows, and external callbacks, verify the state after resume and keep a small no-network regression test around this boundary.
On google-adk==2.6.2, I reproduced the failure on both dispatch paths, confirmed the new_message control on both paths, and verified an explicit event-level workaround. I would still prefer an upstream fixed release as soon as one is available, then remove the temporary workaround after the same matrix passes.
References
- Google ADK issue #6644: https://github.com/google/adk-python/issues/6644
- Google ADK repository: https://github.com/google/adk-python
- For framework-level trade-offs around state, orchestration, and recovery, continue with AI Agent Framework Comparison: LangChain / LangGraph, AutoGen, and CrewAI.
- If your resume path also includes human approval and cross-process continuation, see OpenAI Agents SDK RunState Approval and Resume.
- If a resumed state can unlock consequential tools such as payment, sending, or deletion, pair it with an AI Agent Tool Authorization Policy Gate.
- XBSTACK Production AI Agent Systems · XBSTACK AI Engineering Resource Hub
Reproduction assets
repro.py: four failure/control casesworkaround.py: explicit content-less state-event workaroundrequirements.txt: pinsgoogle-adk==2.6.2README.md: environment, results, and evidence boundary
Continue from one agent pattern to the complete production system
The AI Agent hub organizes architecture, memory, tool use, evaluation, security, deployment and multi-agent coordination into a single learning path.
Next Reading
View Hub →
AI Agent Data Analysis in Practice: Building an Automated Financial Research and Decision System
AI Agent Data Analysis in Practice: A detailed guide to the engineering applications of AI agents in data analysis, covering automated workflows, tool invocation, secure sandboxes
Semantic Kernel in Practice: Building an Industrial-Grade AI Plugin System and Planner Orchestration Hub
A practical Semantic Kernel guide to plugin design, typed functions, planner orchestration, dependency injection, execution controls, and production-ready AI workflow boundaries.
OpenAI Responses API: Why Stream Abort Causes No tool call found for function call output
A function_call can be visible before it is durable in Conversation state. This guide explains the 400 No tool call found error, reconciliation, idempotency, and safe recovery.
Practical Guide to AI Agent Memory Systems: Memory Layering, User Isolation, Forgetting Mechanisms, and Long-Term State Management
Practical Guide to AI Agent Memory Systems: A systematic breakdown of production-grade design for AI Agent Memory Systems, covering short-term state, long-term memory, user profile
Xiaobai
Full-Stack AI Engineer
Xiaobai, a full-stack AI engineer building production Agent systems, product tools and independent software assets.
About Xiaobai & XBSTACK →Liked this article?
Join the newsletter
Every issue condenses production AI engineering changes, real failures, reproducible experiments, useful tools and new XBSTACK assets. No generic news digest and no filler.
DISCUSSION
Questions, verification and corrections
Sign in to comment. Every new comment is reviewed before publication; while pending, it is visible only to you and the administrator.