Path: blob/main/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts
4780 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { DeferredPromise } from '../../../../../base/common/async.js';6import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';7import { toErrorMessage } from '../../../../../base/common/errorMessage.js';8import { BugIndicatingError, ErrorNoTelemetry } from '../../../../../base/common/errors.js';9import { Emitter, Event } from '../../../../../base/common/event.js';10import { MarkdownString } from '../../../../../base/common/htmlContent.js';11import { Iterable } from '../../../../../base/common/iterator.js';12import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';13import { revive } from '../../../../../base/common/marshalling.js';14import { Schemas } from '../../../../../base/common/network.js';15import { autorun, derived, IObservable } from '../../../../../base/common/observable.js';16import { isEqual } from '../../../../../base/common/resources.js';17import { StopWatch } from '../../../../../base/common/stopwatch.js';18import { isDefined } from '../../../../../base/common/types.js';19import { URI } from '../../../../../base/common/uri.js';20import { generateUuid } from '../../../../../base/common/uuid.js';21import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js';22import { localize } from '../../../../../nls.js';23import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';24import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';25import { ILogService } from '../../../../../platform/log/common/log.js';26import { Progress } from '../../../../../platform/progress/common/progress.js';27import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js';28import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';29import { IExtensionService } from '../../../../services/extensions/common/extensions.js';30import { InlineChatConfigKeys } from '../../../inlineChat/common/inlineChat.js';31import { IMcpService } from '../../../mcp/common/mcpTypes.js';32import { awaitStatsForSession } from '../chat.js';33import { IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../participants/chatAgents.js';34import { chatEditingSessionIsReady } from '../editing/chatEditingService.js';35import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData, toChatHistoryContent, updateRanges } from '../model/chatModel.js';36import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js';37import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js';38import { ChatRequestParser } from '../requestParser/chatRequestParser.js';39import { ChatMcpServersStarting, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js';40import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js';41import { IChatSessionsService } from '../chatSessionsService.js';42import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js';43import { IChatSlashCommandService } from '../participants/chatSlashCommands.js';44import { IChatTransferService } from '../model/chatTransferService.js';45import { LocalChatSessionUri } from '../model/chatUri.js';46import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js';47import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js';48import { ChatMessageRole, IChatMessage } from '../languageModels.js';49import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js';5051const serializedChatKey = 'interactive.sessions';5253class CancellableRequest implements IDisposable {54constructor(55public readonly cancellationTokenSource: CancellationTokenSource,56public requestId: string | undefined,57@ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService58) { }5960dispose() {61this.cancellationTokenSource.dispose();62}6364cancel() {65if (this.requestId) {66this.toolsService.cancelToolCallsForRequest(this.requestId);67}6869this.cancellationTokenSource.cancel();70}71}7273export class ChatService extends Disposable implements IChatService {74declare _serviceBrand: undefined;7576private readonly _sessionModels: ChatModelStore;77private readonly _pendingRequests = this._register(new DisposableResourceMap<CancellableRequest>());78private _saveModelsEnabled = true;7980private _transferredSessionResource: URI | undefined;81public get transferredSessionResource(): URI | undefined {82return this._transferredSessionResource;83}8485private readonly _onDidSubmitRequest = this._register(new Emitter<{ readonly chatSessionResource: URI }>());86public readonly onDidSubmitRequest = this._onDidSubmitRequest.event;8788private readonly _onDidPerformUserAction = this._register(new Emitter<IChatUserActionEvent>());89public readonly onDidPerformUserAction: Event<IChatUserActionEvent> = this._onDidPerformUserAction.event;9091private readonly _onDidDisposeSession = this._register(new Emitter<{ readonly sessionResource: URI[]; reason: 'cleared' }>());92public readonly onDidDisposeSession = this._onDidDisposeSession.event;9394private readonly _sessionFollowupCancelTokens = this._register(new DisposableResourceMap<CancellationTokenSource>());95private readonly _chatServiceTelemetry: ChatServiceTelemetry;96private readonly _chatSessionStore: ChatSessionStore;9798readonly requestInProgressObs: IObservable<boolean>;99100readonly chatModels: IObservable<Iterable<IChatModel>>;101102/**103* For test use only104*/105setSaveModelsEnabled(enabled: boolean): void {106this._saveModelsEnabled = enabled;107}108109/**110* For test use only111*/112waitForModelDisposals(): Promise<void> {113return this._sessionModels.waitForModelDisposals();114}115116public get edits2Enabled(): boolean {117return this.configurationService.getValue(ChatConfiguration.Edits2Enabled);118}119120private get isEmptyWindow(): boolean {121const workspace = this.workspaceContextService.getWorkspace();122return !workspace.configuration && workspace.folders.length === 0;123}124125constructor(126@IStorageService private readonly storageService: IStorageService,127@ILogService private readonly logService: ILogService,128@IExtensionService private readonly extensionService: IExtensionService,129@IInstantiationService private readonly instantiationService: IInstantiationService,130@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,131@IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService,132@IChatAgentService private readonly chatAgentService: IChatAgentService,133@IConfigurationService private readonly configurationService: IConfigurationService,134@IChatTransferService private readonly chatTransferService: IChatTransferService,135@IChatSessionsService private readonly chatSessionService: IChatSessionsService,136@IMcpService private readonly mcpService: IMcpService,137) {138super();139140this._sessionModels = this._register(instantiationService.createInstance(ChatModelStore, {141createModel: (props: IStartSessionProps) => this._startSession(props),142willDisposeModel: async (model: ChatModel) => {143const localSessionId = LocalChatSessionUri.parseLocalSessionId(model.sessionResource);144if (localSessionId && this.shouldStoreSession(model)) {145// Always preserve sessions that have custom titles, even if empty146if (model.getRequests().length === 0 && !model.customTitle) {147await this._chatSessionStore.deleteSession(localSessionId);148} else if (this._saveModelsEnabled) {149await this._chatSessionStore.storeSessions([model]);150}151} else if (!localSessionId && model.getRequests().length > 0) {152await this._chatSessionStore.storeSessionsMetadataOnly([model]);153}154}155}));156this._register(this._sessionModels.onDidDisposeModel(model => {157this._onDidDisposeSession.fire({ sessionResource: [model.sessionResource], reason: 'cleared' });158}));159160this._chatServiceTelemetry = this.instantiationService.createInstance(ChatServiceTelemetry);161this._chatSessionStore = this._register(this.instantiationService.createInstance(ChatSessionStore));162this._chatSessionStore.migrateDataIfNeeded(() => this.migrateData());163164const transferredData = this._chatSessionStore.getTransferredSessionData();165if (transferredData) {166this.trace('constructor', `Transferred session ${transferredData}`);167this._transferredSessionResource = transferredData;168}169170this.reviveSessionsWithEdits();171172this._register(storageService.onWillSaveState(() => this.saveState()));173174this.chatModels = derived(this, reader => [...this._sessionModels.observable.read(reader).values()]);175176this.requestInProgressObs = derived(reader => {177const models = this._sessionModels.observable.read(reader).values();178return Iterable.some(models, model => model.requestInProgress.read(reader));179});180}181182public get editingSessions() {183return [...this._sessionModels.values()].map(v => v.editingSession).filter(isDefined);184}185186isEnabled(location: ChatAgentLocation): boolean {187return this.chatAgentService.getContributedDefaultAgent(location) !== undefined;188}189190private migrateData(): ISerializableChatsData | undefined {191const sessionData = this.storageService.get(serializedChatKey, this.isEmptyWindow ? StorageScope.APPLICATION : StorageScope.WORKSPACE, '');192if (sessionData) {193const persistedSessions = this.deserializeChats(sessionData);194const countsForLog = Object.keys(persistedSessions).length;195if (countsForLog > 0) {196this.info('migrateData', `Restored ${countsForLog} persisted sessions`);197}198199return persistedSessions;200}201202return;203}204205private saveState(): void {206if (!this._saveModelsEnabled) {207return;208}209210const liveLocalChats = Array.from(this._sessionModels.values())211.filter(session => this.shouldStoreSession(session));212213this._chatSessionStore.storeSessions(liveLocalChats);214215const liveNonLocalChats = Array.from(this._sessionModels.values())216.filter(session => !LocalChatSessionUri.parseLocalSessionId(session.sessionResource));217this._chatSessionStore.storeSessionsMetadataOnly(liveNonLocalChats);218}219220/**221* Only persist local sessions from chat that are not imported.222*/223private shouldStoreSession(session: ChatModel): boolean {224if (!LocalChatSessionUri.parseLocalSessionId(session.sessionResource)) {225return false;226}227return session.initialLocation === ChatAgentLocation.Chat && !session.isImported;228}229230notifyUserAction(action: IChatUserActionEvent): void {231this._chatServiceTelemetry.notifyUserAction(action);232this._onDidPerformUserAction.fire(action);233if (action.action.kind === 'chatEditingSessionAction') {234const model = this._sessionModels.get(action.sessionResource);235if (model) {236model.notifyEditingAction(action.action);237}238}239}240241async setChatSessionTitle(sessionResource: URI, title: string): Promise<void> {242const model = this._sessionModels.get(sessionResource);243if (model) {244model.setCustomTitle(title);245}246247// Update the title in the file storage248const localSessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource);249if (localSessionId) {250await this._chatSessionStore.setSessionTitle(localSessionId, title);251// Trigger immediate save to ensure consistency252this.saveState();253}254}255256private trace(method: string, message?: string): void {257if (message) {258this.logService.trace(`ChatService#${method}: ${message}`);259} else {260this.logService.trace(`ChatService#${method}`);261}262}263264private info(method: string, message?: string): void {265if (message) {266this.logService.info(`ChatService#${method}: ${message}`);267} else {268this.logService.info(`ChatService#${method}`);269}270}271272private error(method: string, message: string): void {273this.logService.error(`ChatService#${method} ${message}`);274}275276private deserializeChats(sessionData: string): ISerializableChatsData {277try {278const arrayOfSessions: ISerializableChatDataIn[] = revive(JSON.parse(sessionData)); // Revive serialized URIs in session data279if (!Array.isArray(arrayOfSessions)) {280throw new Error('Expected array');281}282283const sessions = arrayOfSessions.reduce<ISerializableChatsData>((acc, session) => {284// Revive serialized markdown strings in response data285for (const request of session.requests) {286if (Array.isArray(request.response)) {287request.response = request.response.map((response) => {288if (typeof response === 'string') {289return new MarkdownString(response);290}291return response;292});293} else if (typeof request.response === 'string') {294request.response = [new MarkdownString(request.response)];295}296}297298acc[session.sessionId] = normalizeSerializableChatData(session);299return acc;300}, {});301return sessions;302} catch (err) {303this.error('deserializeChats', `Malformed session data: ${err}. [${sessionData.substring(0, 20)}${sessionData.length > 20 ? '...' : ''}]`);304return {};305}306}307308/**309* todo@connor4312 This will be cleaned up with the globalization of edits.310*/311private async reviveSessionsWithEdits(): Promise<void> {312const idx = await this._chatSessionStore.getIndex();313await Promise.all(Object.values(idx).map(async session => {314if (!session.hasPendingEdits) {315return;316}317318const sessionResource = LocalChatSessionUri.forSession(session.sessionId);319const sessionRef = await this.getOrRestoreSession(sessionResource);320if (sessionRef?.object.editingSession) {321await chatEditingSessionIsReady(sessionRef.object.editingSession);322// the session will hold a self-reference as long as there are modified files323sessionRef.dispose();324}325}));326}327328/**329* Returns an array of chat details for all persisted chat sessions that have at least one request.330* Chat sessions that have already been loaded into the chat view are excluded from the result.331* Imported chat sessions are also excluded from the result.332* TODO this is only used by the old "show chats" command which can be removed when the pre-agents view333* options are removed.334*/335async getLocalSessionHistory(): Promise<IChatDetail[]> {336const liveSessionItems = await this.getLiveSessionItems();337const historySessionItems = await this.getHistorySessionItems();338339return [...liveSessionItems, ...historySessionItems];340}341342/**343* Returns an array of chat details for all local live chat sessions.344*/345async getLiveSessionItems(): Promise<IChatDetail[]> {346return await Promise.all(Array.from(this._sessionModels.values())347.filter(session => this.shouldBeInHistory(session))348.map(async (session): Promise<IChatDetail> => {349const title = session.title || localize('newChat', "New Chat");350return {351sessionResource: session.sessionResource,352title,353lastMessageDate: session.lastMessageDate,354timing: session.timing,355isActive: true,356stats: await awaitStatsForSession(session),357lastResponseState: session.lastRequest?.response?.state ?? ResponseModelState.Pending,358};359}));360}361362/**363* Returns an array of chat details for all local chat sessions in history (not currently loaded).364*/365async getHistorySessionItems(): Promise<IChatDetail[]> {366const index = await this._chatSessionStore.getIndex();367return Object.values(index)368.filter(entry => !entry.isExternal)369.filter(entry => !this._sessionModels.has(LocalChatSessionUri.forSession(entry.sessionId)) && entry.initialLocation === ChatAgentLocation.Chat && !entry.isEmpty)370.map((entry): IChatDetail => {371const sessionResource = LocalChatSessionUri.forSession(entry.sessionId);372return ({373...entry,374sessionResource,375// TODO@roblourens- missing for old data- normalize inside the store376timing: entry.timing ?? { startTime: entry.lastMessageDate },377isActive: this._sessionModels.has(sessionResource),378// TODO@roblourens- missing for old data- normalize inside the store379lastResponseState: entry.lastResponseState ?? ResponseModelState.Complete,380});381});382}383384async getMetadataForSession(sessionResource: URI): Promise<IChatDetail | undefined> {385const index = await this._chatSessionStore.getIndex();386const metadata: IChatSessionEntryMetadata | undefined = index[sessionResource.toString()];387if (metadata) {388return {389...metadata,390sessionResource,391// TODO@roblourens- missing for old data- normalize inside the store392timing: metadata.timing ?? { startTime: metadata.lastMessageDate },393isActive: this._sessionModels.has(sessionResource),394// TODO@roblourens- missing for old data- normalize inside the store395lastResponseState: metadata.lastResponseState ?? ResponseModelState.Complete,396};397}398399return undefined;400}401402private shouldBeInHistory(entry: ChatModel): boolean {403return !entry.isImported && !!LocalChatSessionUri.parseLocalSessionId(entry.sessionResource) && entry.initialLocation === ChatAgentLocation.Chat;404}405406async removeHistoryEntry(sessionResource: URI): Promise<void> {407await this._chatSessionStore.deleteSession(this.toLocalSessionId(sessionResource));408this._onDidDisposeSession.fire({ sessionResource: [sessionResource], reason: 'cleared' });409}410411async clearAllHistoryEntries(): Promise<void> {412await this._chatSessionStore.clearAllSessions();413}414415startSession(location: ChatAgentLocation, options?: IChatSessionStartOptions): IChatModelReference {416this.trace('startSession');417const sessionId = generateUuid();418const sessionResource = LocalChatSessionUri.forSession(sessionId);419return this._sessionModels.acquireOrCreate({420initialData: undefined,421location,422sessionResource,423sessionId,424canUseTools: options?.canUseTools ?? true,425disableBackgroundKeepAlive: options?.disableBackgroundKeepAlive426});427}428429private _startSession(props: IStartSessionProps): ChatModel {430const { initialData, location, sessionResource, sessionId, canUseTools, transferEditingSession, disableBackgroundKeepAlive, inputState } = props;431const model = this.instantiationService.createInstance(ChatModel, initialData, { initialLocation: location, canUseTools, resource: sessionResource, sessionId, disableBackgroundKeepAlive, inputState });432if (location === ChatAgentLocation.Chat) {433model.startEditingSession(true, transferEditingSession);434}435436this.initializeSession(model);437return model;438}439440private initializeSession(model: ChatModel): void {441this.trace('initializeSession', `Initialize session ${model.sessionResource}`);442443// Activate the default extension provided agent but do not wait444// for it to be ready so that the session can be used immediately445// without having to wait for the agent to be ready.446this.activateDefaultAgent(model.initialLocation).catch(e => this.logService.error(e));447}448449async activateDefaultAgent(location: ChatAgentLocation): Promise<void> {450await this.extensionService.whenInstalledExtensionsRegistered();451452const defaultAgentData = this.chatAgentService.getContributedDefaultAgent(location) ?? this.chatAgentService.getContributedDefaultAgent(ChatAgentLocation.Chat);453if (!defaultAgentData) {454throw new ErrorNoTelemetry('No default agent contributed');455}456457// Await activation of the extension provided agent458// Using `activateById` as workaround for the issue459// https://github.com/microsoft/vscode/issues/250590460if (!defaultAgentData.isCore) {461await this.extensionService.activateById(defaultAgentData.extensionId, {462activationEvent: `onChatParticipant:${defaultAgentData.id}`,463extensionId: defaultAgentData.extensionId,464startup: false465});466}467468const defaultAgent = this.chatAgentService.getActivatedAgents().find(agent => agent.id === defaultAgentData.id);469if (!defaultAgent) {470throw new ErrorNoTelemetry('No default agent registered');471}472}473474getSession(sessionResource: URI): IChatModel | undefined {475return this._sessionModels.get(sessionResource);476}477478getActiveSessionReference(sessionResource: URI): IChatModelReference | undefined {479return this._sessionModels.acquireExisting(sessionResource);480}481482async getOrRestoreSession(sessionResource: URI): Promise<IChatModelReference | undefined> {483this.trace('getOrRestoreSession', `${sessionResource}`);484const existingRef = this._sessionModels.acquireExisting(sessionResource);485if (existingRef) {486return existingRef;487}488489const sessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource);490if (!sessionId) {491throw new Error(`Cannot restore non-local session ${sessionResource}`);492}493494let sessionData: ISerializableChatData | undefined;495if (isEqual(this.transferredSessionResource, sessionResource)) {496this._transferredSessionResource = undefined;497sessionData = revive(await this._chatSessionStore.readTransferredSession(sessionResource));498} else {499sessionData = revive(await this._chatSessionStore.readSession(sessionId));500}501502if (!sessionData) {503return undefined;504}505506const sessionRef = this._sessionModels.acquireOrCreate({507initialData: sessionData,508location: sessionData.initialLocation ?? ChatAgentLocation.Chat,509sessionResource,510sessionId,511canUseTools: true,512});513514return sessionRef;515}516517// There are some cases where this returns a real string. What happens if it doesn't?518// This had titles restored from the index, so just return titles from index instead, sync.519getSessionTitle(sessionResource: URI): string | undefined {520const sessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource);521if (!sessionId) {522return undefined;523}524525return this._sessionModels.get(sessionResource)?.title ??526this._chatSessionStore.getMetadataForSessionSync(sessionResource)?.title;527}528529loadSessionFromContent(data: IExportableChatData | ISerializableChatData): IChatModelReference | undefined {530const sessionId = (data as ISerializableChatData).sessionId ?? generateUuid();531const sessionResource = LocalChatSessionUri.forSession(sessionId);532return this._sessionModels.acquireOrCreate({533initialData: data,534location: data.initialLocation ?? ChatAgentLocation.Chat,535sessionResource,536sessionId,537canUseTools: true,538});539}540541async loadSessionForResource(chatSessionResource: URI, location: ChatAgentLocation, token: CancellationToken): Promise<IChatModelReference | undefined> {542// TODO: Move this into a new ChatModelService543544if (chatSessionResource.scheme === Schemas.vscodeLocalChatSession) {545return this.getOrRestoreSession(chatSessionResource);546}547548const existingRef = this._sessionModels.acquireExisting(chatSessionResource);549if (existingRef) {550return existingRef;551}552553const providedSession = await this.chatSessionService.getOrCreateChatSession(chatSessionResource, CancellationToken.None);554const chatSessionType = chatSessionResource.scheme;555556// Contributed sessions do not use UI tools557const modelRef = this._sessionModels.acquireOrCreate({558initialData: undefined,559location,560sessionResource: chatSessionResource,561canUseTools: false,562transferEditingSession: providedSession.transferredState?.editingSession,563inputState: providedSession.transferredState?.inputState,564});565566modelRef.object.setContributedChatSession({567chatSessionResource,568chatSessionType,569isUntitled: chatSessionResource.path.startsWith('/untitled-') //TODO(jospicer)570});571572const model = modelRef.object;573const disposables = new DisposableStore();574disposables.add(modelRef.object.onDidDispose(() => {575disposables.dispose();576providedSession.dispose();577}));578579let lastRequest: ChatRequestModel | undefined;580for (const message of providedSession.history) {581if (message.type === 'request') {582if (lastRequest) {583lastRequest.response?.complete();584}585586const requestText = message.prompt;587588const parsedRequest: IParsedChatRequest = {589text: requestText,590parts: [new ChatRequestTextPart(591new OffsetRange(0, requestText.length),592{ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: requestText.length + 1 },593requestText594)]595};596const agent =597message.participant598? this.chatAgentService.getAgent(message.participant) // TODO(jospicer): Remove and always hardcode?599: this.chatAgentService.getAgent(chatSessionType);600lastRequest = model.addRequest(parsedRequest,601message.variableData ?? { variables: [] },6020, // attempt603undefined,604agent,605undefined, // slashCommand606undefined, // confirmation607undefined, // locationData608undefined, // attachments609false, // Do not treat as requests completed, else edit pills won't show.610undefined,611undefined,612message.id613);614} else {615// response616if (lastRequest) {617for (const part of message.parts) {618model.acceptResponseProgress(lastRequest, part);619}620}621}622}623624if (providedSession.isCompleteObs?.get()) {625lastRequest?.response?.complete();626}627628if (providedSession.progressObs && lastRequest && providedSession.interruptActiveResponseCallback) {629const initialCancellationRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined);630this._pendingRequests.set(model.sessionResource, initialCancellationRequest);631const cancellationListener = disposables.add(new MutableDisposable());632633const createCancellationListener = (token: CancellationToken) => {634return token.onCancellationRequested(() => {635providedSession.interruptActiveResponseCallback?.().then(userConfirmedInterruption => {636if (!userConfirmedInterruption) {637// User cancelled the interruption638const newCancellationRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined);639this._pendingRequests.set(model.sessionResource, newCancellationRequest);640cancellationListener.value = createCancellationListener(newCancellationRequest.cancellationTokenSource.token);641}642});643});644};645646cancellationListener.value = createCancellationListener(initialCancellationRequest.cancellationTokenSource.token);647648let lastProgressLength = 0;649disposables.add(autorun(reader => {650const progressArray = providedSession.progressObs?.read(reader) ?? [];651const isComplete = providedSession.isCompleteObs?.read(reader) ?? false;652653// Process only new progress items654if (progressArray.length > lastProgressLength) {655const newProgress = progressArray.slice(lastProgressLength);656for (const progress of newProgress) {657model?.acceptResponseProgress(lastRequest, progress);658}659lastProgressLength = progressArray.length;660}661662// Handle completion663if (isComplete) {664lastRequest.response?.complete();665cancellationListener.clear();666}667}));668} else {669if (lastRequest && model.editingSession) {670// wait for timeline to load so that a 'changes' part is added when the response completes671await chatEditingSessionIsReady(model.editingSession);672lastRequest.response?.complete();673}674}675676return modelRef;677}678679getChatSessionFromInternalUri(sessionResource: URI): IChatSessionContext | undefined {680const model = this._sessionModels.get(sessionResource);681if (!model) {682return;683}684const { contributedChatSession } = model;685return contributedChatSession;686}687688async resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise<void> {689const model = this._sessionModels.get(request.session.sessionResource);690if (!model && model !== request.session) {691throw new Error(`Unknown session: ${request.session.sessionResource}`);692}693694const cts = this._pendingRequests.get(request.session.sessionResource);695if (cts) {696this.trace('resendRequest', `Session ${request.session.sessionResource} already has a pending request, cancelling...`);697cts.cancel();698}699700const location = options?.location ?? model.initialLocation;701const attempt = options?.attempt ?? 0;702const enableCommandDetection = !options?.noCommandDetection;703const defaultAgent = this.chatAgentService.getDefaultAgent(location, options?.modeInfo?.kind)!;704705model.removeRequest(request.id, ChatRequestRemovalReason.Resend);706707const resendOptions: IChatSendRequestOptions = {708...options,709locationData: request.locationData,710attachedContext: request.attachedContext,711};712await this._sendRequestAsync(model, model.sessionResource, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions).responseCompletePromise;713}714715async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise<IChatSendRequestData | undefined> {716this.trace('sendRequest', `sessionResource: ${sessionResource.toString()}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`);717718719if (!request.trim() && !options?.slashCommand && !options?.agentId && !options?.agentIdSilent) {720this.trace('sendRequest', 'Rejected empty message');721return;722}723724const model = this._sessionModels.get(sessionResource);725if (!model) {726throw new Error(`Unknown session: ${sessionResource}`);727}728729if (this._pendingRequests.has(sessionResource)) {730this.trace('sendRequest', `Session ${sessionResource} already has a pending request`);731return;732}733734const requests = model.getRequests();735for (let i = requests.length - 1; i >= 0; i -= 1) {736const request = requests[i];737if (request.shouldBeRemovedOnSend) {738if (request.shouldBeRemovedOnSend.afterUndoStop) {739request.response?.finalizeUndoState();740} else {741await this.removeRequest(sessionResource, request.id);742}743}744}745746const location = options?.location ?? model.initialLocation;747const attempt = options?.attempt ?? 0;748const defaultAgent = this.chatAgentService.getDefaultAgent(location, options?.modeInfo?.kind)!;749750const parsedRequest = this.parseChatRequest(sessionResource, request, location, options);751const silentAgent = options?.agentIdSilent ? this.chatAgentService.getAgent(options.agentIdSilent) : undefined;752const agent = silentAgent ?? parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent;753const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart);754755// This method is only returning whether the request was accepted - don't block on the actual request756return {757...this._sendRequestAsync(model, sessionResource, parsedRequest, attempt, !options?.noCommandDetection, silentAgent ?? defaultAgent, location, options),758agent,759slashCommand: agentSlashCommandPart?.command,760};761}762763private parseChatRequest(sessionResource: URI, request: string, location: ChatAgentLocation, options: IChatSendRequestOptions | undefined): IParsedChatRequest {764let parserContext = options?.parserContext;765if (options?.agentId) {766const agent = this.chatAgentService.getAgent(options.agentId);767if (!agent) {768throw new Error(`Unknown agent: ${options.agentId}`);769}770parserContext = { selectedAgent: agent, mode: options.modeInfo?.kind };771const commandPart = options.slashCommand ? ` ${chatSubcommandLeader}${options.slashCommand}` : '';772request = `${chatAgentLeader}${agent.name}${commandPart} ${request}`;773}774775const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionResource, request, location, parserContext);776return parsedRequest;777}778779private refreshFollowupsCancellationToken(sessionResource: URI): CancellationToken {780this._sessionFollowupCancelTokens.get(sessionResource)?.cancel();781const newTokenSource = new CancellationTokenSource();782this._sessionFollowupCancelTokens.set(sessionResource, newTokenSource);783784return newTokenSource.token;785}786787private _sendRequestAsync(model: ChatModel, sessionResource: URI, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, defaultAgent: IChatAgentData, location: ChatAgentLocation, options?: IChatSendRequestOptions): IChatSendRequestResponseState {788const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionResource);789let request: ChatRequestModel;790const agentPart = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart);791const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart);792const commandPart = parsedRequest.parts.find((r): r is ChatRequestSlashCommandPart => r instanceof ChatRequestSlashCommandPart);793const requests = [...model.getRequests()];794const requestTelemetry = this.instantiationService.createInstance(ChatRequestTelemetry, {795agent: agentPart?.agent ?? defaultAgent,796agentSlashCommandPart,797commandPart,798sessionId: model.sessionId,799location: model.initialLocation,800options,801enableCommandDetection802});803804let gotProgress = false;805const requestType = commandPart ? 'slashCommand' : 'string';806807const responseCreated = new DeferredPromise<IChatResponseModel>();808let responseCreatedComplete = false;809function completeResponseCreated(): void {810if (!responseCreatedComplete && request?.response) {811responseCreated.complete(request.response);812responseCreatedComplete = true;813}814}815816const store = new DisposableStore();817const source = store.add(new CancellationTokenSource());818const token = source.token;819const sendRequestInternal = async () => {820const progressCallback = (progress: IChatProgress[]) => {821if (token.isCancellationRequested) {822return;823}824825gotProgress = true;826827for (let i = 0; i < progress.length; i++) {828const isLast = i === progress.length - 1;829const progressItem = progress[i];830831if (progressItem.kind === 'markdownContent') {832this.trace('sendRequest', `Provider returned progress for session ${model.sessionResource}, ${progressItem.content.value.length} chars`);833} else {834this.trace('sendRequest', `Provider returned progress: ${JSON.stringify(progressItem)}`);835}836837model.acceptResponseProgress(request, progressItem, !isLast);838}839completeResponseCreated();840};841842let detectedAgent: IChatAgentData | undefined;843let detectedCommand: IChatAgentCommand | undefined;844845const stopWatch = new StopWatch(false);846store.add(token.onCancellationRequested(() => {847this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`);848if (!request) {849return;850}851852requestTelemetry.complete({853timeToFirstProgress: undefined,854result: 'cancelled',855// Normally timings happen inside the EH around the actual provider. For cancellation we can measure how long the user waited before cancelling856totalTime: stopWatch.elapsed(),857requestType,858detectedAgent,859request,860});861862model.cancelRequest(request);863}));864865try {866let rawResult: IChatAgentResult | null | undefined;867let agentOrCommandFollowups: Promise<IChatFollowup[] | undefined> | undefined = undefined;868if (agentPart || (defaultAgent && !commandPart)) {869const prepareChatAgentRequest = (agent: IChatAgentData, command?: IChatAgentCommand, enableCommandDetection?: boolean, chatRequest?: ChatRequestModel, isParticipantDetected?: boolean): IChatAgentRequest => {870const initVariableData: IChatRequestVariableData = { variables: [] };871request = chatRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, agent, command, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get());872873let variableData: IChatRequestVariableData;874let message: string;875if (chatRequest) {876variableData = chatRequest.variableData;877message = getPromptText(request.message).message;878} else {879variableData = { variables: this.prepareContext(request.attachedContext) };880model.updateRequest(request, variableData);881882const promptTextResult = getPromptText(request.message);883variableData = updateRanges(variableData, promptTextResult.diff); // TODO bit of a hack884message = promptTextResult.message;885}886887const agentRequest: IChatAgentRequest = {888sessionResource: model.sessionResource,889requestId: request.id,890agentId: agent.id,891message,892command: command?.name,893variables: variableData,894enableCommandDetection,895isParticipantDetected,896attempt,897location,898locationData: request.locationData,899acceptedConfirmationData: options?.acceptedConfirmationData,900rejectedConfirmationData: options?.rejectedConfirmationData,901userSelectedModelId: options?.userSelectedModelId,902userSelectedTools: options?.userSelectedTools?.get(),903modeInstructions: options?.modeInfo?.modeInstructions,904editedFileEvents: request.editedFileEvents,905};906907let isInitialTools = true;908909store.add(autorun(reader => {910const tools = options?.userSelectedTools?.read(reader);911if (isInitialTools) {912isInitialTools = false;913return;914}915916if (tools) {917this.chatAgentService.setRequestTools(agent.id, request.id, tools);918// in case the request has not been sent out yet:919agentRequest.userSelectedTools = tools;920}921}));922923return agentRequest;924};925926if (927this.configurationService.getValue('chat.detectParticipant.enabled') !== false &&928this.chatAgentService.hasChatParticipantDetectionProviders() &&929!agentPart &&930!commandPart &&931!agentSlashCommandPart &&932enableCommandDetection &&933(location !== ChatAgentLocation.EditorInline || !this.configurationService.getValue(InlineChatConfigKeys.EnableV2)) &&934options?.modeInfo?.kind !== ChatModeKind.Agent &&935options?.modeInfo?.kind !== ChatModeKind.Edit &&936!options?.agentIdSilent937) {938// We have no agent or command to scope history with, pass the full history to the participant detection provider939const defaultAgentHistory = this.getHistoryEntriesFromModel(requests, location, defaultAgent.id);940941// Prepare the request object that we will send to the participant detection provider942const chatAgentRequest = prepareChatAgentRequest(defaultAgent, undefined, enableCommandDetection, undefined, false);943944const result = await this.chatAgentService.detectAgentOrCommand(chatAgentRequest, defaultAgentHistory, { location }, token);945if (result && this.chatAgentService.getAgent(result.agent.id)?.locations?.includes(location)) {946// Update the response in the ChatModel to reflect the detected agent and command947request.response?.setAgent(result.agent, result.command);948detectedAgent = result.agent;949detectedCommand = result.command;950}951}952953const agent = (detectedAgent ?? agentPart?.agent ?? defaultAgent)!;954const command = detectedCommand ?? agentSlashCommandPart?.command;955956await this.extensionService.activateByEvent(`onChatParticipant:${agent.id}`);957958// Recompute history in case the agent or command changed959const history = this.getHistoryEntriesFromModel(requests, location, agent.id);960const requestProps = prepareChatAgentRequest(agent, command, enableCommandDetection, request /* Reuse the request object if we already created it for participant detection */, !!detectedAgent);961this.generateInitialChatTitleIfNeeded(model, requestProps, defaultAgent, token);962const pendingRequest = this._pendingRequests.get(sessionResource);963if (pendingRequest && !pendingRequest.requestId) {964pendingRequest.requestId = requestProps.requestId;965}966completeResponseCreated();967968// MCP autostart: only run for native VS Code sessions (sidebar, new editors) but not for extension contributed sessions that have inputType set.969if (model.canUseTools) {970const autostartResult = new ChatMcpServersStarting(this.mcpService.autostart(token));971if (!autostartResult.isEmpty) {972progressCallback([autostartResult]);973await autostartResult.wait();974}975}976977const agentResult = await this.chatAgentService.invokeAgent(agent.id, requestProps, progressCallback, history, token);978rawResult = agentResult;979agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, history, followupsCancelToken);980} else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) {981if (commandPart.slashCommand.silent !== true) {982request = model.addRequest(parsedRequest, { variables: [] }, attempt, options?.modeInfo);983completeResponseCreated();984}985// contributed slash commands986// TODO: spell this out in the UI987const history: IChatMessage[] = [];988for (const modelRequest of model.getRequests()) {989if (!modelRequest.response) {990continue;991}992history.push({ role: ChatMessageRole.User, content: [{ type: 'text', value: modelRequest.message.text }] });993history.push({ role: ChatMessageRole.Assistant, content: [{ type: 'text', value: modelRequest.response.response.toString() }] });994}995const message = parsedRequest.text;996const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress<IChatProgress>(p => {997progressCallback([p]);998}), history, location, model.sessionResource, token);999agentOrCommandFollowups = Promise.resolve(commandResult?.followUp);1000rawResult = {};10011002} else {1003throw new Error(`Cannot handle request`);1004}10051006if (token.isCancellationRequested && !rawResult) {1007return;1008} else {1009if (!rawResult) {1010this.trace('sendRequest', `Provider returned no response for session ${model.sessionResource}`);1011rawResult = { errorDetails: { message: localize('emptyResponse', "Provider returned null response") } };1012}10131014const result = rawResult.errorDetails?.responseIsFiltered ? 'filtered' :1015rawResult.errorDetails && gotProgress ? 'errorWithOutput' :1016rawResult.errorDetails ? 'error' :1017'success';10181019requestTelemetry.complete({1020timeToFirstProgress: rawResult.timings?.firstProgress,1021totalTime: rawResult.timings?.totalElapsed,1022result,1023requestType,1024detectedAgent,1025request,1026});10271028model.setResponse(request, rawResult);1029completeResponseCreated();1030this.trace('sendRequest', `Provider returned response for session ${model.sessionResource}`);10311032request.response?.complete();1033if (agentOrCommandFollowups) {1034agentOrCommandFollowups.then(followups => {1035model.setFollowups(request, followups);1036const commandForTelemetry = agentSlashCommandPart ? agentSlashCommandPart.command.name : commandPart?.slashCommand.command;1037this._chatServiceTelemetry.retrievedFollowups(agentPart?.agent.id ?? '', commandForTelemetry, followups?.length ?? 0);1038});1039}1040}1041} catch (err) {1042this.logService.error(`Error while handling chat request: ${toErrorMessage(err, true)}`);1043requestTelemetry.complete({1044timeToFirstProgress: undefined,1045totalTime: undefined,1046result: 'error',1047requestType,1048detectedAgent,1049request,1050});1051if (request) {1052const rawResult: IChatAgentResult = { errorDetails: { message: err.message } };1053model.setResponse(request, rawResult);1054completeResponseCreated();1055request.response?.complete();1056}1057} finally {1058store.dispose();1059}1060};1061const rawResponsePromise = sendRequestInternal();1062// Note- requestId is not known at this point, assigned later1063this._pendingRequests.set(model.sessionResource, this.instantiationService.createInstance(CancellableRequest, source, undefined));1064rawResponsePromise.finally(() => {1065this._pendingRequests.deleteAndDispose(model.sessionResource);1066});1067this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource });1068return {1069responseCreatedPromise: responseCreated.p,1070responseCompletePromise: rawResponsePromise,1071};1072}10731074private generateInitialChatTitleIfNeeded(model: ChatModel, request: IChatAgentRequest, defaultAgent: IChatAgentData, token: CancellationToken): void {1075// Generate a title only for the first request, and only via the default agent.1076// Use a single-entry history based on the current request (no full chat history).1077if (model.getRequests().length !== 1 || model.customTitle) {1078return;1079}10801081const singleEntryHistory: IChatAgentHistoryEntry[] = [{1082request,1083response: [],1084result: {}1085}];1086const generate = async () => {1087const title = await this.chatAgentService.getChatTitle(defaultAgent.id, singleEntryHistory, token);1088if (title && !model.customTitle) {1089model.setCustomTitle(title);1090}1091};1092void generate();1093}10941095private prepareContext(attachedContextVariables: IChatRequestVariableEntry[] | undefined): IChatRequestVariableEntry[] {1096attachedContextVariables ??= [];10971098// "reverse", high index first so that replacement is simple1099attachedContextVariables.sort((a, b) => {1100// If either range is undefined, sort it to the back1101if (!a.range && !b.range) {1102return 0; // Keep relative order if both ranges are undefined1103}1104if (!a.range) {1105return 1; // a goes after b1106}1107if (!b.range) {1108return -1; // a goes before b1109}1110return b.range.start - a.range.start;1111});11121113return attachedContextVariables;1114}11151116private getHistoryEntriesFromModel(requests: IChatRequestModel[], location: ChatAgentLocation, forAgentId: string): IChatAgentHistoryEntry[] {1117const history: IChatAgentHistoryEntry[] = [];1118const agent = this.chatAgentService.getAgent(forAgentId);1119for (const request of requests) {1120if (!request.response) {1121continue;1122}11231124if (forAgentId !== request.response.agent?.id && !agent?.isDefault && !agent?.canAccessPreviousChatHistory) {1125// An agent only gets to see requests that were sent to this agent.1126// The default agent (the undefined case), or agents with 'canAccessPreviousChatHistory', get to see all of them.1127continue;1128}11291130// Do not save to history inline completions1131if (location === ChatAgentLocation.EditorInline) {1132continue;1133}11341135const promptTextResult = getPromptText(request.message);1136const historyRequest: IChatAgentRequest = {1137sessionResource: request.session.sessionResource,1138requestId: request.id,1139agentId: request.response.agent?.id ?? '',1140message: promptTextResult.message,1141command: request.response.slashCommand?.name,1142variables: updateRanges(request.variableData, promptTextResult.diff), // TODO bit of a hack1143location: ChatAgentLocation.Chat,1144editedFileEvents: request.editedFileEvents,1145};1146history.push({ request: historyRequest, response: toChatHistoryContent(request.response.response.value), result: request.response.result ?? {} });1147}11481149return history;1150}11511152async removeRequest(sessionResource: URI, requestId: string): Promise<void> {1153const model = this._sessionModels.get(sessionResource);1154if (!model) {1155throw new Error(`Unknown session: ${sessionResource}`);1156}11571158const pendingRequest = this._pendingRequests.get(sessionResource);1159if (pendingRequest?.requestId === requestId) {1160pendingRequest.cancel();1161this._pendingRequests.deleteAndDispose(sessionResource);1162}11631164model.removeRequest(requestId);1165}11661167async adoptRequest(sessionResource: URI, request: IChatRequestModel) {1168if (!(request instanceof ChatRequestModel)) {1169throw new TypeError('Can only adopt requests of type ChatRequestModel');1170}1171const target = this._sessionModels.get(sessionResource);1172if (!target) {1173throw new Error(`Unknown session: ${sessionResource}`);1174}11751176const oldOwner = request.session;1177target.adoptRequest(request);11781179if (request.response && !request.response.isComplete) {1180const cts = this._pendingRequests.deleteAndLeak(oldOwner.sessionResource);1181if (cts) {1182cts.requestId = request.id;1183this._pendingRequests.set(target.sessionResource, cts);1184}1185}1186}11871188async addCompleteRequest(sessionResource: URI, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, attempt: number | undefined, response: IChatCompleteResponse): Promise<void> {1189this.trace('addCompleteRequest', `message: ${message}`);11901191const model = this._sessionModels.get(sessionResource);1192if (!model) {1193throw new Error(`Unknown session: ${sessionResource}`);1194}11951196const parsedRequest = typeof message === 'string' ?1197this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionResource, message) :1198message;1199const request = model.addRequest(parsedRequest, variableData || { variables: [] }, attempt ?? 0, undefined, undefined, undefined, undefined, undefined, undefined, true);1200if (typeof response.message === 'string') {1201// TODO is this possible?1202model.acceptResponseProgress(request, { content: new MarkdownString(response.message), kind: 'markdownContent' });1203} else {1204for (const part of response.message) {1205model.acceptResponseProgress(request, part, true);1206}1207}1208model.setResponse(request, response.result || {});1209if (response.followups !== undefined) {1210model.setFollowups(request, response.followups);1211}1212request.response?.complete();1213}12141215cancelCurrentRequestForSession(sessionResource: URI): void {1216this.trace('cancelCurrentRequestForSession', `session: ${sessionResource}`);1217this._pendingRequests.get(sessionResource)?.cancel();1218this._pendingRequests.deleteAndDispose(sessionResource);1219}12201221public hasSessions(): boolean {1222return this._chatSessionStore.hasSessions();1223}12241225async transferChatSession(transferredSessionResource: URI, toWorkspace: URI): Promise<void> {1226if (!LocalChatSessionUri.isLocalSession(transferredSessionResource)) {1227throw new Error(`Can only transfer local chat sessions. Invalid session: ${transferredSessionResource}`);1228}12291230const model = this._sessionModels.get(transferredSessionResource) as ChatModel | undefined;1231if (!model) {1232throw new Error(`Failed to transfer session. Unknown session: ${transferredSessionResource}`);1233}12341235if (model.initialLocation !== ChatAgentLocation.Chat) {1236throw new Error(`Can only transfer chat sessions located in the Chat view. Session ${transferredSessionResource} has location=${model.initialLocation}`);1237}12381239await this._chatSessionStore.storeTransferSession({1240sessionResource: model.sessionResource,1241timestampInMilliseconds: Date.now(),1242toWorkspace: toWorkspace,1243}, model);1244this.chatTransferService.addWorkspaceToTransferred(toWorkspace);1245this.trace('transferChatSession', `Transferred session ${model.sessionResource} to workspace ${toWorkspace.toString()}`);1246}12471248getChatStorageFolder(): URI {1249return this._chatSessionStore.getChatStorageFolder();1250}12511252logChatIndex(): void {1253this._chatSessionStore.logIndex();1254}12551256setTitle(sessionResource: URI, title: string): void {1257this._sessionModels.get(sessionResource)?.setCustomTitle(title);1258}12591260appendProgress(request: IChatRequestModel, progress: IChatProgress): void {1261const model = this._sessionModels.get(request.session.sessionResource);1262if (!(request instanceof ChatRequestModel)) {1263throw new BugIndicatingError('Can only append progress to requests of type ChatRequestModel');1264}12651266model?.acceptResponseProgress(request, progress);1267}12681269private toLocalSessionId(sessionResource: URI) {1270const localSessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource);1271if (!localSessionId) {1272throw new Error(`Invalid local chat session resource: ${sessionResource}`);1273}1274return localSessionId;1275}1276}127712781279