From afe34566a123776d6bd640ba1f53ddcfda9ad8e2 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 15:19:38 +0100 Subject: [PATCH] Local agent sessions provider cleanup (#279359) (#279363) * Local agent sessions provider cleanup (#279359) * add tests --- .../chatCloseNotification.ts | 24 +- .../localAgentSessionsProvider.ts} | 230 +++--- .../contrib/chat/browser/chat.contribution.ts | 4 +- .../contrib/chat/browser/chatEditorInput.ts | 2 +- .../chatSessions/view/sessionsViewPane.ts | 4 +- .../contrib/chat/browser/chatViewPane.ts | 2 +- .../localAgentSessionsProvider.test.ts | 780 ++++++++++++++++++ .../test/common/mockChatSessionsService.ts | 5 +- 8 files changed, 928 insertions(+), 123 deletions(-) rename src/vs/workbench/contrib/chat/browser/{agentSessions => actions}/chatCloseNotification.ts (59%) rename src/vs/workbench/contrib/chat/browser/{chatSessions/localChatSessionsProvider.ts => agentSessions/localAgentSessionsProvider.ts} (50%) create mode 100644 src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/chatCloseNotification.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCloseNotification.ts similarity index 59% rename from src/vs/workbench/contrib/chat/browser/agentSessions/chatCloseNotification.ts rename to src/vs/workbench/contrib/chat/browser/actions/chatCloseNotification.ts index 90f6339190a..bc416f371ca 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/chatCloseNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCloseNotification.ts @@ -4,23 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from '../../../../../nls.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { INotificationService, NeverShowAgainScope, Severity } from '../../../../../platform/notification/common/notification.js'; -import { IViewsService } from '../../../../services/views/common/viewsService.js'; -import { AGENT_SESSIONS_VIEW_ID } from './agentSessions.js'; - -const STORAGE_KEY = 'chat.closeWithActiveResponse.doNotShowAgain2'; +import { LEGACY_AGENT_SESSIONS_VIEW_ID } from '../../common/constants.js'; +import { AGENT_SESSIONS_VIEW_ID } from '../agentSessions/agentSessions.js'; /** * Shows a notification when closing a chat with an active response, informing the user * that the chat will continue running in the background. The notification includes a button * to open the Agent Sessions view and a "Don't Show Again" option. */ -export function showCloseActiveChatNotification( - accessor: ServicesAccessor -): void { +export function showCloseActiveChatNotification(accessor: ServicesAccessor): void { const notificationService = accessor.get(INotificationService); - const viewsService = accessor.get(IViewsService); + const configurationService = accessor.get(IConfigurationService); + const commandService = accessor.get(ICommandService); notificationService.prompt( Severity.Info, @@ -29,13 +28,18 @@ export function showCloseActiveChatNotification( { label: nls.localize('chat.openAgentSessions', "Open Agent Sessions"), run: async () => { - await viewsService.openView(AGENT_SESSIONS_VIEW_ID, true); + // TODO@bpasero remove this check once settled + if (configurationService.getValue('chat.agentSessionsViewLocation') === 'single-view') { + commandService.executeCommand(AGENT_SESSIONS_VIEW_ID); + } else { + commandService.executeCommand(LEGACY_AGENT_SESSIONS_VIEW_ID); + } } } ], { neverShowAgain: { - id: STORAGE_KEY, + id: 'chat.closeWithActiveResponse.doNotShowAgain', scope: NeverShowAgainScope.APPLICATION } } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/localChatSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts similarity index 50% rename from src/vs/workbench/contrib/chat/browser/chatSessions/localChatSessionsProvider.ts rename to src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts index cc5c336dc03..0e30aa5f30f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/localChatSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts @@ -2,31 +2,33 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { coalesce } from '../../../../../base/common/arrays.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; -import { localize } from '../../../../../nls.js'; +import { truncate } from '../../../../../base/common/strings.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { IChatModel } from '../../common/chatModel.js'; -import { IChatService } from '../../common/chatService.js'; +import { IChatDetail, IChatService } from '../../common/chatService.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { ChatAgentLocation } from '../../common/constants.js'; -import { IChatWidget, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; -import { ChatSessionItemWithProvider } from './common.js'; +import { ChatViewId, IChatWidget, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; +import { ChatSessionItemWithProvider } from '../chatSessions/common.js'; + +export class LocalAgentsSessionsProvider extends Disposable implements IChatSessionItemProvider, IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.localAgentsSessionsProvider'; -export class LocalChatSessionsProvider extends Disposable implements IChatSessionItemProvider, IWorkbenchContribution { - static readonly ID = 'workbench.contrib.localChatSessionsProvider'; - static readonly CHAT_WIDGET_VIEW_ID = 'workbench.panel.chat.view.copilot'; readonly chatSessionType = localChatSessionType; private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; + readonly onDidChange = this._onDidChange.event; readonly _onDidChangeChatSessionItems = this._register(new Emitter()); - public get onDidChangeChatSessionItems() { return this._onDidChangeChatSessionItems.event; } + readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; constructor( @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @@ -37,50 +39,52 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio this._register(this.chatSessionsService.registerChatSessionItemProvider(this)); - this.registerWidgetListeners(); + this.registerListeners(); + } + + private registerListeners(): void { + + // Listen for new chat widgets being added/removed + this._register(this.chatWidgetService.onDidAddWidget(widget => { + if ( + widget.location === ChatAgentLocation.Chat && // Only fire for chat view instance + isIChatViewViewContext(widget.viewContext) && + widget.viewContext.viewId === ChatViewId + ) { + this._onDidChange.fire(); + + this.registerWidgetModelListeners(widget); + } + })); + + // Check for existing chat widgets and register listeners + this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat) + .filter(widget => isIChatViewViewContext(widget.viewContext) && widget.viewContext.viewId === ChatViewId) + .forEach(widget => this.registerWidgetModelListeners(widget)); this._register(this.chatService.onDidDisposeSession(() => { this._onDidChange.fire(); })); // Listen for global session items changes for our session type - this._register(this.chatSessionsService.onDidChangeSessionItems((sessionType) => { + this._register(this.chatSessionsService.onDidChangeSessionItems(sessionType => { if (sessionType === this.chatSessionType) { this._onDidChange.fire(); } })); } - private registerWidgetListeners(): void { - // Listen for new chat widgets being added/removed - this._register(this.chatWidgetService.onDidAddWidget(widget => { - // Only fire for chat view instance - if (widget.location === ChatAgentLocation.Chat && - isIChatViewViewContext(widget.viewContext) && - widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID) { - this._onDidChange.fire(); - this._registerWidgetModelListeners(widget); - } - })); - - // Check for existing chat widgets and register listeners - const existingWidgets = this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat) - .filter(widget => isIChatViewViewContext(widget.viewContext) && widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID); - - existingWidgets.forEach(widget => { - this._registerWidgetModelListeners(widget); - }); - } - - private _registerWidgetModelListeners(widget: IChatWidget): void { + private registerWidgetModelListeners(widget: IChatWidget): void { const register = () => { this.registerModelTitleListener(widget); + if (widget.viewModel) { this.chatSessionsService.registerModelProgressListener(widget.viewModel.model, () => { this._onDidChangeChatSessionItems.fire(); }); } }; + // Listen for view model changes on this widget this._register(widget.onDidChangeViewModel(() => { register(); @@ -93,8 +97,10 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio private registerModelTitleListener(widget: IChatWidget): void { const model = widget.viewModel?.model; if (model) { + // Listen for model changes, specifically for title changes via setCustomTitle - this._register(model.onDidChange((e) => { + this._register(model.onDidChange(e => { + // Fire change events for all title-related changes to refresh the tree if (!e || e.kind === 'setCustomTitle') { this._onDidChange.fire(); @@ -106,62 +112,45 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio private modelToStatus(model: IChatModel): ChatSessionStatus | undefined { if (model.requestInProgress.get()) { return ChatSessionStatus.InProgress; - } else { - const requests = model.getRequests(); - if (requests.length > 0) { - // Check if the last request was completed successfully or failed - const lastRequest = requests[requests.length - 1]; - if (lastRequest?.response) { - if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails) { - return ChatSessionStatus.Failed; - } else if (lastRequest.response.isComplete) { - return ChatSessionStatus.Completed; - } else { - return ChatSessionStatus.InProgress; - } + } + + const requests = model.getRequests(); + if (requests.length > 0) { + + // Check if the last request was completed successfully or failed + const lastRequest = requests[requests.length - 1]; + if (lastRequest?.response) { + if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails) { + return ChatSessionStatus.Failed; + } else if (lastRequest.response.isComplete) { + return ChatSessionStatus.Completed; + } else { + return ChatSessionStatus.InProgress; } } } - return; + + return undefined; } async provideChatSessionItems(token: CancellationToken): Promise { const sessions: ChatSessionItemWithProvider[] = []; const sessionsByResource = new ResourceSet(); - this.chatService.getLiveSessionItems().forEach(sessionDetail => { - let status: ChatSessionStatus | undefined; - let startTime: number | undefined; - let endTime: number | undefined; - let description: string | undefined; - const model = this.chatService.getSession(sessionDetail.sessionResource); - if (model) { - status = this.modelToStatus(model); - startTime = model.timestamp; - description = this.chatSessionsService.getSessionDescription(model); - const lastResponse = model.getRequests().at(-1)?.response; - if (lastResponse) { - endTime = lastResponse.completedAt ?? lastResponse.timestamp; - } + + for (const sessionDetail of this.chatService.getLiveSessionItems()) { + const editorSession = this.toChatSessionItem(sessionDetail); + if (!editorSession) { + continue; } - const statistics = model ? this.getSessionStatistics(model) : undefined; - const editorSession: ChatSessionItemWithProvider = { - resource: sessionDetail.sessionResource, - label: sessionDetail.title, - iconPath: Codicon.chatSparkle, - status, - provider: this, - timing: { - startTime: startTime ?? Date.now(), // TODO@osortega this is not so good - endTime - }, - statistics, - description: description || localize('chat.localSessionDescription.finished', "Finished"), - }; + sessionsByResource.add(sessionDetail.sessionResource); sessions.push(editorSession); - }); - const history = await this.getHistoryItems(); - sessions.push(...history.filter(h => !sessionsByResource.has(h.resource))); + } + + if (!token.isCancellationRequested) { + const history = await this.getHistoryItems(); + sessions.push(...history.filter(h => !sessionsByResource.has(h.resource))); + } return sessions; } @@ -169,46 +158,77 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio private async getHistoryItems(): Promise { try { const allHistory = await this.chatService.getHistorySessionItems(); - const historyItems = allHistory.map((historyDetail): ChatSessionItemWithProvider => { - const model = this.chatService.getSession(historyDetail.sessionResource); - const statistics = model ? this.getSessionStatistics(model) : undefined; - return { - resource: historyDetail.sessionResource, - label: historyDetail.title, - iconPath: Codicon.chatSparkle, - provider: this, - timing: { - startTime: historyDetail.lastMessageDate ?? Date.now() - }, - archived: true, - statistics - }; - }); - return historyItems; - + return coalesce(allHistory.map(history => this.toChatSessionItem(history))); } catch (error) { return []; } } + private toChatSessionItem(chat: IChatDetail): ChatSessionItemWithProvider | undefined { + const model = this.chatService.getSession(chat.sessionResource); + + let description: string | undefined; + let startTime: number | undefined; + let endTime: number | undefined; + if (model) { + if (!model.hasRequests) { + return undefined; // ignore sessions without requests + } + + const lastResponse = model.getRequests().at(-1)?.response; + + description = this.chatSessionsService.getSessionDescription(model); + if (!description) { + const responseValue = lastResponse?.response.toString(); + if (responseValue) { + description = truncate(responseValue.replace(/\r?\n/g, ' '), 100); + } + } + + startTime = model.timestamp; + if (lastResponse) { + endTime = lastResponse.completedAt ?? lastResponse.timestamp; + } + } else { + startTime = chat.lastMessageDate; + } + + return { + resource: chat.sessionResource, + provider: this, + label: chat.title, + description, + status: model ? this.modelToStatus(model) : undefined, + iconPath: Codicon.chatSparkle, + timing: { + startTime, + endTime + }, + statistics: model ? this.getSessionStatistics(model) : undefined + }; + } + private getSessionStatistics(chatModel: IChatModel) { let linesAdded = 0; let linesRemoved = 0; - const modifiedFiles = new ResourceSet(); + const files = new ResourceSet(); + const currentEdits = chatModel.editingSession?.entries.get(); if (currentEdits) { - const uncommittedEdits = currentEdits.filter((edit) => edit.state.get() === ModifiedFileEntryState.Modified); - uncommittedEdits.forEach(edit => { + const uncommittedEdits = currentEdits.filter(edit => edit.state.get() === ModifiedFileEntryState.Modified); + for (const edit of uncommittedEdits) { linesAdded += edit.linesAdded?.get() ?? 0; linesRemoved += edit.linesRemoved?.get() ?? 0; - modifiedFiles.add(edit.modifiedURI); - }); + files.add(edit.modifiedURI); + } } - if (modifiedFiles.size === 0) { - return; + + if (files.size === 0) { + return undefined; } + return { - files: modifiedFiles.size, + files: files.size, insertions: linesAdded, deletions: linesRemoved, }; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f5f36520811..b9233cc2a71 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -111,7 +111,7 @@ import { ChatPasteProvidersFeature } from './chatPasteProviders.js'; import { QuickChatService } from './chatQuick.js'; import { ChatResponseAccessibleView } from './chatResponseAccessibleView.js'; import { ChatTerminalOutputAccessibleView } from './chatTerminalOutputAccessibleView.js'; -import { LocalChatSessionsProvider } from './chatSessions/localChatSessionsProvider.js'; +import { LocalAgentsSessionsProvider } from './agentSessions/localAgentSessionsProvider.js'; import { ChatSessionsView, ChatSessionsViewContrib } from './chatSessions/view/chatSessionsView.js'; import { ChatSetupContribution, ChatTeardownContribution } from './chatSetup.js'; import { ChatStatusBarEntry } from './chatStatus/chatStatusEntry.js'; @@ -1145,7 +1145,7 @@ registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribu registerWorkbenchContribution2(ChatContextContributions.ID, ChatContextContributions, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatResponseResourceFileSystemProvider.ID, ChatResponseResourceFileSystemProvider, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(PromptUrlHandler.ID, PromptUrlHandler, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(LocalChatSessionsProvider.ID, LocalChatSessionsProvider, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(LocalAgentsSessionsProvider.ID, LocalAgentsSessionsProvider, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatSessionsViewContrib.ID, ChatSessionsViewContrib, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatSessionsView.ID, ChatSessionsView, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts index f2b87fa1f74..48c076c99c8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts @@ -24,7 +24,7 @@ import { IChatSessionsService, localChatSessionType } from '../common/chatSessio import { LocalChatSessionUri } from '../common/chatUri.js'; import { ChatAgentLocation, ChatEditorTitleMaxLength } from '../common/constants.js'; import { IClearEditingSessionConfirmationOptions } from './actions/chatActions.js'; -import { showCloseActiveChatNotification } from './agentSessions/chatCloseNotification.js'; +import { showCloseActiveChatNotification } from './actions/chatCloseNotification.js'; import type { IChatEditorOptions } from './chatEditor.js'; const ChatEditorIcon = registerIcon('chat-editor-label-icon', Codicon.chatSparkle, nls.localize('chatEditorLabelIcon', 'Icon of the chat editor label.')); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts index b091b7a6842..f19d43dbdce 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts @@ -45,7 +45,7 @@ import { IChatWidgetService } from '../../chat.js'; import { IChatEditorOptions } from '../../chatEditor.js'; import { ChatSessionTracker } from '../chatSessionTracker.js'; import { ChatSessionItemWithProvider, getSessionItemContextOverlay, NEW_CHAT_SESSION_ACTION_ID } from '../common.js'; -import { LocalChatSessionsProvider } from '../localChatSessionsProvider.js'; +import { LocalAgentsSessionsProvider } from '../../agentSessions/localAgentSessionsProvider.js'; import { ArchivedSessionItems, GettingStartedDelegate, GettingStartedRenderer, IGettingStartedItem, SessionsDataSource, SessionsDelegate, SessionsRenderer } from './sessionsTreeRenderer.js'; // Identity provider for session items @@ -105,7 +105,7 @@ export class SessionsViewPane extends ViewPane { this.minimumBodySize = 44; // Listen for changes in the provider if it's a LocalChatSessionsProvider - if (provider instanceof LocalChatSessionsProvider) { + if (provider instanceof LocalAgentsSessionsProvider) { this._register(provider.onDidChange(() => { if (this.tree && this.isBodyVisible()) { this.refreshTreeWithProgress(); diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index ce8571e4c2e..6acc49cb3c8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -37,7 +37,7 @@ import { IChatModelReference, IChatService } from '../common/chatService.js'; import { IChatSessionsExtensionPoint, IChatSessionsService, localChatSessionType } from '../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../common/chatUri.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; -import { showCloseActiveChatNotification } from './agentSessions/chatCloseNotification.js'; +import { showCloseActiveChatNotification } from './actions/chatCloseNotification.js'; import { ChatWidget } from './chatWidget.js'; import { ChatViewWelcomeController, IViewWelcomeDelegate } from './viewsWelcome/chatViewWelcomeController.js'; import { AgentSessionsControl } from './agentSessions/agentSessionsControl.js'; diff --git a/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts new file mode 100644 index 00000000000..afb3accda0e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts @@ -0,0 +1,780 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { IChatWidget, IChatWidgetService } from '../../browser/chat.js'; +import { LocalAgentsSessionsProvider } from '../../browser/agentSessions/localAgentSessionsProvider.js'; +import { IChatDetail, IChatService } from '../../common/chatService.js'; +import { ChatSessionStatus, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; +import { ChatAgentLocation } from '../../common/constants.js'; +import { MockChatSessionsService } from '../common/mockChatSessionsService.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../common/chatModel.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { LocalChatSessionUri } from '../../common/chatUri.js'; +import { ModifiedFileEntryState } from '../../common/chatEditingService.js'; + +class MockChatWidgetService implements IChatWidgetService { + private readonly _onDidAddWidget = new Emitter(); + readonly onDidAddWidget = this._onDidAddWidget.event; + + readonly _serviceBrand: undefined; + readonly lastFocusedWidget: IChatWidget | undefined; + + private widgets: IChatWidget[] = []; + + fireDidAddWidget(widget: IChatWidget): void { + this._onDidAddWidget.fire(widget); + } + + addWidget(widget: IChatWidget): void { + this.widgets.push(widget); + } + + getWidgetByInputUri(_uri: URI): IChatWidget | undefined { + return undefined; + } + + getWidgetBySessionResource(_sessionResource: URI): IChatWidget | undefined { + return undefined; + } + + getWidgetsByLocations(location: ChatAgentLocation): ReadonlyArray { + return this.widgets.filter(w => w.location === location); + } + + revealWidget(_preserveFocus?: boolean): Promise { + return Promise.resolve(undefined); + } + + reveal(_widget: IChatWidget, _preserveFocus?: boolean): Promise { + return Promise.resolve(true); + } + + getAllWidgets(): ReadonlyArray { + return this.widgets; + } + + openSession(_sessionResource: URI): Promise { + throw new Error('Method not implemented.'); + } + + register(_newWidget: IChatWidget): { dispose: () => void } { + return { dispose: () => { } }; + } +} + +class MockChatService implements IChatService { + requestInProgressObs = observableValue('name', false); + edits2Enabled: boolean = false; + _serviceBrand: undefined; + editingSessions = []; + transferredSessionData = undefined; + readonly onDidSubmitRequest = Event.None; + + private sessions = new Map(); + private liveSessionItems: IChatDetail[] = []; + private historySessionItems: IChatDetail[] = []; + + private readonly _onDidDisposeSession = new Emitter<{ sessionResource: URI; reason: 'cleared' }>(); + readonly onDidDisposeSession = this._onDidDisposeSession.event; + + fireDidDisposeSession(sessionResource: URI): void { + this._onDidDisposeSession.fire({ sessionResource, reason: 'cleared' }); + } + + setLiveSessionItems(items: IChatDetail[]): void { + this.liveSessionItems = items; + } + + setHistorySessionItems(items: IChatDetail[]): void { + this.historySessionItems = items; + } + + addSession(sessionResource: URI, session: IChatModel): void { + this.sessions.set(sessionResource.toString(), session); + } + + isEnabled(_location: ChatAgentLocation): boolean { + return true; + } + + hasSessions(): boolean { + return this.sessions.size > 0; + } + + getProviderInfos() { + return []; + } + + startSession(_location: ChatAgentLocation, _token: CancellationToken): any { + throw new Error('Method not implemented.'); + } + + getSession(sessionResource: URI): IChatModel | undefined { + return this.sessions.get(sessionResource.toString()); + } + + getOrRestoreSession(_sessionResource: URI): Promise { + throw new Error('Method not implemented.'); + } + + getPersistedSessionTitle(_sessionResource: URI): string | undefined { + return undefined; + } + + loadSessionFromContent(_data: any): any { + throw new Error('Method not implemented.'); + } + + loadSessionForResource(_resource: URI, _position: ChatAgentLocation, _token: CancellationToken): Promise { + throw new Error('Method not implemented.'); + } + + getActiveSessionReference(_sessionResource: URI): any { + return undefined; + } + + setTitle(_sessionResource: URI, _title: string): void { } + + appendProgress(_request: IChatRequestModel, _progress: any): void { } + + sendRequest(_sessionResource: URI, _message: string): Promise { + throw new Error('Method not implemented.'); + } + + resendRequest(_request: IChatRequestModel, _options?: any): Promise { + throw new Error('Method not implemented.'); + } + + adoptRequest(_sessionResource: URI, _request: IChatRequestModel): Promise { + throw new Error('Method not implemented.'); + } + + removeRequest(_sessionResource: URI, _requestId: string): Promise { + throw new Error('Method not implemented.'); + } + + cancelCurrentRequestForSession(_sessionResource: URI): void { } + + addCompleteRequest(): void { } + + async getLocalSessionHistory(): Promise { + return this.historySessionItems; + } + + async clearAllHistoryEntries(): Promise { } + + async removeHistoryEntry(_resource: URI): Promise { } + + readonly onDidPerformUserAction = Event.None; + + notifyUserAction(_event: any): void { } + + transferChatSession(): void { } + + setChatSessionTitle(): void { } + + isEditingLocation(_location: ChatAgentLocation): boolean { + return false; + } + + getChatStorageFolder(): URI { + return URI.file('/tmp'); + } + + logChatIndex(): void { } + + isPersistedSessionEmpty(_sessionResource: URI): boolean { + return false; + } + + activateDefaultAgent(_location: ChatAgentLocation): Promise { + return Promise.resolve(); + } + + getChatSessionFromInternalUri(_sessionResource: URI): any { + return undefined; + } + + getLiveSessionItems(): IChatDetail[] { + return this.liveSessionItems; + } + + async getHistorySessionItems(): Promise { + return this.historySessionItems; + } + + waitForModelDisposals(): Promise { + return Promise.resolve(); + } +} + +function createMockChatModel(options: { + sessionResource: URI; + hasRequests?: boolean; + requestInProgress?: boolean; + timestamp?: number; + lastResponseComplete?: boolean; + lastResponseCanceled?: boolean; + lastResponseHasError?: boolean; + lastResponseTimestamp?: number; + lastResponseCompletedAt?: number; + customTitle?: string; + editingSession?: { + entries: Array<{ + state: ModifiedFileEntryState; + linesAdded: number; + linesRemoved: number; + modifiedURI: URI; + }>; + }; +}): IChatModel { + const requests: IChatRequestModel[] = []; + + if (options.hasRequests !== false) { + const mockResponse: Partial = { + isComplete: options.lastResponseComplete ?? true, + isCanceled: options.lastResponseCanceled ?? false, + result: options.lastResponseHasError ? { errorDetails: { message: 'error' } } : undefined, + timestamp: options.lastResponseTimestamp ?? Date.now(), + completedAt: options.lastResponseCompletedAt, + response: { + value: [], + getMarkdown: () => '', + toString: () => options.customTitle ? '' : 'Test response content' + } + }; + + requests.push({ + id: 'request-1', + response: mockResponse as IChatResponseModel + } as IChatRequestModel); + } + + const editingSessionEntries = options.editingSession?.entries.map(entry => ({ + state: observableValue('state', entry.state), + linesAdded: observableValue('linesAdded', entry.linesAdded), + linesRemoved: observableValue('linesRemoved', entry.linesRemoved), + modifiedURI: entry.modifiedURI + })); + + const mockEditingSession = options.editingSession ? { + entries: observableValue('entries', editingSessionEntries ?? []) + } : undefined; + + const _onDidChange = new Emitter<{ kind: string } | undefined>(); + + return { + sessionResource: options.sessionResource, + hasRequests: options.hasRequests !== false, + timestamp: options.timestamp ?? Date.now(), + requestInProgress: observableValue('requestInProgress', options.requestInProgress ?? false), + getRequests: () => requests, + onDidChange: _onDidChange.event, + editingSession: mockEditingSession, + setCustomTitle: (_title: string) => { + _onDidChange.fire({ kind: 'setCustomTitle' }); + } + } as unknown as IChatModel; +} + +suite('LocalAgentsSessionsProvider', () => { + const disposables = new DisposableStore(); + let mockChatWidgetService: MockChatWidgetService; + let mockChatService: MockChatService; + let mockChatSessionsService: MockChatSessionsService; + let instantiationService: TestInstantiationService; + + setup(() => { + mockChatWidgetService = new MockChatWidgetService(); + mockChatService = new MockChatService(); + mockChatSessionsService = new MockChatSessionsService(); + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + instantiationService.stub(IChatWidgetService, mockChatWidgetService); + instantiationService.stub(IChatService, mockChatService); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createProvider(): LocalAgentsSessionsProvider { + return disposables.add(instantiationService.createInstance(LocalAgentsSessionsProvider)); + } + + test('should have correct session type', () => { + const provider = createProvider(); + assert.strictEqual(provider.chatSessionType, localChatSessionType); + }); + + test('should register itself with chat sessions service', () => { + const provider = createProvider(); + + const providers = mockChatSessionsService.getAllChatSessionItemProviders(); + assert.strictEqual(providers.length, 1); + assert.strictEqual(providers[0], provider); + }); + + test('should provide empty sessions when no live or history sessions', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + mockChatService.setLiveSessionItems([]); + mockChatService.setHistorySessionItems([]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 0); + }); + }); + + test('should provide live session items', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('test-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + timestamp: Date.now() + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Test Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].label, 'Test Session'); + assert.strictEqual(sessions[0].resource.toString(), sessionResource.toString()); + }); + }); + + test('should ignore sessions without requests', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('empty-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: false + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Empty Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 0); + }); + }); + + test('should provide history session items', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('history-session'); + + mockChatService.setLiveSessionItems([]); + mockChatService.setHistorySessionItems([{ + sessionResource, + title: 'History Session', + lastMessageDate: Date.now() - 10000, + isActive: false + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].label, 'History Session'); + }); + }); + + test('should not duplicate sessions in history and live', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('duplicate-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Live Session', + lastMessageDate: Date.now(), + isActive: true + }]); + mockChatService.setHistorySessionItems([{ + sessionResource, + title: 'History Session', + lastMessageDate: Date.now() - 10000, + isActive: false + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].label, 'Live Session'); + }); + }); + + suite('Session Status', () => { + test('should return InProgress status when request in progress', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('in-progress-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + requestInProgress: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'In Progress Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].status, ChatSessionStatus.InProgress); + }); + }); + + test('should return Completed status when last response is complete', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('completed-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + requestInProgress: false, + lastResponseComplete: true, + lastResponseCanceled: false, + lastResponseHasError: false + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Completed Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); + }); + }); + + test('should return Failed status when last response was canceled', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('canceled-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + requestInProgress: false, + lastResponseComplete: false, + lastResponseCanceled: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Canceled Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].status, ChatSessionStatus.Failed); + }); + }); + + test('should return Failed status when last response has error', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('error-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + requestInProgress: false, + lastResponseComplete: true, + lastResponseHasError: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Error Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].status, ChatSessionStatus.Failed); + }); + }); + }); + + suite('Session Statistics', () => { + test('should return statistics for sessions with modified entries', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('stats-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + editingSession: { + entries: [ + { + state: ModifiedFileEntryState.Modified, + linesAdded: 10, + linesRemoved: 5, + modifiedURI: URI.file('/test/file1.ts') + }, + { + state: ModifiedFileEntryState.Modified, + linesAdded: 20, + linesRemoved: 3, + modifiedURI: URI.file('/test/file2.ts') + } + ] + } + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Stats Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.ok(sessions[0].statistics); + assert.strictEqual(sessions[0].statistics?.files, 2); + assert.strictEqual(sessions[0].statistics?.insertions, 30); + assert.strictEqual(sessions[0].statistics?.deletions, 8); + }); + }); + + test('should not return statistics for sessions without modified entries', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('no-stats-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + editingSession: { + entries: [ + { + state: ModifiedFileEntryState.Accepted, + linesAdded: 10, + linesRemoved: 5, + modifiedURI: URI.file('/test/file1.ts') + } + ] + } + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'No Stats Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].statistics, undefined); + }); + }); + }); + + suite('Session Timing', () => { + test('should use model timestamp for startTime when model exists', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('timing-session'); + const modelTimestamp = Date.now() - 5000; + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + timestamp: modelTimestamp + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Timing Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].timing.startTime, modelTimestamp); + }); + }); + + test('should use lastMessageDate for startTime when model does not exist', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('history-timing'); + const lastMessageDate = Date.now() - 10000; + + mockChatService.setLiveSessionItems([]); + mockChatService.setHistorySessionItems([{ + sessionResource, + title: 'History Timing Session', + lastMessageDate, + isActive: false + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].timing.startTime, lastMessageDate); + }); + }); + + test('should set endTime from last response completedAt', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('endtime-session'); + const completedAt = Date.now() - 1000; + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + lastResponseComplete: true, + lastResponseCompletedAt: completedAt + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'EndTime Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].timing.endTime, completedAt); + }); + }); + }); + + suite('Session Icon', () => { + test('should use Codicon.chatSparkle as icon', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('icon-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Icon Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].iconPath, Codicon.chatSparkle); + }); + }); + }); + + suite('Events', () => { + test('should fire onDidChange when session is disposed', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + let changeEventFired = false; + disposables.add(provider.onDidChange(() => { + changeEventFired = true; + })); + + const sessionResource = LocalChatSessionUri.forSession('disposed-session'); + mockChatService.fireDidDisposeSession(sessionResource); + + assert.strictEqual(changeEventFired, true); + }); + }); + + test('should fire onDidChange when session items change for local type', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + let changeEventFired = false; + disposables.add(provider.onDidChange(() => { + changeEventFired = true; + })); + + mockChatSessionsService.notifySessionItemsChanged(localChatSessionType); + + assert.strictEqual(changeEventFired, true); + }); + }); + + test('should not fire onDidChange when session items change for other types', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + let changeEventFired = false; + disposables.add(provider.onDidChange(() => { + changeEventFired = true; + })); + + mockChatSessionsService.notifySessionItemsChanged('other-type'); + + assert.strictEqual(changeEventFired, false); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index c5cfb9806cb..7bdf86b9cd6 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -218,9 +218,10 @@ export class MockChatSessionsService implements IChatSessionsService { } registerModelProgressListener(model: IChatModel, callback: () => void): void { - throw new Error('Method not implemented.'); + // No-op implementation for testing } + getSessionDescription(chatModel: IChatModel): string | undefined { - throw new Error('Method not implemented.'); + return undefined; } }