Skip to content

agentHost: Make the turn observer the only producer of turn progress - #331047

Draft
roblourens wants to merge 5 commits into
mainfrom
roblou/agenthost-single-turn-progress-producer
Draft

agentHost: Make the turn observer the only producer of turn progress#331047
roblourens wants to merge 5 commits into
mainfrom
roblou/agenthost-single-turn-progress-producer

Conversation

@roblourens

@roblourens roblourens commented Aug 15, 2026

Copy link
Copy Markdown
Member

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:

what it is when it runs
activeTurnToProgress one-shot imperative snapshot reconnect only
_observeTurn always-on reactive observable graph live, reconnect, server-initiated

Their 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:

  1. A settled tool call cannot be adopted. The snapshot renders it as toolInvocationSerialized, which adoptInvocations cannot represent, so the live graph emitted a duplicate — and that duplicate split a still-streaming response in two at the reconnect boundary.
  2. The same split for completed subagent tools, which had to be exempted from the first guard so their child session would still be observed. Fixing it needed a second, separate mechanism to decouple observation from emission.
  3. onFileEdits was never wired on the reconnect path. A tool completing with file edits after reconnect gets presentation = Hidden from finalizeToolInvocation, and the edit pills meant to replace it are never emitted — the card vanishes.
  4. PendingResultConfirmation is missing from the activeTurnToProgress branch 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.

_observeTurn is derived from state, and autorunPerKeyedItem runs 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 provideChatSessionContent returns, so progressObs is 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. onFileEdits is wired on the reconnect path, which the single-producer path requires (otherwise it would hit symptom 3).

activeTurnToProgress is 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 onFileEdits on the reconnect path exposed an ordering problem that code review caught. The callback fires during the observer's synchronous replay, inside provideChatSessionContentbefore the ChatModel is created — so _ensureSnapshotController found no model and controller?.addToolCallEdits(...) silently no-oped. Since the active turn is not in _pendingHistoryTurns either, 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

  • Rendering change: settled tools in an active turn now render as finalized live invocations rather than serialized parts. More consistent with live turns, but it is a real change and the unit tests here are mock-driven — worth exercising a real reconnect against a long session with completed tools, file edits, terminals, and subagents.
  • Perf: replaying a long turn now constructs live ChatToolInvocation objects 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

  • Full AgentHost suite: 2210 passing. npm run typecheck-client clean. CI green.
  • records edits from a tool that completed before reconnect once the model appears drives a real AgentHostSnapshotController and asserts hasEditsInRequest; verified failing before the checkpoint fix.
  • Both reconnect regression tests from agentHost: Don't split the final response at the reconnect boundary #331045 carry over, now asserting this branch's behavior — a finalized toolInvocation, which is exactly what a live turn produces for a mid-turn completion.
  • Exactly one pre-existing test needed updating: 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)

roblourens and others added 3 commits August 15, 2026 14:37
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>
Copilot AI balanced review requested due to automatic review settings August 15, 2026 22:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lastEmitted logic 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants