Skip to content

agentHost: retire the optimistic turn start on the first backend action - #332122

Open
Ryan Ewen (RyanEwen) wants to merge 1 commit into
microsoft:mainfrom
RyanEwen:fix/optimistic-turn-start-masks-streaming
Open

agentHost: retire the optimistic turn start on the first backend action#332122
Ryan Ewen (RyanEwen) wants to merge 1 commit into
microsoft:mainfrom
RyanEwen:fix/optimistic-turn-start-masks-streaming

Conversation

@RyanEwen

Copy link
Copy Markdown
Contributor

Fixes #332087

Problem

ChatStateSubscription applies the client's own chat/turnStarted optimistically and holds it in _pendingActions until the backend echoes it back carrying the originating clientSeq. The agent host does not send that clientSeq, so the reconcile takes the non-origin branch and the pending start is never retired.

_recomputeOptimistic() rebuilds the optimistic state on every confirmed action by replaying the pending list over confirmed state:

let state = confirmed;
for (const pending of this._pendingActions) {
	state = chatReducer(state, pending.action as IProtocolChatAction, this._log);
}

Replaying a turn start resets activeTurn to a fresh empty turn, so activeTurn.responseParts reads as empty no matter how many deltas have already been folded into confirmed state. The UI reads the optimistic state, so _observeTurn resolves the turn by id but sees no parts, autorunPerKeyedItem never creates a per-part observer, and _setupMarkdownPart is never called. On completion the turn moves into state.turns, which a replayed turn start does not touch, and the whole reply appears in a single step.

Measured in a live window with the renderer instrumented, on a prompt producing about 2.4 KB of markdown and no tool calls: 17 chat/delta envelopes arrived between 8.0s and 19.4s and all 17 passed the channel filter, confirmed activeTurn.responseParts.length was 1 from 8.0s onward, and the optimistic rebase rewrote it to 0 on all 19 recomputes. The markdown observer was created at 19.4s and emitted all 2,432 characters at once.

_promotePendingTurnStartIfTerminal already exists because the echo can arrive without the clientSeq, but it fires only for chat/turnComplete, chat/turnCancelled and chat/error. That is why the text lands all at once at the end: the promotion happens, just not until the turn is over.

Change

Promote the pending turn start on the first backend action naming the turn instead of only on a terminal one, and rename the method to match.

Once any backend action for a turn arrives, the server has that turn, so the optimistic start is redundant and holding it can only mask confirmed state. The body is otherwise unchanged: the start is still applied to confirmed state when the backend has not created the turn itself, so an action that arrives before any server-side chat/turnStarted still has a turn to land on.

Nothing changes on the origin branch, where the echo does carry the clientSeq and the pending action is spliced as before, and nothing changes for a chat action without a turnId.

How to test

  1. In a Claude agent-host session, send a prompt that produces a long reply and no tool calls, for example "without using any tools, write a numbered list from 1 to 20 with one sentence per line about database indexing".
  2. Watch the response area while it runs.

The text appears incrementally as it is generated. On main the response area stays empty for the whole turn and the entire reply appears at once when the turn completes. Same prompt and build, before and after: 2 DOM updates becomes 13, the markdown observer is created at 7.8s instead of 19.4s, and the renderer emits 16 times across 12 seconds instead of once.

Unit coverage adds one test asserting that a chat/responsePart and chat/delta arriving while the optimistic turn start is still pending are visible in the subscription value. It fails on main and passes with this change.

AI disclosure: this pull request and the related code were written with the assistance of AI.

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

Fixes delayed agent-host response rendering by retiring stale optimistic turn starts as soon as the backend acknowledges the turn.

Changes:

  • Promotes pending turn starts on the first backend action containing the matching turn ID.
  • Adds regression coverage for streamed markdown visibility.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
agentSubscription.ts Updates optimistic turn reconciliation.
agentSubscription.test.ts Tests incremental response-part rendering.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

The client holds its own chat/turnStarted in _pendingActions until the
backend echoes it with the originating clientSeq, which the agent host
does not send. The start stays pending for the whole turn, and
_recomputeOptimistic replays it over confirmed state on every action,
resetting activeTurn to a fresh empty turn. The UI reads the optimistic
state, so a streaming response renders nothing until completion moves
the turn into turns[] and the whole reply appears at once.

Promote the pending start on the first backend action naming the turn
rather than only on a terminal one.
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.

Agent host: responses render only at turn end because the optimistic turn start is never retired

4 participants