Local agent sessions provider cleanup (#279359) (#279363)

* Local agent sessions provider cleanup (#279359)

* add tests
This commit is contained in:
Benjamin Pasero
2025-11-25 15:19:38 +01:00
committed by GitHub
parent b1018f0c37
commit afe34566a1
8 changed files with 928 additions and 123 deletions

View File

@@ -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
}
}

View File

@@ -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<void>());
readonly onDidChange: Event<void> = this._onDidChange.event;
readonly onDidChange = this._onDidChange.event;
readonly _onDidChangeChatSessionItems = this._register(new Emitter<void>());
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<IChatSessionItem[]> {
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<ChatSessionItemWithProvider[]> {
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,
};

View File

@@ -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);

View File

@@ -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.'));

View File

@@ -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();

View File

@@ -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';

View File

@@ -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<IChatWidget>();
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<IChatWidget> {
return this.widgets.filter(w => w.location === location);
}
revealWidget(_preserveFocus?: boolean): Promise<IChatWidget | undefined> {
return Promise.resolve(undefined);
}
reveal(_widget: IChatWidget, _preserveFocus?: boolean): Promise<boolean> {
return Promise.resolve(true);
}
getAllWidgets(): ReadonlyArray<IChatWidget> {
return this.widgets;
}
openSession(_sessionResource: URI): Promise<IChatWidget | undefined> {
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<string, IChatModel>();
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<any> {
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<any> {
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<any> {
throw new Error('Method not implemented.');
}
resendRequest(_request: IChatRequestModel, _options?: any): Promise<void> {
throw new Error('Method not implemented.');
}
adoptRequest(_sessionResource: URI, _request: IChatRequestModel): Promise<void> {
throw new Error('Method not implemented.');
}
removeRequest(_sessionResource: URI, _requestId: string): Promise<void> {
throw new Error('Method not implemented.');
}
cancelCurrentRequestForSession(_sessionResource: URI): void { }
addCompleteRequest(): void { }
async getLocalSessionHistory(): Promise<IChatDetail[]> {
return this.historySessionItems;
}
async clearAllHistoryEntries(): Promise<void> { }
async removeHistoryEntry(_resource: URI): Promise<void> { }
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<void> {
return Promise.resolve();
}
getChatSessionFromInternalUri(_sessionResource: URI): any {
return undefined;
}
getLiveSessionItems(): IChatDetail[] {
return this.liveSessionItems;
}
async getHistorySessionItems(): Promise<IChatDetail[]> {
return this.historySessionItems;
}
waitForModelDisposals(): Promise<void> {
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<IChatResponseModel> = {
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);
});
});
});
});

View File

@@ -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;
}
}