Repository navigation
Description
summary
on an AG-UI resume request, agent-framework-ag-ui prepends the stored thread snapshot to RunAgentInput.messages and persists the concatenation without de-duplication. a client that replays its transcript on resume, which is what the reference AG-UI client does, therefore has the interrupted turn written into the stored snapshot twice. after a refresh the duplicated assistant tool-call has no matching tool result, so an approval UI reopens for a call that already ran.
MAF already has both defences needed here; the resume path just does not use them.
why this is a host-side issue
the AG-UI interrupt spec does not define what messages carries on a run that also carries resume. its contract rules (docs/concepts/interrupts.mdx) constrain the thread id, interrupt coverage, idempotency, payload validation, and expiry, and none of them mentions messages. so both readings are conformant, and clients differ.
the reference TypeScript client sends the full transcript. AbstractAgent.prepareRunAgentInput (sdks/typescript/packages/client/src/agent/agent.ts, read at @ag-ui/[email protected] = 9f393021e) spreads resume on top of an input whose messages is this.messages with only role !== "activity" filtered out. there is no resume-aware trimming anywhere on that path. so HttpAgent pointed at a MAF endpoint reproduces this with no third-party code involved.
for contrast, the other resume-aware AG-UI integrations at that ref (langgraph py+ts, mastra, crew-ai, aws-strands py+ts) all short-circuit before reading messages on a resume, and the stateful ones de-duplicate by message id, with comments naming full-transcript replay as the expected steady state. MAF's messages: [] expectation is reasonable, but it is currently enforced by nothing.
mechanism
all line numbers at 05911fea70b5cf1b0728b186faa8d36883d086c9, package agent-framework-ag-ui.
_snapshot_session.py:134-142concatenates without filtering:
def resume_seeded_messages(self, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Prepend copies of stored thread history to a resume request's messages.
Resume requests carry only the synthesized interrupt response; seeding
with stored history keeps the persisted thread from being truncated.
"""
if self._stored is None:
return incoming
return [copy.deepcopy(message) for message in self._stored.messages] + incomingthe docstring states the assumption the concatenation depends on, and nothing checks it.
_agent_run.py:2481-2499routes resume around the reconciler every other path uses:
if stored_snapshot is not None:
if resume_payload is not None and stored_pending_approval_interrupt_ids:
seeded_resume_from_snapshot = True
if not config.use_service_session:
raw_messages = snapshot_session.resume_seeded_messages(raw_messages)
...
elif not config.use_service_session:
raw_messages = _reconstruct_messages_from_thread_snapshot(
stored_messages=stored_snapshot.messages,
incoming_messages=raw_messages,
stored_interrupt=stored_snapshot.interrupt,
)the elif branch is exactly the "client re-sent history" defence: _reconstruct_messages_from_thread_snapshot (_run_common.py) finds the overlap between stored and incoming, trims the matched prefix, and filters the untrusted suffix. the resume branch above it never reaches that. note the service-session resume path does delegate to the same reconstructor via _split_service_session_input, so the gap is confined to not config.use_service_session resumes (and the equivalent workflow resume in _workflow.py).
_message_adapters.py:576-581de-duplicates the provider-facing list and then builds the snapshot from the raw argument, bypassing it:
provider_messages = agui_messages_to_agent_framework(messages)
if sanitize_tool_history:
provider_messages = _sanitize_tool_history(provider_messages, protected_tool_call_ids=protected_tool_call_ids)
provider_messages = _deduplicate_messages(provider_messages)
snapshot_messages = agui_messages_to_snapshot_format(messages)
return provider_messages, snapshot_messages_deduplicate_messages (:292) keys tool results by (role, call_id), assistant tool-calls by their sorted call-id tuple, and everything else by message_id. agui_messages_to_snapshot_format (:1227) is a strict 1:1 per-message normalizer with no keying at all.
that split is why the symptom is invisible during the run and only appears after a reload: the model call sees a clean list, while the persisted snapshot and the outbound MESSAGES_SNAPSHOT keep the duplicate.
reproduction
- serve an agent through
agent-framework-ag-uiwith snapshot persistence enabled and a tool that requires approval. - drive it with a client that replays its transcript on resume. plain
@ag-ui/clientHttpAgentdoes this by default; no configuration needed. - run a turn that interrupts on the tool call, then resume with
resume: [{ interruptId, status: "resolved", payload: { approved: true } }]and the transcript still inmessages. - the tool runs and the turn completes normally.
- hydrate the thread (
threadId,messages: [], noresume).
expected: one user turn and one assistant tool-call, resolved.
actual: the user turn and the assistant tool-call appear twice; the duplicate assistant tool-call has no tool result, and interrupt is null. a client rendering approval state from the snapshot reopens the gate for a call that already executed.
suggested fix
route the resume branch through the reconciler the sibling branch already uses, so a replayed transcript is detected and trimmed rather than assumed absent. _reconstruct_messages_from_thread_snapshot already computes the stored-vs-incoming overlap and accepts an id match, which is precisely this case.
failing that, applying _deduplicate_messages to snapshot_messages as well as provider_messages in _message_adapters.py would keep the duplicate out of persistence, though it leaves the model-facing and snapshot lists derived by two different rules.
either way, since the spec does not mandate messages: [] on resume, treating it as a precondition rather than a hint means a conformant client can corrupt the stored thread.
secondary observation, not verified by running it
_agent_run.py:3239 seeds again at save time when resume_payload is not None and not seeded_resume_from_snapshot and snapshot_seed_messages is None. a resume arriving with stored_pending_approval_interrupt_ids already empty takes the elif at :2494, so latest_messages_snapshot would already contain stored history, and this second seeding would prepend it again. that reads as a self-inflicted duplication independent of what the client sends, but it is a code reading only.
refs read
microsoft/agent-frameworkat05911fea70b5cf1b0728b186faa8d36883d086c9,python/packages/ag-ui(agent-framework-ag-ui1.2.2)ag-ui-protocol/ag-uiat9f393021ecbd6bcc62dc4b1ad5a6ac4b797565f4(tags@ag-ui/[email protected]and@ag-ui/[email protected])
downstream report that surfaced this: assistant-ui/assistant-ui#7011.
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
feat(react-ag-ui): let the host decide what a resume run replays (#7021)
github-actions commentedon Sep 8, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
The defect occurs in
python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py::_run_agent_implaround lines 2481-249, where a non-service-session approval resume seeds stored messages without reconciling a replayed incoming transcript. Minimal repro: store a user message and assistant tool call, resume with those same messages and the pending call protected, then normalize the seeded input; provider output has two messages while snapshot output contains four.python/packages/ag-ui/tests/ag_ui/test_snapshot_session.py::TestResumeSeedMessages::test_replayed_transcript_is_not_duplicated1.2.21.2.2ktz03 commentedon Sep 8, 2026
Taking this — will route the non-service-session resume path through the existing thread-snapshot reconstructor (same defence as the non-resume branch) so client-replayed transcripts are not double-persisted.
fix(ag-ui): dedupe client-replayed transcripts on resume (microsoft#8140
fix(ag-ui): restore resume_seeded prepend; fold microsoft#8140 tests
test(ag-ui): type folded microsoft#8140 snapshot replay fixture for mypy
Python: fix(ag-ui): dedupe client-replayed transcripts on resume (mic…