fix: don't stream chat progress into a completed response (fixes #326647)#326650
fix: don't stream chat progress into a completed response (fixes #326647)#326650vs-code-engineering[bot] wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot could not run the full agentic suite for this review because it was automatically requested on a bot-authored pull request. Request a review from Copilot under Reviewers to retry with the full agentic suite. Improved support for bot-authored pull requests is coming soon.
Guards chat progress processing to avoid exceptions when contributed sessions emit progress after a response has already completed.
Changes:
- Adds an
isCompletecheck to skip processing progress once the response is completed. - Expands the inline comment to document the race/tick scenario and why the guard is needed.
| // progressObs emitting further items after its response has already been | ||
| // completed (e.g. progress and completion delivered across separate ticks): | ||
| // acceptResponseProgress throws when the response is complete. | ||
| if (lastRequest && !lastRequest.response?.isComplete && progressArray.length > lastProgressLength) { |
There was a problem hiding this comment.
Good catch — made the intent explicit by replacing !lastRequest.response?.isComplete with lastRequest.response && !lastRequest.response.isComplete, so trailing progress is only forwarded when the response exists and is not yet complete. Pushed to the PR branch.
Generated by errors-fix-driver · 702.4 AIC · ⌖ 33.4 AIC · ⊞ 72.9K
…check explicit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Commit pushed:
|
| // Process only new progress items. Guard against a contributed session's | ||
| // progressObs emitting further items after its response has already been | ||
| // completed (e.g. progress and completion delivered across separate ticks): | ||
| // acceptResponseProgress throws when the response is complete. | ||
| if (lastRequest && lastRequest.response && !lastRequest.response.isComplete && progressArray.length > lastProgressLength) { | ||
| const newProgress = progressArray.slice(lastProgressLength); | ||
| for (const progress of newProgress) { | ||
| model?.acceptResponseProgress(lastRequest, progress); |
| // progressObs emitting further items after its response has already been | ||
| // completed (e.g. progress and completion delivered across separate ticks): | ||
| // acceptResponseProgress throws when the response is complete. | ||
| if (lastRequest && lastRequest.response && !lastRequest.response.isComplete && progressArray.length > lastProgressLength) { |
Summary
For contributed/streamed chat sessions (e.g. Copilot cloud sessions),
ChatServiceImplruns a singleautorunthat mirrors a provider-suppliedprogressObsinto the model viamodel.acceptResponseProgress(...). When the provider's observable emits additional progress items after the response has already been completed (progress and completion delivered on separate ticks), the autorun forwards them into an already-completedChatResponseModel, which throwsacceptResponseProgress: Adding progress to a completed response. The throw surfaces as an unhandled error in the Agents (sessions) window. Impact is a noisy uncaught error (158 hits / 109 users in 1.129.1) with no user-facing recovery.Fixes #326647
Recommended reviewer:
@osortegaCulprit Commit
The bucket has non-zero hits in every tracked release (1.128.0, 1.128.1, 1.129.0, 1.129.1, 1.130.0-insider), first seen 2026-07-08. The underlying streaming autorun that forwards provider progress predates all of these, so this is a pre-existing consumer-side lifecycle bug rather than a single-commit regression.
progressObsinto the model) predates the regression window. The large 1.129.x spike correlates with broader cloud/agent-host session rollout — notably3eb10e18(mid-turn steering, #325368), which reset progress tracking mid-stream and increased the chance progress arrives around completion — but that commit post-dates the first sighting, so it is a contributor to volume, not the introducer.Code Flow
sequenceDiagram participant Provider as Contributed session (progressObs) participant Autorun as ChatServiceImpl autorun participant Model as ChatModel participant Response as ChatResponseModel Provider->>Autorun: isComplete = true (tick A) Autorun->>Model: completeLastResponse() Model->>Response: complete() → isComplete = true Note over Provider: ⚠️ Root cause:<br/>progressObs appends more items after completion Provider->>Autorun: progressArray grows (tick B) Autorun->>Model: acceptResponseProgress(lastRequest, progress) Model->>Response: isComplete === true Note over Response: 💥 Error thrown:<br/>Adding progress to a completed responseAffected Files
src/vs/workbench/contrib/chat/common/model/chatModel.tsif (request.response.isComplete) { throw new Error('acceptResponseProgress: Adding progress to a completed response'); }src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.tsif (lastRequest && progressArray.length > lastProgressLength) { ... model?.acceptResponseProgress(lastRequest, progress); }— forwards new progress without checking whether the response was already completed at L999 (completeLastResponse()) on a prior tickRepro Steps
progressObs+isCompleteObs.isCompleteObs→ true) on one observable tick, so the autorun callscompleteLastResponse()and theChatResponseModelbecomes complete.progressObson a subsequent tick (late/trailing chunk after the completion signal).acceptResponseProgress, which throws because the response is already complete.How the Fix Works
Chosen approach —
src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts: guard the progress-forwarding loop with!lastRequest.response?.isCompleteso trailing progress emitted after the response has been completed is dropped instead of being pushed into a completedChatResponseModel.This is a consumer-side guard, which is the correct place here because the producer is an extension-contributed observable (
providedSession.progressObs) — an external boundary whose emit ordering (progress after completion) the core cannot control or fix at its source. The guard does not mask a core-owned producer bug and does not use try/catch: the throwing telemetry path inchatModel.tsis preserved for every other caller. Within the same autorun tick where completion first occurs,response.isCompleteis stillfalsewhen the loop runs (completion happens later at L999), so legitimate final progress delivered alongside the completion signal is still accepted — only strictly-after-completion trailing items are skipped.Alternatives considered:
acceptResponseProgresscall in try/catch — rejected: hides the error from the telemetry pipeline instead of preventing the invalid state, and would swallow genuine misuse from other callers.acceptResponseProgressinchatModel.tsto silently no-op when complete — rejected: that patches the shared crash-site utility for all ~30 callers and removes a useful invariant check; the offending caller is the streaming autorun, so the fix belongs there.Recommended Owner
@osortega— most recent author of the contributed-session progress-streaming / mid-turn-steering code inchatServiceImpl.ts(#325368) and actively working in this area.errors-fix-driver — cycle 1
Trigger: cron_review_comments · Head:
8baa98525235012264a6ddbf5bcaf19f3c6d7dca(8baa985) + review-fix commit pushed this cyclechatServiceImpl.ts:990(in scope)lastRequest.response && !lastRequest.response.isComplete); replied + resolvedPush: yes (review-fix commit staged via push_to_pull_request_branch) · Copilot rerequested: ok
Ready gate: PR already non-draft; new commit pushed → CI re-running and Copilot re-review pending, so no ready-state change needed this cycle.