Skip to content

[Bug] adk_events_to_messages returns stale messages after rewind — rewound events are not filtered #2176

Description

@ferponse

Summary

adk_events_to_messages(session.events) iterates all ADK session events without accounting for rewind markers. After Runner.rewind_async() is called, the session still contains the old (now-invalid) events — rewind_async appends a special event with actions.rewind_before_invocation_id instead of deleting the rewound events. Any consumer that calls adk_events_to_messages directly on session.events will therefore receive the stale messages that were supposed to be discarded.

Reproduction

  1. Start a session, send a message, get a response (turn 1).
  2. Call Runner.rewind_async(rewind_before_invocation_id=<turn1_invocation>).
  3. Send the same (or a different) message again (turn 2).
  4. Call adk_events_to_messages(session.events).

Expected: messages from turn 2 only (or turn 1 + turn 2 if only part of turn 1 was rewound).
Actual: messages from both the rewound turn 1 AND turn 2 — the stale messages reappear.

Root cause

Runner.rewind_async does not delete events; it appends a rewind-marker event:

rewind_event = Event(
    invocation_id=new_invocation_context_id(),
    author='user',
    actions=EventActions(
        rewind_before_invocation_id=rewind_before_invocation_id,
        state_delta=state_delta,
        artifact_delta=artifact_delta,
    ),
)
await self.session_service.append_event(session=session, event=rewind_event)

The ADK Runner itself correctly filters these markers before running the LLM (using an internal _filter_rewound_events-style pass). But adk_events_to_messages bypasses that filtering — it does not check event.actions.rewind_before_invocation_id.

Impact

Any endpoint or component that reconstructs message history from session.events via adk_events_to_messages — notably the POST /agents/state endpoint registered by add_adk_fastapi_endpoint — will return incorrect history after a rewind. Frontend clients that use /agents/state to hydrate or display conversation history will show messages the user explicitly chose to discard.

Suggested fix

Apply the same rewind-filtering pass that the ADK Runner uses internally before passing events to adk_events_to_messages:

def filter_rewound_events(events: list) -> list:
    filtered = []
    i = len(events) - 1
    while i >= 0:
        event = events[i]
        if event.actions and event.actions.rewind_before_invocation_id:
            rewind_id = event.actions.rewind_before_invocation_id
            for j in range(i):
                if events[j].invocation_id == rewind_id:
                    i = j
                    break
        else:
            filtered.append(event)
        i -= 1
    filtered.reverse()
    return filtered

# Then:
messages = adk_events_to_messages(filter_rewound_events(session.events))

Alternatively, adk_events_to_messages itself could apply this filtering internally so callers do not need to know about rewind semantics.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions