From c13a94e226ba01ce7e7214adfb8b73146cc04938 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 30 May 2026 18:16:05 -0700 Subject: [PATCH 1/5] Prevent memory leaks in title file widget in chat components (#319176) Co-authored-by: Copilot --- .../chatContentParts/chatCollapsibleContentPart.ts | 6 ++++-- .../chatContentParts/chatSubagentContentPart.ts | 4 +++- .../chatContentParts/chatThinkingContentPart.ts | 4 +++- .../chatSubagentContentPart.test.ts | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts index f805e40ec94ba..0d171b8eb16c8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts @@ -7,7 +7,7 @@ import { $ } from '../../../../../../base/browser/dom.js'; import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js'; import { HoverStyle } from '../../../../../../base/browser/ui/hover/hover.js'; import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; @@ -28,6 +28,7 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I private _domNode?: HTMLElement; private readonly _renderedTitleWithWidgets = this._register(new MutableDisposable()); + protected readonly _titleFileWidgetStore = this._register(new DisposableStore()); protected readonly hasFollowingContent: boolean; protected _isExpanded = observableValue(this, false); @@ -181,7 +182,8 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I const result = chatContentMarkdownRenderer.render(content); result.element.classList.add('collapsible-title-content'); - renderFileWidgets(result.element, instantiationService, chatMarkdownAnchorService, this._store); + this._titleFileWidgetStore.clear(); + renderFileWidgets(result.element, instantiationService, chatMarkdownAnchorService, this._titleFileWidgetStore); const labelElement = this._collapseButton.labelElement; labelElement.textContent = ''; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index c6119bd20e1d7..3f395a5545566 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -492,6 +492,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.titleShimmerSpan = undefined; this._titleDetailRendered.clear(); + this._titleFileWidgetStore.clear(); this.titleDetailContainer = undefined; const prefixSpan = $('span'); @@ -517,6 +518,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Dispose previous detail rendering this._titleDetailRendered.clear(); + this._titleFileWidgetStore.clear(); if (!toolCallText) { if (this.titleDetailContainer) { @@ -526,7 +528,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } else { const result = this.chatContentMarkdownRenderer.render(new MarkdownString(toolCallText)); result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); - renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._titleFileWidgetStore); this._titleDetailRendered.value = result; if (this.titleDetailContainer) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 981d665f87f66..c9ad136cbfd83 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -2173,6 +2173,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.titleShimmerSpan = undefined; this.titleDetailContainer = undefined; this._titleDetailRendered.clear(); + this._titleFileWidgetStore.clear(); this.currentTitle = title; return; } @@ -2197,10 +2198,11 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): // Dispose previous detail rendering this._titleDetailRendered.clear(); + this._titleFileWidgetStore.clear(); const result = this.chatContentMarkdownRenderer.render(new MarkdownString(title)); result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); - renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._titleFileWidgetStore); this._titleDetailRendered.value = result; if (this.titleDetailContainer) { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 0f97ee16be24a..a09a2aebd57ce 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -376,6 +376,20 @@ suite('ChatSubagentContentPart', () => { (toolInvocation as { toolSpecificData: IChatSubagentToolInvocationData }).toolSpecificData = data; } + test('updateTitle clears previous title file widget disposables', () => { + const toolInvocation = createMockToolInvocation({ invocationMessage: 'first' }); + const context = createMockRenderContext(false); + const part = createPart(toolInvocation, context); + + let disposed = false; + (part as unknown as { _titleFileWidgetStore: DisposableStore })._titleFileWidgetStore.add({ dispose: () => { disposed = true; } }); + + // Trigger a title re-render + part.trackToolState(createMockToolInvocation({ invocationMessage: 'second' })); + + assert.strictEqual(disposed, true, 'Previous title file widget disposable should be cleared'); + }); + test('default description with no agentName → real description arrives later → title updates', () => { const toolInvocation = createMockToolInvocation({ stateType: IChatToolInvocation.StateKind.WaitingForConfirmation, From 1ded495245603460f23d4b8e5ff2b6aeed7f2ca3 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 30 May 2026 18:16:10 -0700 Subject: [PATCH 2/5] Dispose implicit context attachment label in render disposables (#319133) * Clean up elements from DOM that did not get cleaned up in the previous render * Remove accidental file. * PR feedback --- .../chat/browser/attachments/implicitContextAttachment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts index 1e5bc99010616..e530d794542e7 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts @@ -157,7 +157,7 @@ export class ImplicitContextAttachmentWidget extends Disposable { })); } - const label = this.resourceLabels.create(contextNode, { supportIcons: true }); + const label = this.renderDisposables.add(this.resourceLabels.create(contextNode, { supportIcons: true })); let title: string | undefined; let markdownTooltip: IMarkdownString | undefined; From 58178cb2530078b7ca14699ff21341ebee425fbf Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sun, 31 May 2026 04:03:02 +0200 Subject: [PATCH 3/5] fix: memory leak in ipc.electron.ts (#317846) Co-authored-by: Dmitriy Vasyura --- src/vs/base/parts/ipc/electron-main/ipc.electron.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/vs/base/parts/ipc/electron-main/ipc.electron.ts b/src/vs/base/parts/ipc/electron-main/ipc.electron.ts index 732772819d9a3..11037accc234d 100644 --- a/src/vs/base/parts/ipc/electron-main/ipc.electron.ts +++ b/src/vs/base/parts/ipc/electron-main/ipc.electron.ts @@ -40,10 +40,20 @@ export class Server extends IPCServer { client?.dispose(); const onDidClientReconnect = new Emitter(); - Server.Clients.set(id, toDisposable(() => onDidClientReconnect.fire())); + const reconnectDisposable = toDisposable(() => { + onDidClientReconnect.fire(); + }); + Server.Clients.set(id, reconnectDisposable); const onMessage = createScopedOnMessageEvent(id, 'vscode:message') as Event; const onDidClientDisconnect = Event.any(Event.signal(createScopedOnMessageEvent(id, 'vscode:disconnect')), onDidClientReconnect.event); + Event.once(onDidClientDisconnect)(() => { + if (Server.Clients.get(id) === reconnectDisposable) { + Server.Clients.delete(id); + } + + onDidClientReconnect.dispose(); + }); const protocol = new ElectronProtocol(webContents, onMessage); return { protocol, onDidClientDisconnect }; From 1c9f4bbd70295a020288f89e76393718a757fe52 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sun, 31 May 2026 04:03:27 +0200 Subject: [PATCH 4/5] fix: memory leak in search results (#282309) * fix: memory leak in search results view * better fix --------- Co-authored-by: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Co-authored-by: Dmitriy Vasyura --- .../contrib/search/browser/searchResultsView.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/search/browser/searchResultsView.ts b/src/vs/workbench/contrib/search/browser/searchResultsView.ts index 62d5db99be1df..e461c8c0c9e42 100644 --- a/src/vs/workbench/contrib/search/browser/searchResultsView.ts +++ b/src/vs/workbench/contrib/search/browser/searchResultsView.ts @@ -7,7 +7,7 @@ import * as DOM from '../../../../base/browser/dom.js'; import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js'; import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; -import { ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; +import { ITreeElementRenderDetails, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import * as paths from '../../../../base/common/path.js'; import * as nls from '../../../../nls.js'; @@ -70,6 +70,7 @@ interface IMatchTemplate { after: HTMLElement; actions: MenuWorkbenchToolBar; disposables: DisposableStore; + elementDisposables: DisposableStore; contextKeyService: IContextKeyService; } @@ -429,6 +430,10 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender }, })); + const elementDisposables = new DisposableStore(); + disposables.add(elementDisposables); + + return { parent, before, @@ -438,6 +443,7 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender lineNumber, actions, disposables, + elementDisposables, contextKeyService: contextKeyServiceMain }; } @@ -456,7 +462,7 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender templateData.after.textContent = preview.after; const title = (preview.fullBefore + (replace ? match.replaceString : preview.inside) + preview.after).trim().substr(0, 999); - templateData.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.parent, title)); + templateData.elementDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.parent, title)); SearchContext.IsEditableItemKey.bindTo(templateData.contextKeyService).set(!match.isReadonly); @@ -468,12 +474,16 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender templateData.lineNumber.classList.toggle('show', (numLines > 0) || showLineNumbers); templateData.lineNumber.textContent = lineNumberStr + extraLinesStr; - templateData.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.lineNumber, this.getMatchTitle(match, showLineNumbers))); + templateData.elementDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.lineNumber, this.getMatchTitle(match, showLineNumbers))); templateData.actions.context = { viewer: this.searchView.getControl(), element: match } satisfies ISearchActionContext; } + disposeElement(element: ITreeNode, index: number, templateData: IMatchTemplate, details?: ITreeElementRenderDetails): void { + templateData.elementDisposables.clear(); + } + disposeTemplate(templateData: IMatchTemplate): void { templateData.disposables.dispose(); } From 6b1e5513a8bab3688342b3b01de41d4a905b289f Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Sat, 30 May 2026 19:48:26 -0700 Subject: [PATCH 5/5] Report Anthropic thinking_tokens as reasoning tokens in telemetry (#319185) Report Anthropic thinking_tokens as completion_tokens_details.reasoning_tokens --- .../src/platform/endpoint/node/messagesApi.ts | 23 +++- .../endpoint/test/node/messagesApi.spec.ts | 100 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index 5b649bbeae8ca..f485d9b535ab4 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -70,6 +70,9 @@ interface AnthropicStreamEvent { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number; }; + output_tokens_details?: { + thinking_tokens?: number; + }; }; }; index?: number; @@ -100,6 +103,9 @@ interface AnthropicStreamEvent { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number; }; + output_tokens_details?: { + thinking_tokens?: number; + }; }; copilot_usage?: { total_nano_aiu: number; @@ -677,6 +683,13 @@ interface AnthropicCompletionState { readonly cacheCreation1hTokens: number | undefined; readonly cacheCreation5mTokens: number | undefined; readonly cacheReadTokens: number; + /** + * Anthropic-reported thinking (reasoning) tokens, a subset of + * `output_tokens`. Surfaced as `completion_tokens_details.reasoning_tokens` + * to match the OpenAI/CAPI naming used elsewhere in telemetry. Undefined + * when the server did not include `output_tokens_details`. + */ + readonly thinkingTokens: number | undefined; readonly requestId: string; readonly ghRequestId: string; readonly serverExperiments: string; @@ -744,7 +757,7 @@ function buildAnthropicCompletion(state: AnthropicCompletionState, logService: I : {}), }, completion_tokens_details: { - reasoning_tokens: 0, + reasoning_tokens: state.thinkingTokens ?? 0, accepted_prediction_tokens: 0, rejected_prediction_tokens: 0, }, @@ -798,6 +811,9 @@ type AnthropicNonStreamingResponse = ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number; }; + output_tokens_details?: { + thinking_tokens?: number; + }; }; } | { @@ -933,6 +949,7 @@ export async function processNonStreamingResponseFromMessagesEndpoint( cacheCreation1hTokens: usage?.cache_creation?.ephemeral_1h_input_tokens, cacheCreation5mTokens: usage?.cache_creation?.ephemeral_5m_input_tokens, cacheReadTokens: usage?.cache_read_input_tokens ?? 0, + thinkingTokens: usage?.output_tokens_details?.thinking_tokens, requestId, ghRequestId, serverExperiments, @@ -983,6 +1000,7 @@ export class AnthropicMessagesProcessor { private cacheCreation1hTokens: number | undefined; private cacheCreation5mTokens: number | undefined; private cacheReadTokens: number = 0; + private thinkingTokens: number | undefined; private copilotUsage?: { total_nano_aiu: number }; private contextManagementResponse?: ContextManagementResponse; private stopReason: string | undefined; @@ -1065,6 +1083,7 @@ export class AnthropicMessagesProcessor { this.cacheCreation1hTokens = chunk.message.usage.cache_creation?.ephemeral_1h_input_tokens ?? this.cacheCreation1hTokens; this.cacheCreation5mTokens = chunk.message.usage.cache_creation?.ephemeral_5m_input_tokens ?? this.cacheCreation5mTokens; this.cacheReadTokens = chunk.message.usage.cache_read_input_tokens ?? 0; + this.thinkingTokens = chunk.message.usage.output_tokens_details?.thinking_tokens ?? this.thinkingTokens; } return; case 'content_block_start': @@ -1177,6 +1196,7 @@ export class AnthropicMessagesProcessor { this.cacheCreation1hTokens = chunk.usage.cache_creation?.ephemeral_1h_input_tokens ?? this.cacheCreation1hTokens; this.cacheCreation5mTokens = chunk.usage.cache_creation?.ephemeral_5m_input_tokens ?? this.cacheCreation5mTokens; this.cacheReadTokens = chunk.usage.cache_read_input_tokens ?? this.cacheReadTokens; + this.thinkingTokens = chunk.usage.output_tokens_details?.thinking_tokens ?? this.thinkingTokens; } if (chunk.copilot_usage && typeof chunk.copilot_usage.total_nano_aiu === 'number') { this.copilotUsage = chunk.copilot_usage; @@ -1272,6 +1292,7 @@ export class AnthropicMessagesProcessor { cacheCreation1hTokens: this.cacheCreation1hTokens, cacheCreation5mTokens: this.cacheCreation5mTokens, cacheReadTokens: this.cacheReadTokens, + thinkingTokens: this.thinkingTokens, requestId: this.requestId, ghRequestId: this.ghRequestId, serverExperiments: this.serverExperiments, diff --git a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts index 7d53f9bd28eb7..5bcb9d2dbb3a9 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts @@ -1479,6 +1479,67 @@ suite('processNonStreamingResponseFromMessagesEndpoint', () => { expect(details?.anthropic_cache_creation).toBeUndefined(); }); + test('surfaces thinking_tokens as completion_tokens_details.reasoning_tokens', async () => { + const response = createNonStreamingResponse({ + id: 'msg_thinking', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'thought' }], + model: 'claude-sonnet-4-20250514', + stop_reason: 'end_turn', + usage: { + input_tokens: 10, + output_tokens: 1140, + output_tokens_details: { thinking_tokens: 580 }, + }, + }); + + const telemetryData = TelemetryData.createAndMarkAsIssued(); + const completions = await processNonStreamingResponseFromMessagesEndpoint( + new NullTelemetryService(), + new TestLogService(), + response, + async () => undefined, + telemetryData, + ); + + const results = []; + for await (const c of completions) { + results.push(c); + } + + expect(results[0].usage?.completion_tokens).toBe(1140); + expect(results[0].usage?.completion_tokens_details?.reasoning_tokens).toBe(580); + }); + + test('reasoning_tokens defaults to 0 when output_tokens_details is absent', async () => { + const response = createNonStreamingResponse({ + id: 'msg_no_thinking', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'no thinking' }], + model: 'claude-sonnet-4-20250514', + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 50 }, + }); + + const telemetryData = TelemetryData.createAndMarkAsIssued(); + const completions = await processNonStreamingResponseFromMessagesEndpoint( + new NullTelemetryService(), + new TestLogService(), + response, + async () => undefined, + telemetryData, + ); + + const results = []; + for await (const c of completions) { + results.push(c); + } + + expect(results[0].usage?.completion_tokens_details?.reasoning_tokens).toBe(0); + }); + test('rejects on malformed JSON', async () => { const response = Response.fromText(200, 'OK', createNonStreamingHeaders(), 'not json at all', 'node-fetch'); const telemetryData = TelemetryData.createAndMarkAsIssued(); @@ -1747,4 +1808,43 @@ suite('AnthropicMessagesProcessor streaming cache_creation', () => { expect(details?.anthropic_cache_creation?.ephemeral_1h_input_tokens).toBe(5000); expect(details?.anthropic_cache_creation?.ephemeral_5m_input_tokens).toBe(10000); }); + + test('streaming thinking_tokens from message_delta surfaces as reasoning_tokens', () => { + // Anthropic typically reports thinking_tokens in the final message_delta + // (after the cumulative output_tokens count is known). Matches the + // observed payload shape from CAPI/Anthropic 1P/Bedrock/Vertex. + const processor = makeProcessor(); + const noop = async () => undefined; + + processor.push({ + type: 'message_start', + message: { + id: 'msg_thinking_stream', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-20250514', + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 5, + output_tokens: 1, + }, + }, + }, noop); + + processor.push({ + type: 'message_delta', + delta: { type: 'message_delta', stop_reason: 'end_turn' }, + usage: { + output_tokens: 2024, + input_tokens: 5, + output_tokens_details: { thinking_tokens: 639 }, + }, + }, noop); + + const completion = processor.push({ type: 'message_stop' }, noop); + expect(completion!.usage?.completion_tokens).toBe(2024); + expect(completion!.usage?.completion_tokens_details?.reasoning_tokens).toBe(639); + }); });