fix: stop dropping assistant turns across TUIs, remote sessions and recovery - #4493
Merged
Merged
Conversation
Make event delivery reliable end to end: app subscriptions track generation lifetimes so stale events can't leak into a new session, both TUIs reconcile messages by ID instead of blindly appending so startup and reconnects can't duplicate or lose text, and the remote runtime recovers from streaming timeouts and event-log gaps by replaying from a safe cursor instead of guessing. Synthetic answers now get a message ID so they can be tracked the same way.
Add chat.VisibleAssistantContent so synthetic answers, lean TUI session reload, and remote recovery never surface raw <tool_call> payloads that were already hidden from the live stream. Assisted-By: docker-agent
A dedicated event lets consumers distinguish an authoritative idle reset from a stream stop, so recovery doesn't fire stop-triggered actions. Wires it into the SSE client decoder. Assisted-By: docker-agent
…r origin context LocalRuntime can now register context-aware background and elicitation sinks (OnBackgroundEventWithContext, OnElicitationRequestWithContext), and App uses them when available so a detached background task or elicitation keeps the conversation it started in, even across session replacement. Per-subscriber queues now carry a generation so stale queued deliveries are discarded on retirement without racing newly accepted ones. Assisted-By: docker-agent
…tations remoteMessageHistory now tracks nested stream depth and byte/message counts and fails recovery closed instead of replaying once any limit is exceeded, release message buffers as soon as a stream completes, and reconcile against suppressed (non-tool) content. OAuth elicitations are tracked per elicitation ID instead of a single shared pointer, serialized so concurrent accepts can't double-authorize, and cleared on root SessionRecovered without touching detached sub-session requests. A failed background subscription can now restart on the next call instead of sticking around as a dead placeholder. Assisted-By: docker-agent
A new snapshotBoundary mutex is held while copying session messages and event cursor so a concurrent recall can't start a run (and move both) mid-copy; recall now waits on the same boundary instead of racing it. Snapshots clone the session and recheck the event cursor so background events landing between the streaming check and the copy are reflected as still-streaming rather than silently dropped. Assisted-By: docker-agent
Both TUIs and tabstate now clear nested stream depth, spinners, and transfer/compaction presentation on SessionRecovered without running stop-triggered actions (draining the queued-message buffer, firing attention) and without discarding elicitations detached to a sub-session, since root idleness doesn't mean they completed. Assisted-By: docker-agent
Derive a dedup key from session/message/artifact instead of resolution order, so re-resolving, restoring from a session reload, and replaying canonical content that interleaves with other messages all join the same placeholder instead of appending duplicates. Legacy (ID-less) canonical replay adopts the identity of the already-restored manifest entry it matches. Assisted-By: docker-agent
redrawSuffix now diffs against the previous frame and only re-emits offscreen rows that actually changed before restoring the visible tail, so an answer pushed into scrollback by unrelated tool-timer churn is archived once instead of duplicated on each tick. Assisted-By: docker-agent
Signed-off-by: David Gageot <david.gageot@docker.com>
trungutt
approved these changes
Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Assistant replies could go missing in several related ways: a message dropped between the control plane and a TUI, tool-call XML leaking into canonical or reloaded text, generated media losing its identity on redraw, and remote sessions losing history across idle recovery, OAuth elicitation, or a snapshot taken mid-turn. All of these share the same root cause — events and context getting detached from the turn that produced them, or state being reconstructed from an incomplete view of history.
This change makes delivery of assistant turns lossless end to end. The control plane and TUI subscribers now track generations with an explicit startup handshake so a client can't miss events emitted before it finished subscribing, and detached background or elicitation goroutines keep running against their originating context instead of a request-scoped one that may already be cancelled. Canonical assistant text strips tool-call XML and gets a stable message identity so it displays consistently whether it's streamed live or reloaded from storage, and generated media keeps that same identity across redraws instead of being treated as new content. The lean TUI's offscreen renderer no longer replays the full suffix on every redraw, just the delta. On the remote runtime side, a new SessionRecovered event marks idle recovery as a reset rather than a stop, history pulled during recovery is bounded and correlated with any pending OAuth elicitation, and the server holds an idle snapshot boundary across GetSessionSnapshot so a snapshot taken concurrently with recovery can't observe a half-written session. Streaming reconnects no longer time out after 30 seconds, and a dropped connection can resume from the last cursor instead of starting over.
Foreground network interruptions during an active turn still fail loudly — they error back to the user rather than silently resubmitting a tool call, since transient tool or interaction state from that turn generally can't be reconstructed safely. That's an intentional boundary, not a gap this change tries to close.