From 80ed04e7b59717c20197bc85ee102a7bb99af65e Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sun, 13 Sep 2026 20:40:11 -0700 Subject: [PATCH 1/2] chat: stop polling unchanged thinking content Stop the progressive render loop when the accepted thinking part has unchanged text and duration. Continue accepting replacement model objects so generated titles are persisted to the current part, and preserve grouped-section identity. Add regression coverage for model mutations, generated titles, and render-loop pause/resume. Refs #275176. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chatThinkingContentPart.ts | 9 +- .../chatThinkingContentPart.test.ts | 160 +++++++++++++++++- .../browser/widget/chatListRenderer.test.ts | 95 +++++++++++ 3 files changed, 260 insertions(+), 4 deletions(-) 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 e4652dd192dd73..c6fdc0173630ab 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -2737,7 +2737,14 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): return false; } - return other?.id !== this.id; + if (other.id !== this.id) { + return true; + } + + // Accept replacement model parts so generated titles are written back to the current part. + return other === this.content + && extractTextFromPart(other) === this.currentThinkingValue + && other.reasoningDurationMs === this.reasoningDurationMs; } override dispose(): void { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index 3e559910078e70..72fc467969e2c7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -2132,7 +2132,162 @@ suite('ChatThinkingContentPart', () => { assert.strictEqual(result, true, 'Should accept markdown content as same content'); }); - test('should return false for different thinking part with same id', () => { + test('should return true for unchanged thinking content', () => { + const values: IChatThinkingPart['value'][] = ['**Working**', ' \n**Working** \n', '', undefined, ['**Working**', ' on it']]; + const context = createMockRenderContext(false); + const results = values.map(value => { + const content: IChatThinkingPart = { kind: 'thinking', value, id: 'id-1' }; + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + + return part.hasSameContent(content, [], context.element); + }); + + assert.deepStrictEqual(results, [true, true, true, true, true]); + }); + + test('should detect in-place text and duration changes', () => { + const content = createThinkingPart('**Working**', 'id-1'); + const context = createMockRenderContext(false); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + + content.value = '**Updated thinking**'; + const sameBeforeTextUpdate = part.hasSameContent(content, [], context.element); + part.updateThinking(content); + const sameAfterTextUpdate = part.hasSameContent(content, [], context.element); + + content.reasoningDurationMs = 2300; + const sameBeforeDurationUpdate = part.hasSameContent(content, [], context.element); + part.updateThinking(content); + const sameAfterDurationUpdate = part.hasSameContent(content, [], context.element); + part.finalizeTitleIfDefault(); + + assert.deepStrictEqual({ + sameBeforeTextUpdate, + sameAfterTextUpdate, + sameBeforeDurationUpdate, + sameAfterDurationUpdate, + finalLabel: part.domNode.querySelector('.monaco-button')?.textContent, + }, { + sameBeforeTextUpdate: false, + sameAfterTextUpdate: true, + sameBeforeDurationUpdate: false, + sameAfterDurationUpdate: true, + finalLabel: 'Updated thinking - 3s', + }); + }); + + test('should accept replacement model parts before skipping unchanged content', () => { + const content = createThinkingPart('**Working**', 'id-1'); + const context = createMockRenderContext(false); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + const replacement: IChatThinkingPart = { + ...content, + value: '**Working** ', + metadata: { signature: 'updated' }, + generatedTitle: 'Reviewed the implementation', + }; + + const sameBeforeUpdate = part.hasSameContent(replacement, [], context.element); + part.updateThinking(replacement); + const sameAfterUpdate = part.hasSameContent(replacement, [], context.element); + part.finalizeTitleIfDefault(); + + assert.deepStrictEqual({ + sameBeforeUpdate, + sameAfterUpdate, + finalLabel: part.domNode.querySelector('.monaco-button')?.textContent, + }, { + sameBeforeUpdate: false, + sameAfterUpdate: true, + finalLabel: 'Reviewed the implementation', + }); + }); + + test('should persist generated titles on replacement model parts with unchanged text', () => { + const content = createThinkingPart('**Working**', 'id-1'); + const context = createMockRenderContext(false); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + const replacement: IChatThinkingPart = { ...content, value: '**Working** ' }; + const sameBeforeUpdate = part.hasSameContent(replacement, [], context.element); + if (!sameBeforeUpdate) { + part.updateThinking(replacement); + } + part.finalizeTitleIfDefault(); + + assert.deepStrictEqual({ + sameBeforeUpdate, + generatedTitle: replacement.generatedTitle, + }, { + sameBeforeUpdate: false, + generatedTitle: 'Working', + }); + }); + + for (const thinkingStyle of [ThinkingDisplayMode.Collapsed, ThinkingDisplayMode.CollapsedPreview, ThinkingDisplayMode.FixedScrolling]) { + test(`should compare only the active grouped thinking section in ${thinkingStyle} mode`, () => { + mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', thinkingStyle); + const content = createThinkingPart('**Earlier thinking**', 'id-1'); + const context = createMockRenderContext(false); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + const nextContent = createThinkingPart('**Current thinking**', 'id-2'); + part.setupThinkingContainer(nextContent); + part.updateThinking(nextContent); + const sameEarlierSection = part.hasSameContent(content, [], context.element); + const sameActiveSection = part.hasSameContent(nextContent, [], context.element); + + nextContent.value += ' with more detail'; + const sameBeforeUpdate = part.hasSameContent(nextContent, [], context.element); + part.updateThinking(nextContent); + const sameAfterUpdate = part.hasSameContent(nextContent, [], context.element); + part.resetId(); + + assert.deepStrictEqual({ + sameEarlierSection, + sameActiveSection, + sameBeforeUpdate, + sameAfterUpdate, + sameInactiveSection: part.hasSameContent(nextContent, [], context.element), + }, { + sameEarlierSection: true, + sameActiveSection: true, + sameBeforeUpdate: false, + sameAfterUpdate: true, + sameInactiveSection: true, + }); + }); + } + + test('should return false for changed thinking text with the same id', () => { const content = createThinkingPart('**Working**', 'id-1'); const context = createMockRenderContext(false); @@ -2146,9 +2301,8 @@ suite('ChatThinkingContentPart', () => { const otherThinking: IChatRendererContent = createThinkingPart('**Different**', 'id-1'); - // When the id is the same, hasSameContent returns true (other.id !== this.id is false) const result = part.hasSameContent(otherThinking, [], context.element); - assert.strictEqual(result, false, 'Should return false for thinking part with same id'); + assert.strictEqual(result, false, 'Should update changed thinking text'); }); test('should return true for thinking part with different id', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 08c31c58af3bf2..a1cfc2481ec5f9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import * as dom from '../../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { timeout } from '../../../../../../base/common/async.js'; @@ -20,6 +21,7 @@ import { NullHoverService } from '../../../../../../platform/hover/test/browser/ import { IUserInteractionService, MockUserInteractionService } from '../../../../../../platform/userInteraction/browser/userInteractionService.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/virtualScheduling/index.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { IViewDescriptorService } from '../../../../../common/views.js'; import { IChatOutputRendererService } from '../../../browser/chatOutputItemRenderer.js'; @@ -49,6 +51,8 @@ import { MockChatModelFeedbackSurveyService } from '../feedbackSurvey/mockChatMo suite('ChatListRenderer', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => sinon.restore()); + test('recognizes anchors and their nested content as link targets', () => { const anchor = mainWindow.document.createElement('a'); const icon = mainWindow.document.createElement('span'); @@ -1396,6 +1400,97 @@ suite('ChatListRenderer', () => { }); }); + test('stops polling unchanged thinking and resumes on the next model update', () => runWithFakedTimers({}, async () => { + const disposables = store.add(new DisposableStore()); + try { + const instantiationService = workbenchInstantiationService(undefined, disposables); + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(ChatConfiguration.IncrementalRendering, false); + configurationService.setUserConfiguration(ChatConfiguration.ThinkingStyle, ThinkingDisplayMode.FixedScrolling); + configurationService.setUserConfiguration('chat.agent.thinking.collapsedTools', CollapsedToolsDisplayMode.WithThinking); + configurationService.setUserConfiguration('chat.checkpoints.enabled', false); + configurationService.setUserConfiguration('chat.checkpoints.showFileChanges', false); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); + instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); + + const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); + const viewModel = disposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + const text = 'test'; + const request = model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)] + }, { variables: [] }, 0); + const response = viewModel.getItems().find(isResponseVM); + assert.ok(response); + + const container = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(container); + disposables.add(toDisposable(() => container.remove())); + const renderer = disposables.add(instantiationService.createInstance( + ChatListItemRenderer, + {} as ChatEditorOptions, + {}, + { + getListLength: () => 1, + onDidScroll: () => toDisposable(() => { }), + container, + currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, + }, + undefined, + viewModel, + )); + const template = renderer.renderTemplate(container); + disposables.add(toDisposable(() => renderer.disposeTemplate(template))); + const node = { element: response, children: [], depth: 0, visibleChildrenCount: 0, visibleChildIndex: 0, collapsible: false, collapsed: false, visible: true, filterData: undefined }; + + model.acceptResponseProgress(request, { kind: 'thinking', value: 'Thinking', id: 'thinking-1' }); + renderer.renderElement(node, 0, template); + const thinkingPart = template.renderedParts?.find(part => part instanceof ChatThinkingContentPart); + assert.ok(thinkingPart); + const contentChecks = sinon.spy(thinkingPart, 'hasSameContent'); + disposables.add(toDisposable(() => contentChecks.restore())); + const updates = sinon.spy(thinkingPart, 'updateThinking'); + disposables.add(toDisposable(() => updates.restore())); + + await timeout(100); + const checksAfterInitialRender = contentChecks.callCount; + await timeout(500); + const checksAfterPause = contentChecks.callCount; + const updatesAfterPause = updates.callCount; + + model.acceptResponseProgress(request, { kind: 'thinking', value: ' with more detail', id: 'thinking-1' }); + renderer.renderElement(node, 0, template); + await timeout(100); + const checksAfterUpdate = contentChecks.callCount; + await timeout(500); + + assert.deepStrictEqual({ + checksAfterInitialRender, + checksAfterPause, + updatesAfterPause, + updatesAfterResume: updates.callCount, + stoppedAfterResume: contentChecks.callCount === checksAfterUpdate, + renderedLatestText: template.value.textContent?.includes('Thinking with more detail'), + preservedThinkingPart: template.renderedParts?.includes(thinkingPart), + }, { + checksAfterInitialRender: 1, + checksAfterPause: 1, + updatesAfterPause: 0, + updatesAfterResume: 1, + stoppedAfterResume: true, + renderedLatestText: true, + preservedThinkingPart: true, + }); + } finally { + disposables.dispose(); + } + })); + test('final markdown remains mounted after thinking and tool progress completes with reduced motion', async () => { const disposables = store.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, disposables); From b16532efb261afedec70acc0b510045af2555860 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 15 Sep 2026 00:24:10 -0700 Subject: [PATCH 2/2] chat: handle array thinking and trailing response removals Track the original source of array-valued thinking and compare its active section snapshot, preserving replacement objects, lazy section ordering, and generated-title write-back. Treat trailing response removals as changes in both rendering paths and truncate disposed entries so cleared content cannot survive the no-change fast path. Add renderer regressions for both review findings. Refs #275176. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chatThinkingContentPart.ts | 30 ++- .../chat/browser/widget/chatListRenderer.ts | 12 +- .../browser/widget/chatListRenderer.test.ts | 252 ++++++++++++++---- 3 files changed, 239 insertions(+), 55 deletions(-) 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 c6fdc0173630ab..082d4cf37cb1ec 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -69,6 +69,12 @@ function extractTextFromPart(content: IChatThinkingPart): string { return raw.trim(); } +function extractActiveTextFromPart(content: IChatThinkingPart): string { + return Array.isArray(content.value) + ? (content.value.findLast(value => !!value) ?? '').trim() + : extractTextFromPart(content); +} + function isEditToolId(toolId: string): boolean { const lowerToolId = toolId.toLowerCase(); return lowerToolId.includes('edit') || @@ -372,6 +378,7 @@ export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implem private id: string | undefined; private content: IChatThinkingPart; + private arrayThinkingSource: IChatThinkingPart | undefined; private currentThinkingValue: string; private currentTitle: string; private defaultTitle = localize('chat.thinking.header', 'Thinking'); @@ -1358,18 +1365,25 @@ export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implem this.setExpanded(false); } + public setArrayThinkingSource(content: IChatThinkingPart): void { + this.arrayThinkingSource = content; + } + public updateThinking(content: IChatThinkingPart): void { // If disposed, ignore late updates coming from renderer diffing if (this._store.isDisposed) { return; } + if (Array.isArray(content.value)) { + this.setArrayThinkingSource(content); + content = { ...content, value: extractActiveTextFromPart(content) }; + } this.content = content; this.reasoningDurationMs = content.reasoningDurationMs; - // Update any pending lazy thinking item with matching ID so that - // when materialized, it will have the latest streaming content + // Array sections share an ID; only update the lazy item for the current text container. for (const lazyItem of this.lazyItems) { - if (lazyItem.kind === 'thinking' && lazyItem.content.id === content.id) { + if (lazyItem.kind === 'thinking' && lazyItem.content.id === content.id && lazyItem.textContainer === this.textContainer) { lazyItem.content = content; break; } @@ -1578,6 +1592,9 @@ export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implem for (const thinkingPart of this.allThinkingParts) { thinkingPart.generatedTitle = title; } + if (this.arrayThinkingSource) { + this.arrayThinkingSource.generatedTitle = title; + } } private loadTitleCache(): Record { @@ -2741,6 +2758,13 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): return true; } + if (Array.isArray(other.value) && this.arrayThinkingSource) { + return other === this.arrayThinkingSource + && extractActiveTextFromPart(other) === extractTextFromPart(this.content) + && other.reasoningDurationMs === this.reasoningDurationMs + && other.generatedTitle === this.content.generatedTitle; + } + // Accept replacement model parts so generated titles are written back to the current part. return other === this.content && extractTextFromPart(other) === this.currentThinkingValue diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 7884a3adfac660..cd5d0c2244aeb7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2609,7 +2609,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part === null); + const contentIsAlreadyRendered = partsToRender.length === (templateData.renderedParts?.length ?? 0) && partsToRender.every(part => part === null); if (!contentIsAlreadyRendered) { this.renderChatContentDiff(partsToRender, contentForThisTurn.content, element, index, templateData); } @@ -2673,7 +2673,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part === null); + const contentIsAlreadyRendered = partsToRender.length === (templateData.renderedParts?.length ?? 0) && partsToRender.every(part => part === null); if (contentIsAlreadyRendered) { if (contentForThisTurn.moreContentAvailable) { // The content that we want to render in this turn is already rendered, but there is more content to render on the next tick @@ -2746,9 +2746,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'working'); if (alreadyRenderedPart) { if (partToRender.kind === 'thinking' && alreadyRenderedPart instanceof ChatThinkingContentPart) { - if (!Array.isArray(partToRender.value)) { - alreadyRenderedPart.updateThinking(partToRender); - } + alreadyRenderedPart.updateThinking(partToRender); renderedParts[contentIndex] = alreadyRenderedPart; return; } else if (alreadyRenderedPart instanceof ChatThinkingContentPart && this.shouldPinPart(partToRender, element)) { @@ -2876,6 +2874,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer content.kind === other.kind); // non-array, handle case where we are currently thinking vs. starting a new thinking part } else { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index a1cfc2481ec5f9..49b2e9766cef13 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -32,7 +32,7 @@ import { ChatThinkingContentPart } from '../../../browser/widget/chatContentPart import { ChatMarkdownContentPart } from '../../../browser/widget/chatContentParts/chatMarkdownContentPart.js'; import { ChatSystemNotificationContentPart } from '../../../browser/widget/chatContentParts/chatSystemNotificationContentPart.js'; import { ChatCollapsibleContentPart } from '../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; -import { ChatRequestQueueKind, IChatMcpServersStartingSlow, IChatQuestionCarousel, IChatService, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, IChatMcpServersStartingSlow, IChatQuestionCarousel, IChatService, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../common/constants.js'; import { ChatModel } from '../../../common/model/chatModel.js'; @@ -40,7 +40,7 @@ import { ChatViewModel, IChatPendingDividerViewModel, IChatRendererContent, ICha import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; -import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { ChatEditorOptions } from '../../../browser/widget/chatOptions.js'; import { shouldRenderGeneratedImageResult, shouldRenderSessionCreatedResult } from '../../../browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; import { getGeneratedImageResultParts, getGeneratedImageResultPartsFromContent } from '../../../browser/widget/chatContentParts/toolInvocationParts/chatGeneratedImageResultSubPart.js'; @@ -1400,13 +1400,12 @@ suite('ChatListRenderer', () => { }); }); - test('stops polling unchanged thinking and resumes on the next model update', () => runWithFakedTimers({}, async () => { - const disposables = store.add(new DisposableStore()); - try { + suite('streaming thinking', () => { + function createRenderer(disposables: DisposableStore, incrementalRendering = false, thinkingStyle = ThinkingDisplayMode.FixedScrolling) { const instantiationService = workbenchInstantiationService(undefined, disposables); const configurationService = new TestConfigurationService(); - configurationService.setUserConfiguration(ChatConfiguration.IncrementalRendering, false); - configurationService.setUserConfiguration(ChatConfiguration.ThinkingStyle, ThinkingDisplayMode.FixedScrolling); + configurationService.setUserConfiguration(ChatConfiguration.IncrementalRendering, incrementalRendering); + configurationService.setUserConfiguration(ChatConfiguration.ThinkingStyle, thinkingStyle); configurationService.setUserConfiguration('chat.agent.thinking.collapsedTools', CollapsedToolsDisplayMode.WithThinking); configurationService.setUserConfiguration('chat.checkpoints.enabled', false); configurationService.setUserConfiguration('chat.checkpoints.showFileChanges', false); @@ -1448,48 +1447,207 @@ suite('ChatListRenderer', () => { disposables.add(toDisposable(() => renderer.disposeTemplate(template))); const node = { element: response, children: [], depth: 0, visibleChildrenCount: 0, visibleChildIndex: 0, collapsible: false, collapsed: false, visible: true, filterData: undefined }; - model.acceptResponseProgress(request, { kind: 'thinking', value: 'Thinking', id: 'thinking-1' }); - renderer.renderElement(node, 0, template); - const thinkingPart = template.renderedParts?.find(part => part instanceof ChatThinkingContentPart); - assert.ok(thinkingPart); - const contentChecks = sinon.spy(thinkingPart, 'hasSameContent'); - disposables.add(toDisposable(() => contentChecks.restore())); - const updates = sinon.spy(thinkingPart, 'updateThinking'); - disposables.add(toDisposable(() => updates.restore())); - - await timeout(100); - const checksAfterInitialRender = contentChecks.callCount; - await timeout(500); - const checksAfterPause = contentChecks.callCount; - const updatesAfterPause = updates.callCount; - - model.acceptResponseProgress(request, { kind: 'thinking', value: ' with more detail', id: 'thinking-1' }); - renderer.renderElement(node, 0, template); - await timeout(100); - const checksAfterUpdate = contentChecks.callCount; - await timeout(500); + return { model, request, response, template, render: () => renderer.renderElement(node, 0, template) }; + } - assert.deepStrictEqual({ - checksAfterInitialRender, - checksAfterPause, - updatesAfterPause, - updatesAfterResume: updates.callCount, - stoppedAfterResume: contentChecks.callCount === checksAfterUpdate, - renderedLatestText: template.value.textContent?.includes('Thinking with more detail'), - preservedThinkingPart: template.renderedParts?.includes(thinkingPart), - }, { - checksAfterInitialRender: 1, - checksAfterPause: 1, - updatesAfterPause: 0, - updatesAfterResume: 1, - stoppedAfterResume: true, - renderedLatestText: true, - preservedThinkingPart: true, - }); - } finally { - disposables.dispose(); + for (const array of [false, true]) { + test(`stops polling unchanged ${array ? 'array ' : ''}thinking and resumes on the next model update`, () => runWithFakedTimers({}, async () => { + const disposables = store.add(new DisposableStore()); + try { + const { model, request, template, render } = createRenderer(disposables); + model.acceptResponseProgress(request, { kind: 'thinking', value: array ? ['Earlier section', 'Thinking'] : 'Thinking', id: 'thinking-1' }); + render(); + const thinkingPart = template.renderedParts?.find(part => part instanceof ChatThinkingContentPart); + assert.ok(thinkingPart); + const contentChecks = sinon.spy(thinkingPart, 'hasSameContent'); + disposables.add(toDisposable(() => contentChecks.restore())); + const updates = sinon.spy(thinkingPart, 'updateThinking'); + disposables.add(toDisposable(() => updates.restore())); + + await timeout(100); + const checksAfterInitialRender = contentChecks.callCount; + await timeout(500); + const checksAfterPause = contentChecks.callCount; + const updatesAfterPause = updates.callCount; + + model.acceptResponseProgress(request, { kind: 'thinking', value: ' with more detail', id: 'thinking-1' }); + render(); + await timeout(100); + const checksAfterUpdate = contentChecks.callCount; + await timeout(500); + + assert.deepStrictEqual({ + checksAfterInitialRender, + checksAfterPause, + updatesAfterPause, + updatesAfterResume: updates.callCount, + stoppedAfterResume: contentChecks.callCount === checksAfterUpdate, + renderedLatestText: template.value.textContent?.includes('Thinking with more detail'), + preservedThinkingPart: template.renderedParts?.includes(thinkingPart), + }, { + checksAfterInitialRender: 1, + checksAfterPause: 1, + updatesAfterPause: 0, + updatesAfterResume: 1, + stoppedAfterResume: true, + renderedLatestText: true, + preservedThinkingPart: true, + }); + } finally { + disposables.dispose(); + } + })); } - })); + + test('accepts replacement array parts and updates the active section snapshot', () => runWithFakedTimers({}, async () => { + const disposables = store.add(new DisposableStore()); + try { + const { model, request, response, template, render } = createRenderer(disposables); + const content: IChatThinkingPart = { kind: 'thinking', value: ['**Working**', 'Initial active section'], id: 'thinking-1' }; + model.acceptResponseProgress(request, content); + render(); + const thinkingPart = template.renderedParts?.find(part => part instanceof ChatThinkingContentPart); + assert.ok(thinkingPart); + await timeout(100); + + const replacement = { ...content, value: ['**Working**', 'Initial active section'], metadata: { signature: 'updated' } }; + const sameBeforeReplacement = thinkingPart.hasSameContent(replacement, [], response); + model.acceptResponseProgress(request, { kind: 'clearToPreviousToolInvocation', reason: ChatResponseClearToPreviousToolInvocationReason.NoReason }); + model.acceptResponseProgress(request, replacement); + render(); + const sameAfterReplacement = thinkingPart.hasSameContent(replacement, [], response); + + replacement.value[1] = 'Updated active section'; + const sameBeforeMutation = thinkingPart.hasSameContent(replacement, [], response); + render(); + const sameAfterMutation = thinkingPart.hasSameContent(replacement, [], response); + replacement.reasoningDurationMs = 2300; + const sameBeforeDurationUpdate = thinkingPart.hasSameContent(replacement, [], response); + render(); + const sameAfterDurationUpdate = thinkingPart.hasSameContent(replacement, [], response); + const sections = Array.from(thinkingPart.domNode.querySelectorAll('.chat-thinking-item.markdown-content'), section => section.textContent?.trim()); + thinkingPart.finalizeTitleIfDefault(); + + assert.deepStrictEqual({ + sameBeforeReplacement, + sameAfterReplacement, + sameBeforeMutation, + sameAfterMutation, + sameBeforeDurationUpdate, + sameAfterDurationUpdate, + sections, + generatedTitle: replacement.generatedTitle, + preservedThinkingPart: template.renderedParts?.includes(thinkingPart), + }, { + sameBeforeReplacement: false, + sameAfterReplacement: true, + sameBeforeMutation: false, + sameAfterMutation: true, + sameBeforeDurationUpdate: false, + sameAfterDurationUpdate: true, + sections: ['Working', 'Updated active section'], + generatedTitle: 'Working', + preservedThinkingPart: true, + }); + } finally { + disposables.dispose(); + } + })); + + test('preserves earlier lazy array sections when the active section changes', () => runWithFakedTimers({}, async () => { + const disposables = store.add(new DisposableStore()); + try { + const { model, request, response, template, render } = createRenderer(disposables, false, ThinkingDisplayMode.Collapsed); + const content: IChatThinkingPart & { value: string[] } = { + kind: 'thinking', + value: ['**Working**', 'Earlier lazy section', 'Active section', ''], + id: 'thinking-1', + }; + model.acceptResponseProgress(request, content); + render(); + const thinkingPart = template.renderedParts?.find(part => part instanceof ChatThinkingContentPart); + assert.ok(thinkingPart); + const sameBeforeMutation = thinkingPart.hasSameContent(content, [], response); + + content.value[2] = 'Updated active section'; + render(); + const button = thinkingPart.domNode.querySelector('.monaco-button'); + assert.ok(button); + button.click(); + + assert.deepStrictEqual({ + sameBeforeMutation, + sameAfterExpansion: thinkingPart.hasSameContent(content, [], response), + sections: Array.from(thinkingPart.domNode.querySelectorAll('.chat-thinking-item.markdown-content'), section => section.textContent?.trim()), + }, { + sameBeforeMutation: true, + sameAfterExpansion: true, + sections: ['Earlier lazy section', 'Updated active section'], + }); + } finally { + disposables.dispose(); + } + })); + + for (const incrementalRendering of [false, true]) { + test(`removes a cleared trailing warning with ${incrementalRendering ? 'incremental' : 'progressive'} rendering`, () => runWithFakedTimers({}, async () => { + const disposables = store.add(new DisposableStore()); + try { + const { model, request, template, render } = createRenderer(disposables, incrementalRendering); + model.acceptResponseProgress(request, { kind: 'thinking', value: 'Thinking', id: 'thinking-1' }); + const invocation = new ChatToolInvocation({ + invocationMessage: '', + pastTenseMessage: '', + presentation: ToolInvocationPresentation.Hidden, + }, { + id: 'hidden-tool', + displayName: '', + modelDescription: '', + source: ToolDataSource.Internal, + }, 'tool-1', undefined, {}); + await invocation.didExecuteTool(undefined); + model.acceptResponseProgress(request, invocation); + model.acceptResponseProgress(request, { kind: 'warning', content: new MarkdownString('Warning to clear') }); + render(); + const thinkingPart = template.renderedParts?.find(part => part instanceof ChatThinkingContentPart); + assert.ok(thinkingPart); + const warning = template.renderedParts?.at(-1); + assert.ok(warning); + const warningInitiallyVisible = warning.domNode?.textContent?.includes('Warning to clear'); + const warningDisposed = sinon.spy(warning, 'dispose'); + disposables.add(toDisposable(() => warningDisposed.restore())); + const checks = sinon.spy(thinkingPart, 'hasSameContent'); + disposables.add(toDisposable(() => checks.restore())); + + model.acceptResponseProgress(request, { kind: 'clearToPreviousToolInvocation', reason: ChatResponseClearToPreviousToolInvocationReason.NoReason }); + if (incrementalRendering) { + render(); + } + await timeout(150); + const checksAfterRemoval = checks.callCount; + await timeout(500); + + assert.deepStrictEqual({ + warningInitiallyVisible, + warningVisible: template.value.textContent?.includes('Warning to clear'), + warningDisposed: warningDisposed.callCount, + renderedParts: template.renderedParts?.length, + retainedThinking: template.value.contains(thinkingPart.domNode), + stoppedAfterRemoval: checks.callCount === checksAfterRemoval, + }, { + warningInitiallyVisible: true, + warningVisible: false, + warningDisposed: 1, + renderedParts: 3, + retainedThinking: true, + stoppedAfterRemoval: true, + }); + } finally { + disposables.dispose(); + } + })); + } + }); test('final markdown remains mounted after thinking and tool progress completes with reduced motion', async () => { const disposables = store.add(new DisposableStore());