Repository navigation
state_delta is silently ignored when resuming an invocation without a new_message #6644
Description
🔴 Required Information
Describe the Bug:
Runner.run_async accepts a state_delta argument, documented as "Optional state
changes to apply to the session". When an invocation is resumed by invocation_id
with no new_message, that delta is accepted and then silently discarded — no
warning, no error, no event.
This is the sibling of #5763 / #5767, which fixed the new_message path. The delta
is still only ever applied while appending the user message event, so both dispatch
paths remain gated on if new_message::
- node path —
runners.py:628, feeding_append_user_event(..., state_delta=...) - legacy path —
runners.py:2142in_setup_context_for_resumed_invocation,
feeding_handle_new_message(..., state_delta=...)
Resuming with no new_message is a supported call — run_async explicitly permits it
when the app is resumable and an invocation_id is given (the "A new message is
required when no invocation can be resumed" guard passes). So the caller is using the
API as documented and losing data.
Steps to Reproduce:
- Check out
main@a5864a0e(version.pyreports2.6.2). - Save the script below as
repro.py. python repro.py— it needs no network, API key, or model access.
"""state_delta when resuming an invocation without a new_message.
Covers both dispatch paths in Runner.run_async:
A) node path -> _run_node_async (LlmAgent, a BaseNode)
B) legacy path -> _setup_context_for_resumed_invocation (plain BaseAgent)
"""
import asyncio
from typing import AsyncGenerator
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.llm_agent import LlmAgent
from google.adk.apps.app import App, ResumabilityConfig
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.events.event import Event
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
USER, SESSION = "u1", "s1"
class EchoLlm(BaseLlm):
"""Minimal offline model so the repro needs no network or API key."""
model: str = "echo"
async def generate_content_async(
self, llm_request, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
yield LlmResponse(
content=types.Content(role="model", parts=[types.Part(text="ok")])
)
class EchoAgent(BaseAgent):
"""Plain BaseAgent (not a BaseNode) -> takes the legacy runner path."""
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
yield Event(
invocation_id=ctx.invocation_id,
author=self.name,
content=types.Content(role="model", parts=[types.Part(text="ok")]),
)
async def check(app_name: str, label: str, agent: BaseAgent) -> bool:
session_service = InMemorySessionService()
runner = Runner(
app=App(
name=app_name,
root_agent=agent,
resumability_config=ResumabilityConfig(is_resumable=True),
),
session_service=session_service,
artifact_service=InMemoryArtifactService(),
)
await session_service.create_session(
app_name=app_name, user_id=USER, session_id=SESSION
)
# Turn 1: a normal run, so there is an invocation to resume.
async for _ in runner.run_async(
user_id=USER,
session_id=SESSION,
new_message=types.Content(role="user", parts=[types.Part(text="hi")]),
):
pass
session = await session_service.get_session(
app_name=app_name, user_id=USER, session_id=SESSION
)
invocation_id = session.events[0].invocation_id
# Turn 2: resume by invocation_id, with a state_delta and NO new_message.
async for _ in runner.run_async(
user_id=USER,
session_id=SESSION,
invocation_id=invocation_id,
state_delta={"resumed_key": "resumed_value"},
):
pass
session = await session_service.get_session(
app_name=app_name, user_id=USER, session_id=SESSION
)
applied = session.state.get("resumed_key") == "resumed_value"
print(f" {label:<28} state={dict(session.state)!r:<34} applied={applied}")
return applied
async def main():
print("resume by invocation_id + state_delta, no new_message:")
a = await check(
"app_node", "A) node path (LlmAgent)", LlmAgent(name="root", model=EchoLlm())
)
b = await check(
"app_legacy", "B) legacy path (BaseAgent)", EchoAgent(name="root")
)
print(f"\nRESULT: {'PASS' if (a and b) else 'FAIL (state_delta dropped)'}")
asyncio.run(main())Expected Behavior:
The supplied state_delta is applied to session.state and persisted as an
EventActions payload, the same as it is when a new_message is present.
Observed Behavior:
The delta is silently dropped on both paths. session.state stays empty.
resume by invocation_id + state_delta, no new_message:
A) node path (LlmAgent) state={} applied=False
B) legacy path (BaseAgent) state={} applied=False
RESULT: FAIL (state_delta dropped)
Environment Details:
- ADK Library Version:
main@a5864a0e(version.py=2.6.2) - Desktop OS: macOS
- Python Version: 3.13.7
Model Information:
- Are you using LiteLLM: No
- Which model is being used: N/A — the repro uses an offline stub model, so the bug is
independent of the model.
🟡 Optional Information
Regression:
Not a regression on this specific path — resuming without a new_message has never
applied the delta. #5763 was the new_message half of the same root cause, fixed in
#5767; this is the remaining half.
Suggested Fix:
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
(runners.py:1373-1381).
Happy to send the PR — I have the fix, a unit test that fails without it, and E2E
evidence for both paths.
How often has this issue occurred?:
- Always (100%)
Activity
surajksharma07 commentedon Aug 11, 2026
@lwangverizon Confirmed on main (v2.6.3) with your repro — both dispatch paths gate state_delta behind if new_message, so resuming by invocation_id alone drops it silently.
Workaround: wrapping run_async to append a content-less Event(author="user", actions=EventActions(state_delta=...)) after the fact patches it on our end, same shape your rewind path already uses.
Could you confirm that holds up on your setup too before raising anything separate?
#6645 already covers this properly at the source, so this is really just to sanity-check the behavior matches what you expect!
surajksharma07 commentedon Aug 26, 2026
@lwangverizon closing for now but not because anything's unresolved. The bug's confirmed and #6645 already fixes it cleanly (tested, zero regressions) but it's gone stale and now has conflicts against main. No need to wait on my sanity-check question. If you get a chance to rebase it or a maintainer wants to review/merge it as-is that's the real next step. Reopen or comment here once it lands.
fix: apply state_delta when resuming without a new_message