🔴 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:2142 in _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.py reports 2.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?:
🔴 Required Information
Describe the Bug:
Runner.run_asyncaccepts astate_deltaargument, documented as "Optional statechanges to apply to the session". When an invocation is resumed by
invocation_idwith no
new_message, that delta is accepted and then silently discarded — nowarning, no error, no event.
This is the sibling of #5763 / #5767, which fixed the
new_messagepath. The deltais still only ever applied while appending the user message event, so both dispatch
paths remain gated on
if new_message::runners.py:628, feeding_append_user_event(..., state_delta=...)runners.py:2142in_setup_context_for_resumed_invocation,feeding
_handle_new_message(..., state_delta=...)Resuming with no
new_messageis a supported call —run_asyncexplicitly permits itwhen the app is resumable and an
invocation_idis given (the "A new message isrequired when no invocation can be resumed" guard passes). So the caller is using the
API as documented and losing data.
Steps to Reproduce:
main@a5864a0e(version.pyreports2.6.2).repro.py.python repro.py— it needs no network, API key, or model access.Expected Behavior:
The supplied
state_deltais applied tosession.stateand persisted as anEventActionspayload, the same as it is when anew_messageis present.Observed Behavior:
The delta is silently dropped on both paths.
session.statestays empty.Environment Details:
main@a5864a0e(version.py=2.6.2)Model Information:
independent of the model.
🟡 Optional Information
Regression:
Not a regression on this specific path — resuming without a
new_messagehas neverapplied the delta. #5763 was the
new_messagehalf 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?: