XBSTACK XBSTACK
Xiaobai

Xiaobai

Developer · Builder

Building AI engineering systems, developer tools and long-term digital assets at XBSTACK.

About Xiaobai & XBSTACK →
Google ADK 2.6.2 state_delta not applied to session.state when Runner.run_async resumes without new_message

Google ADK Resume Bugs: state_delta Loss and the 2.7.0 A2A HITL Regression

Google ADK state_delta not applied: reproduce the 2.6.2 state-only resume loss and compare the 2.6.1 vs 2.7.0 A2A HITL message-conversion regression.

Published · 2026-08-097 min readXBSTACK
#Google ADK#Runner.run_async#state_delta#invocation_id#SessionService#Resumability#A2A#Human-in-the-loop

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"

August 14 update: ADK 2.7.0 has a separate A2A approval-resume regression

I did not create a second article for this new upstream report. It still belongs to the same production question — whether a paused workflow actually resumes with the semantics the caller approved — but the failure point is different. The earlier bug in this article is about Runner.run_async(state_delta=...); this new case is about how RemoteA2aAgent serializes a relayed human-in-the-loop response.

I reran the minimal message-construction case behind upstream issue #6721 completely offline. The comparison pins a2a-sdk==0.3.26 and gives RemoteA2aAgent._create_a2a_request_for_user_function_response() the same two events: a relayed adk_request_confirmation function call from the remote peer and the user’s FunctionResponse for the same call id.

VersionPart sent back to the peerFunction-response semantics preserved?
google-adk==2.6.1DataPart with metadata.adk_type=function_responseYes
google-adk==2.7.0TextPart containing JSON textNo

On 2.6.1 the outbound part retains the call id, function name and structured response. On 2.7.0 the same input becomes plain text. So the local result is narrow but important: on this message-construction path, 2.7.0 no longer sends the answer to the remote pause as a function response; it sends text instead.

Do not merge that finding with the 2.6.2 state_delta bug. They share the word “resume,” but they have different APIs, roots and version boundaries. I also did not run a full three-agent A2A deployment here, so this local test does not claim that every 2.7.0 workflow skips the gated business tool. What it proves is the protocol-shape change between 2.6.1 and 2.7.0.

If A2A human approval is production-critical, add a regression assertion before upgrading: after the human answers a relayed pause, the outbound peer message must still contain a function response correlated to the original call id rather than only a text blob. A parent workflow continuing to run is not sufficient evidence that the remote approval actually resumed.

The comparison script and machine-readable results live in experiments/google-adk-a2a-relayed-hitl-resume-repro/, including logs/2.6.1.json, logs/2.7.0.json, and logs/verification.json.

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.

More to Explore

Topic hub →
AI Agent Data Analysis in Practice: Building an Automated Financial Research and Decision SystemAI Agent Data Analysis in Practice: A detailed guide to the engineering applications of AI agents in data analysis, covering automated workflows, tool invocation, secure sandboxesSemantic Kernel Tutorial: Plugins, Function Calling, and Current Agent BoundariesSemantic Kernel plugins tutorial covering KernelFunction, automatic function calling, dependency injection, authorization, and the Agent Framework migration boundary.OpenAI Responses API: Why Stream Abort Causes No tool call found for function call outputA 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 ManagementPractical 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

AI Engineering Weekly

Production changes, real failures, experiments and new XBSTACK assets.

Comments & evidence

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…