Google ADK 2.6.2 state_delta not applied to session.state when Runner.run_async resumes without new_message - XBSTACK

Google ADK state_delta Not Applied on Resume: Runner.run_async Reproduction and Workaround

Release Date
2026-08-09
Reading Time
6 min
Content Size
8,893 chars
Google Adk
Runner.run_async
State_delta
Invocation_id
Sessionservice
Resumability
Python
Ai Agent
Laboratory Note

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.2
  • ResumabilityConfig(is_resumable=True)
  • InMemorySessionService
  • an offline Echo model stub

The four-case matrix was:

Runner pathnew_message on resumeWas state_delta applied?Result
Node / LlmAgentNoNoFailure reproduced
Node / LlmAgentYesYesControl passes
legacy / BaseAgentNoNoFailure reproduced
legacy / BaseAgentYesYesControl passes

Four Google ADK state_delta reproduction cases across Node and legacy runner paths with and without new_message

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.

Google ADK Runner.run_async failing path without new_message and the tested explicit SessionService append_event workaround

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_id as 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:

  1. Check whether issue #6644 is closed and whether a fix is included in your installed release.
  2. 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:

  1. Record the exact google-adk version.
  2. Confirm the call is resuming with invocation_id.
  3. Confirm resumability is enabled for the app.
  4. Check whether new_message is absent.
  5. Re-read session.state after the run; do not rely only on the lack of an exception.
  6. Run the same delta once with and once without new_message.
  7. Check the current status of #6644 and your release notes.
  8. 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

Reproduction assets

  • repro.py: four failure/control cases
  • workaround.py: explicit content-less state-event workaround
  • requirements.txt: pins google-adk==2.6.2
  • README.md: environment, results, and evidence boundary
Topic path / AI Agents

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 →
Xiaobai

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.

Comments

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.

Sign-in required Reviewed before public
Loading the discussion…