Skip to content

fix: stop dropping assistant turns across TUIs, remote sessions and recovery - #4493

Merged
dgageot merged 10 commits into
docker:mainfrom
dgageot:worktree-board-ad6d9cab2b9cc894
Oct 1, 2026
Merged

dgageot merged 10 commits into
docker:mainfrom
dgageot:worktree-board-ad6d9cab2b9cc894

Conversation

@dgageot

@dgageot dgageot commented Oct 1, 2026

Copy link
Copy Markdown
Member

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.

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
@dgageot
dgageot requested a review from a team as a code owner October 1, 2026 09:56
@aheritier aheritier added area/core Core agent runtime, session management area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/tui For features/issues/fixes related to the TUI kind/fix PR fixes a bug (maps to fix:). Use on PRs only. labels Oct 1, 2026
Signed-off-by: David Gageot <david.gageot@docker.com>
@dgageot
dgageot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into docker:main with commit 3f285a9 Oct 1, 2026
22 checks passed
@dgageot
dgageot deleted the worktree-board-ad6d9cab2b9cc894 branch October 1, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Core agent runtime, session management area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/tui For features/issues/fixes related to the TUI kind/fix PR fixes a bug (maps to fix:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants