Repository navigation
Description
Description
1. Current behavior
_save_thread_snapshot is invoked only at run-completion sites in _agent_run.py (the confirmation-turn path and the main RUN_FINISHED path). A harness agent's Execute todo auto-loop is one AG-UI run that can span many model roundtrips and minutes of tool execution. For its whole duration, the last persisted snapshot is the previous run's end state.
When the client disconnects mid-run (mobile screen-off, page reload, network blip), the SSE generator is aborted (http.disconnect), so the completion code — including the snapshot save — never executes. Meanwhile the underlying agent work can continue running detached.
2. Observed live (Azure Container Apps deployment, harness + AG-UI)
Case A — hard data loss + discarded work (2026-07-14, ~21:35–22:02 UTC). A user refreshed the page ~6 minutes into a long Execute run:
- the stream died; no
Completed streamingwas ever logged for the run — the snapshot save was skipped; - the reloaded client hydrated the previous run's snapshot: the conversation visibly rolled back to the end of planning; even the user's plan-approval message was gone;
- the detached run kept executing for many more minutes (event counter reached 3459+ after the disconnect;
file_access_write/file_memory_writetool calls kept succeeding up to ~20 minutes later) — tokens billed, results appended to a session no snapshot will ever reference. Worst of both worlds: neither cancelled nor persisted.
Case B — stale-window confusion (2026-07-15, 14:27–14:32 UTC). Screen-off killed the SSE during a 5196-event run; the restored tab reloaded 18 seconds before the run finished and hydrated the previous run's snapshot (including its already-superseded pending-approval interrupt). The run then completed and saved correctly; the user saw "all progress gone" and a stale interrupt card until a later manual refresh. Recoverable — but indistinguishable, from the user's point of view, from case A.
3. Requested
Any of the following (in descending order of preference; they compose):
- Incremental snapshot saves at safe boundaries. Persist the thread snapshot after each model roundtrip / executed tool-result batch inside the run loop (optionally throttled, e.g. at most once per N seconds). The snapshot format needs no change — it is the same messages/state/interrupt/session_state payload, just written more often. Hydration then returns the latest roundtrip instead of the previous run's end.
- Deterministic disconnect semantics. On client disconnect, either (a) detach and run to completion including the final snapshot save (the save must not live only inside the event-stream generator), or (b) cancel the run promptly. Either is defensible; continue-then-discard is not. A config knob (
on_client_disconnect: "complete_and_save" | "cancel") would cover both camps. - A snapshot-save hook/config on the endpoint (e.g.
snapshot_save_mode: "run_end" | "roundtrip", or a callback) so hosts can opt into incremental persistence without the framework changing its default.
4. Why it matters
The harness positions AG-UI agents for long-running autonomous loops; at that duration, client-connection lifetime and run lifetime diverge routinely (mobile browsers suspend tabs aggressively). Today the durability contract is "nothing since the last completed run", which for a 5–30 minute auto-loop run means the entire turn. With #7084, private Session Continuation State shares the same fate. Server-side the work usually succeeds — the loss is purely a persistence-timing artifact.
5. Workarounds evaluated (application-side)
- Post-reload polling of the snapshot store: repairs case B only, after the run eventually completes.
- Periodic app-initiated saves are not possible: the endpoint owns the snapshot lifecycle and the authoritative in-run message list; a host writing snapshots concurrently would race the endpoint's own run-end save.
- Keep-alive tricks cannot help: the disconnect originates client-side (tab suspension), not from idle timeouts.
Code Sample
Language/SDK
Both
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
[-][Feature]: persist AG-UI thread snapshots incrementally (and handle client disconnect), not only at run completion[/-][+]Python: [Feature]: persist AG-UI thread snapshots incrementally (and handle client disconnect), not only at run completion[/+]antsok commentedon Aug 22, 2026
Since filing this we have hit a concrete data-loss defect in the same area, and it does not depend on the feature this issue asks for. Would you consider reclassifying this as a bug — or splitting it, keeping the incremental-persistence request as a feature and tracking the following separately?
The failed behaviour
A completed run's stored transcript can be silently and permanently replaced by a shorter one. No error, no warning, and the loss is only visible later, as a conversation that is missing turns.
The AG-UI thread snapshot is written as the request's message list plus the current run's output. The stored messages are never consulted:
So whenever a client posts with a transcript shorter than the stored one, the shorter list becomes the thread's record.
What makes this look unintended rather than by design is the asymmetry with
state, which is merged in the same file:State reconciles stored with request. Messages replace outright.
The rest of the design reads as assuming exactly two client positions —
hydrate_eventsserves the stored snapshot when a request carries no messages, and the request is treated as authoritative when it carries some. A client that carries some messages but is behind the stored snapshot is a third position the protocol has no way to express, and the server resolves it by trusting the client.Minimal reproduction (no custom host, no disconnect, no error path)
Tin client A and run a turn to completion. The stored snapshot now holds turns 1..N.No crash and no dropped connection are required; the ordinary success path is sufficient. A dropped connection makes it easier to reach, because a client that misses events is exactly a client whose transcript is behind, but it is not a precondition.
Impact
Completed agent work disappears from a thread with no error surfaced to the user or the application. It is worse the longer a run is, since the window in which another surface can be behind grows with the run — which is what makes it hurt most for the long tool-using runs this framework is built for.
How it actually reached us: a mobile client and a long run
The two-client reproduction above is the minimal form. What put us here was a phone, and it is worth describing because it is the ordinary case rather than a contrived one.
A user started a long analysis from Android Chrome and switched away. The tab was backgrounded and eventually discarded. On mobile this does not produce a clean, immediate disconnect: the browser stops running the page's JavaScript well before the server learns the peer is gone, and when the fetch is eventually aborted the client finalises the run on its own side. So the client's transcript stops at whatever it had already processed, while the server carries on and commits the completed run. Both sides believe they are consistent; neither is.
The user came back nearly two hours later to a transcript frozen at the moment the tab went away, and sent a message asking why it had stopped. That message carried the frozen transcript. When the run ended, the thread's stored record became the frozen list plus that turn — and roughly forty-four minutes of completed work (224 model round trips, 387 tool calls, ~22M input tokens, two delegated sub-agents, files written to durable storage) stopped being part of the conversation. It had been in the store for over an hour before the next request replaced it.
That last turn happened to fail on a provider error, so in our case the write came through our own crash-recovery path rather than the normal one. It changes nothing about the outcome: both paths persist the request's list plus the run's output, so a turn that succeeded would have produced exactly the same record. We mention it only so the failure is not mistaken for an error-handling defect — it is the ordinary save.
Nothing in that sequence is exotic: background a tab on a phone during a long agent run, come back, and type. The only reason the work existed to be lost is that our host detaches runs from the request so a suspended tab does not abandon an analysis in progress. That is our addition and we are not asking you to adopt it — but it is worth being precise about what it does and does not explain. The detach is why there was completed work in the store; it is not why the store lost it. The loss happens in the save path, which never consults what it is overwriting, and the minimal reproduction above reaches it on stock with no detaching, no disconnect and no error at all.
The mobile angle also bears on the original request in this issue: without detaching, the same suspended tab simply kills the run, and because the end-of-run save sits inline before
RUN_FINISHEDrather than in afinally, everything it streamed is discarded. Either way, a phone going to sleep costs the user the run — once by losing it in flight, once by overwriting it afterwards.Precedent in this repo
PR #7242 (for #7211) taught
save_messagesto reconcile its input against what the store already holds, viaget_message_identity/filter_new_messages, instead of appending blindly. That is the same move — a store reconciling rather than trusting its input — applied to the history provider. The AG-UI snapshot store has no equivalent.Possible directions
Either would close it:
effective_statealready does for state.On the original scope
The incremental-persistence and disconnect-handling request in this issue still stands on its own and is genuinely feature-shaped, so splitting is fine if that is cleaner for triage. Worth noting for that half:
checkpointappears throughout the workflow path and not at all in the agent run path, so the durability concept already exists in the framework — the ask is to extend it across that boundary rather than to invent it.Environment: agent-framework-core: 1.14.0