agentHost: Make the turn observer the only producer of turn progress - #331047
Draft
roblourens wants to merge 5 commits into
Draft
agentHost: Make the turn observer the only producer of turn progress#331047roblourens wants to merge 5 commits into
roblourens wants to merge 5 commits into
Conversation
Reconnecting to an active turn ran two independent converters over the same response parts: the one-shot `activeTurnToProgress` snapshot and the always-on `_observeTurn` graph. They de-duplicate via `adoptInvocations`, which is keyed on live `ChatToolInvocation` instances — so a tool call that had already settled, and which the snapshot renders as a `toolInvocationSerialized` part, could not be adopted and was emitted a second time as a live invocation. That duplicate lands between the restored markdown prefix and the markdown still streaming into the same response part. The response model only merges a markdown update into an immediately preceding markdown part, so the final answer was split in two — in the observed case mid-word, with the prefix folded into the collapsed activity section and the remainder rendered as a separate response. Record what the snapshot emitted per tool call instead of only the adoptable subset, so per-tool setup can skip a settled tool call that is already fully rendered. Subagent tools are excluded because their setup is what streams the child session's inner tool calls into the response. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reconnecting to an active turn ran two independent converters over the same response parts: the one-shot `activeTurnToProgress` snapshot and the always-on `_observeTurn` graph. Their overlap was managed by three hand-maintained continuity parameters — `adoptInvocations`, `seedEmittedLengths`, and `initialResponsePartCount` — that had to stay in sync between converters evolving independently. Nothing enforced that, and it had already decayed in three places: - a settled tool call could not be adopted (it is a serialized part), so it was emitted twice, and the duplicate split a still-streaming response in two at the reconnect boundary; - `onFileEdits` was never wired on the reconnect path, so a tool that completes with file edits after reconnect is hidden by `finalizeToolInvocation` while its replacement edit pills are never emitted, and the card disappears; - `PendingResultConfirmation` is absent from `activeTurnToProgress`, so such a tool renders nothing at all on reconnect. `_observeTurn` derives progress from state, and `autorunPerKeyedItem` runs synchronously on install over every existing response part, so its first pass already replays the whole accumulated turn. The snapshot was recomputing what the graph produces anyway. Delete the snapshot path. Reconnect now just installs the observer, which happens before `provideChatSessionContent` returns, so progress is populated by the time the session is handed out. All three continuity parameters and the adoption branches go away, as does the cleanup that settled the orphaned snapshot invocation. `onFileEdits` is wired on the reconnect path, which the single-producer path requires. Reconnect is no longer a special case: it is the same code path a live turn takes, so a reconnected response is indistinguishable from one that was watched the whole time. The invariant is now structural rather than maintained by hand. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Code review caught that the subagent exception left the reconnect split in place for completed subagent calls: the guard declined to skip them so their child session would still be observed, but that fell through to `_setupServerToolCall`, which sinks a second live parent invocation. That part lands between the restored markdown prefix and its continuation — the very split the guard exists to prevent. Separate child-session observation from emitting the parent invocation. The invocation is still built so subagent observation has something to drive, but a tool call the snapshot already rendered as a serialized part is no longer emitted a second time. Also trims the inline commentary flagged in review. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Takes the single-producer implementation for the handler: the subagent emission guard added in review is part of the snapshot machinery this branch removes, so there is nothing left to suppress. Keeps both reconnect regression tests, with the settled-subagent case asserting this branch's behavior — one live invocation and contiguous markdown. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Removes the duplicate reconnect snapshot converter so active turns are replayed exclusively through the live observer.
Changes:
- Removes snapshot adoption and progress deduplication bookkeeping.
- Replays accumulated progress through
_observeTurn. - Updates reconnect tests for live tool invocations.
Show a summary per file
| File | Description |
|---|---|
agentHostSessionHandler.ts |
Unifies reconnect and live progress handling. |
agentHostChatContribution.test.ts |
Updates settled-tool expectations. |
agentHostClientTools.test.ts |
Verifies a single client-tool invocation. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (4)
src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts:1330
- Please collapse this implementation narration to one concise line; the observer's contract is already documented at its definition.
// pending request. Its accumulated progress is not
// rebuilt here — `_reconnectToActiveTurn` installs the
// same observable graph the live path uses, whose first
// (synchronous) pass replays everything the turn has
// produced so far into `progressObs`.
src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts:1352
- This comment only restates the marker and the immediately following observer setup. Please remove it rather than narrating the control flow across two lines.
// Marks the session as having an active turn; the
// observer appends into it before we return.
src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts:3411
- Please reduce this step-by-step narration to one concise note; the
lastEmittedlogic already makes the delta behavior clear.
// Emits only what has not been emitted yet, so the first pass carries
// whatever content the part already holds (all of it on reconnect,
// usually nothing on a live turn) and later passes carry deltas.
src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts:4675
- This function JSDoc now exceeds the repository's concise API-comment convention and repeats details visible in
_observeTurn. Please summarize the contract in one sentence.
* There is no separate snapshot step: {@link _observeTurn} derives progress
* from session state, and its first pass runs synchronously over every
* response part the turn has accumulated so far. Reconnecting therefore
* replays the turn from the beginning through exactly the same code path
* that a live turn takes, so a reconnected response is indistinguishable
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Balanced
Base automatically changed from
roblou/fix-agenthost-reconnect-markdown-split
to
main
August 15, 2026 23:11
Resolves the conflicts from #331045 in favor of the single-producer implementation: the snapshot machinery that PR carefully de-duplicated against is removed here, so there is nothing left to skip or adopt. Also fixes a gap review caught in the reconnect `onFileEdits` wiring. That callback runs during the observer's synchronous replay, which happens inside `provideChatSessionContent` — before the ChatModel is created — so `_ensureSnapshotController` found no model and `controller?.addToolCallEdits(...)` silently no-oped. The active turn is not in `_pendingHistoryTurns` either, so edits from tools that completed before the reconnect never reached a checkpoint and Restore Checkpoint had no boundary for that turn. Queue those edits and flush them when the matching model appears; the pills already rendered either way. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.
Follow-up to #331045 (now merged). Draft on purpose — targeted for after the next release branch so it gets bake time in Insiders.
Why
#331045 fixes a real bug but treats a symptom. The underlying problem is that reconnect runs two independent converters over the same response parts:
activeTurnToProgress_observeTurnTheir overlap is managed by three hand-maintained continuity parameters —
adoptInvocations,seedEmittedLengths,initialResponsePartCount— which are positional bookkeeping that must stay in sync between two converters that evolve independently. Every new response-part kind or tool status needs a matching dedup rule on the other side, and nothing enforces it.It had already decayed in four places:
toolInvocationSerialized, whichadoptInvocationscannot represent, so the live graph emitted a duplicate — and that duplicate split a still-streaming response in two at the reconnect boundary.onFileEditswas never wired on the reconnect path. A tool completing with file edits after reconnect getspresentation = HiddenfromfinalizeToolInvocation, and the edit pills meant to replace it are never emitted — the card vanishes.PendingResultConfirmationis missing from theactiveTurnToProgressbranch chain, so such a tool renders nothing at all on reconnect.Items 1 and 2 are what #331045 patches, with two separate guards. Same root cause, four symptoms, and the next part kind added makes a fifth.
What this does
Deletes the snapshot path.
_observeTurnis derived from state, andautorunPerKeyedItemruns synchronously on install, iterating every existing response part — so its first pass already replays the entire accumulated turn. The snapshot was recomputing what the graph produces anyway.Reconnect is now just "install the observer", which happens before
provideChatSessionContentreturns, soprogressObsis populated by the time the session is handed out. All three continuity parameters disappear, along with both guards from #331045, the adoption branches in the three tool-setup functions, and the cleanup that settled the orphaned snapshot invocation.onFileEditsis wired on the reconnect path, which the single-producer path requires (otherwise it would hit symptom 3).activeTurnToProgressis no longer referenced by the handler; it stays exported for the cloud-sandbox read-only path.Reconnect is no longer a special case — it is the same code path a live turn takes, so a reconnected response is indistinguishable from one watched the whole time. The invariant is structural instead of hand-maintained; there is no boundary left to get wrong. The settled-subagent case from symptom 2 needs no special handling here at all: it just falls out.
Restore Checkpoint fix (from review)
Wiring
onFileEditson the reconnect path exposed an ordering problem that code review caught. The callback fires during the observer's synchronous replay, insideprovideChatSessionContent— before the ChatModel is created — so_ensureSnapshotControllerfound no model andcontroller?.addToolCallEdits(...)silently no-oped. Since the active turn is not in_pendingHistoryTurnseither, edits from tools that completed before the reconnect never reached a checkpoint, leaving Restore Checkpoint without a boundary for that turn.Those edits are now queued and flushed when the matching model appears. The pills rendered either way, so only the checkpoint data was affected.
Risk / what to watch in Insiders
ChatToolInvocationobjects for every completed tool instead of plain serialized objects. Same order of magnitude, but a turn with hundreds of tool calls does more work on reconnect.Testing
AgentHostsuite: 2210 passing.npm run typecheck-clientclean. CI green.records edits from a tool that completed before reconnect once the model appearsdrives a realAgentHostSnapshotControllerand assertshasEditsInRequest; verified failing before the checkpoint fix.toolInvocation, which is exactly what a live turn produces for a mid-turn completion.reconnecting to an active turn with owned client tool completes the initial snapshot invocation, whose entire subject was the orphaned-snapshot workaround. With one producer there is no orphan, so it now asserts what actually matters — exactly one invocation, no duplicate.(Written by Copilot)