Skip to content

Conversation

@justschen
Copy link
Collaborator

fix #290893

Copilot AI review requested due to automatic review settings January 27, 2026 19:43
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes issue #290893 where streaming tool entries were being shown for failed edits, resulting in duplicate tool entries in the chat interface. The fix introduces a mechanism to track and remove tool invocations that transition from streaming to non-streaming state with hidden presentation.

Changes:

  • Adds tracking of tool DOM wrappers by call ID and a pending removals queue
  • Implements removal logic for streaming tool entries that become hidden
  • Processes pending removals when new items are appended
Comments suppressed due to low confidence (1)

src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts:910

  • The new removal logic for streaming tool entries with hidden presentation lacks test coverage. Consider adding tests that verify: 1) tool entries are queued for removal when transitioning from streaming to non-streaming with hidden presentation, 2) pending removals are processed when appendItem is called, and 3) the tool entry is removed from the DOM and all internal tracking structures.
	// removes the tool entry that was previously streaming and now is not. removes item from dom and internal tracking.
	private removeStreamingToolEntry(toolCallId: string, toolLabel: string): void {
		const wrapper = this.toolWrappersByCallId.get(toolCallId);
		if (wrapper) {
			wrapper.remove();
			this.toolWrappersByCallId.delete(toolCallId);
		}

		// make sure to remove any lazy item as well
		const lazyIndex = this.lazyItems.findIndex(item =>
			item.kind === 'tool' &&
			item.toolInvocationOrMarkdown &&
			(item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') &&
			item.toolInvocationOrMarkdown.toolCallId === toolCallId
		);
		if (lazyIndex !== -1) {
			this.lazyItems.splice(lazyIndex, 1);
		}

		this.appendedItemCount = Math.max(0, this.appendedItemCount - 1);
		this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1);
		const toolInvocationsIndex = this.toolInvocations.findIndex(t =>
			(t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId
		);
		if (toolInvocationsIndex !== -1) {
			this.toolInvocations.splice(toolInvocationsIndex, 1);
		}

		const titleIndex = this.extractedTitles.indexOf(toolLabel);
		if (titleIndex !== -1) {
			this.extractedTitles.splice(titleIndex, 1);
		}

		this.updateDropdownClickability();
		this._onDidChangeHeight.fire();
	}

Comment on lines +813 to +817
// remove any queued removals
for (const pending of this.pendingRemovals) {
this.removeStreamingToolEntry(pending.toolCallId, pending.toolLabel);
}
this.pendingRemovals = [];
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

Pending removals are only processed when appendItem is called. If a tool transitions to hidden presentation after streaming completes but no new items are appended, the tool entry will remain visible in the UI. Consider processing pendingRemovals in finalizeTitleIfDefault or markAsInactive to ensure hidden tool entries are removed even when no more items are appended.

Copilot uses AI. Check for mistakes.
item.toolInvocationOrMarkdown.toolCallId === toolCallId
);
if (lazyIndex !== -1) {
this.lazyItems.splice(lazyIndex, 1);
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

When removing a lazy item that has been materialized, its disposable is not disposed. The lazy item may have been materialized via materializeLazyItem, which would have registered its disposable to the class (see materializeLazyItem lines 1119-1121). Consider checking if the lazy item has been materialized using item.lazy.hasValue and disposing of the disposable before removing the item from the array to prevent resource leaks.

Suggested change
this.lazyItems.splice(lazyIndex, 1);
const [lazyItem] = this.lazyItems.splice(lazyIndex, 1);
if (lazyItem.lazy && lazyItem.lazy.hasValue) {
const disposable = lazyItem.lazy.value as unknown as IDisposable;
if (typeof disposable.dispose === 'function') {
disposable.dispose();
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +876 to +910
private removeStreamingToolEntry(toolCallId: string, toolLabel: string): void {
const wrapper = this.toolWrappersByCallId.get(toolCallId);
if (wrapper) {
wrapper.remove();
this.toolWrappersByCallId.delete(toolCallId);
}

// make sure to remove any lazy item as well
const lazyIndex = this.lazyItems.findIndex(item =>
item.kind === 'tool' &&
item.toolInvocationOrMarkdown &&
(item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') &&
item.toolInvocationOrMarkdown.toolCallId === toolCallId
);
if (lazyIndex !== -1) {
this.lazyItems.splice(lazyIndex, 1);
}

this.appendedItemCount = Math.max(0, this.appendedItemCount - 1);
this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1);
const toolInvocationsIndex = this.toolInvocations.findIndex(t =>
(t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId
);
if (toolInvocationsIndex !== -1) {
this.toolInvocations.splice(toolInvocationsIndex, 1);
}

const titleIndex = this.extractedTitles.indexOf(toolLabel);
if (titleIndex !== -1) {
this.extractedTitles.splice(titleIndex, 1);
}

this.updateDropdownClickability();
this._onDidChangeHeight.fire();
}
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

The autorun created in trackToolMetadata is registered to the class's dispose store but is never individually disposed when removeStreamingToolEntry removes a tool. This means the autorun will continue to observe state changes and potentially update titles for a tool that has been removed from the UI. Consider storing the autorun disposable in a map keyed by toolCallId so it can be properly disposed when the tool entry is removed.

Copilot uses AI. Check for mistakes.
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.

Streaming tool entry shown for failed edit

1 participant