Path: blob/main/src/vs/workbench/api/browser/mainThreadChatSessions.ts
3296 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 { raceCancellationError } from '../../../base/common/async.js';6import { CancellationToken } from '../../../base/common/cancellation.js';7import { Emitter } from '../../../base/common/event.js';8import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js';9import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';10import { revive } from '../../../base/common/marshalling.js';11import { autorun, IObservable, observableValue } from '../../../base/common/observable.js';12import { localize } from '../../../nls.js';13import { IDialogService } from '../../../platform/dialogs/common/dialogs.js';14import { ILogService } from '../../../platform/log/common/log.js';15import { ChatViewId } from '../../contrib/chat/browser/chat.js';16import { ChatViewPane } from '../../contrib/chat/browser/chatViewPane.js';17import { IChatAgentRequest } from '../../contrib/chat/common/chatAgents.js';18import { IChatContentInlineReference, IChatProgress } from '../../contrib/chat/common/chatService.js';19import { ChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js';20import { ChatSessionUri } from '../../contrib/chat/common/chatUri.js';21import { EditorGroupColumn } from '../../services/editor/common/editorGroupColumn.js';22import { IEditorService } from '../../services/editor/common/editorService.js';23import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';24import { Dto } from '../../services/extensions/common/proxyIdentifier.js';25import { IViewsService } from '../../services/views/common/viewsService.js';26import { ExtHostChatSessionsShape, ExtHostContext, IChatProgressDto, IChatSessionHistoryItemDto, MainContext, MainThreadChatSessionsShape } from '../common/extHost.protocol.js';2728export class ObservableChatSession extends Disposable implements ChatSession {29static generateSessionKey(providerHandle: number, sessionId: string) {30return `${providerHandle}_${sessionId}`;31}3233readonly sessionId: string;34readonly providerHandle: number;35readonly history: Array<IChatSessionHistoryItem>;3637private readonly _progressObservable = observableValue<IChatProgress[]>(this, []);38private readonly _isCompleteObservable = observableValue<boolean>(this, false);3940private readonly _onWillDispose = new Emitter<void>();41readonly onWillDispose = this._onWillDispose.event;4243private readonly _pendingProgressChunks = new Map<string, IChatProgress[]>();44private _isInitialized = false;45private _interruptionWasCanceled = false;46private _disposalPending = false;4748private _initializationPromise?: Promise<void>;4950interruptActiveResponseCallback?: () => Promise<boolean>;51requestHandler?: (52request: IChatAgentRequest,53progress: (progress: IChatProgress[]) => void,54history: any[],55token: CancellationToken56) => Promise<void>;5758private readonly _proxy: ExtHostChatSessionsShape;59private readonly _providerHandle: number;60private readonly _logService: ILogService;61private readonly _dialogService: IDialogService;6263get sessionKey(): string {64return ObservableChatSession.generateSessionKey(this.providerHandle, this.sessionId);65}6667get progressObs(): IObservable<IChatProgress[]> {68return this._progressObservable;69}7071get isCompleteObs(): IObservable<boolean> {72return this._isCompleteObservable;73}7475constructor(76id: string,77providerHandle: number,78proxy: ExtHostChatSessionsShape,79logService: ILogService,80dialogService: IDialogService81) {82super();8384this.sessionId = id;85this.providerHandle = providerHandle;86this.history = [];87this._proxy = proxy;88this._providerHandle = providerHandle;89this._logService = logService;90this._dialogService = dialogService;91}9293initialize(token: CancellationToken): Promise<void> {94if (!this._initializationPromise) {95this._initializationPromise = this._doInitializeContent(token);96}97return this._initializationPromise;98}99100private async _doInitializeContent(token: CancellationToken): Promise<void> {101try {102const sessionContent = await raceCancellationError(103this._proxy.$provideChatSessionContent(this._providerHandle, this.sessionId, token),104token105);106107this.history.length = 0;108this.history.push(...sessionContent.history.map((turn: IChatSessionHistoryItemDto) => {109if (turn.type === 'request') {110return { type: 'request' as const, prompt: turn.prompt, participant: turn.participant };111}112113return {114type: 'response' as const,115parts: turn.parts.map((part: IChatProgressDto) => revive(part) as IChatProgress),116participant: turn.participant117};118}));119120if (sessionContent.hasActiveResponseCallback && !this.interruptActiveResponseCallback) {121this.interruptActiveResponseCallback = async () => {122const confirmInterrupt = () => {123if (this._disposalPending) {124this._proxy.$disposeChatSessionContent(this._providerHandle, this.sessionId);125this._disposalPending = false;126}127this._proxy.$interruptChatSessionActiveResponse(this._providerHandle, this.sessionId, 'ongoing');128return true;129};130131if (sessionContent.supportsInterruption) {132// If the session supports hot reload, interrupt without confirmation133return confirmInterrupt();134}135136// Prompt the user to confirm interruption137return this._dialogService.confirm({138message: localize('interruptActiveResponse', 'Are you sure you want to interrupt the active session?')139}).then(confirmed => {140if (confirmed.confirmed) {141// User confirmed interruption - dispose the session content on extension host142return confirmInterrupt();143} else {144// When user cancels the interruption, fire an empty progress message to keep the session alive145// This matches the behavior of the old implementation146this._addProgress([{147kind: 'progressMessage',148content: { value: '', isTrusted: false }149}]);150// Set flag to prevent completion when extension host calls handleProgressComplete151this._interruptionWasCanceled = true;152// User canceled interruption - cancel the deferred disposal153if (this._disposalPending) {154this._logService.info(`Canceling deferred disposal for session ${this.sessionId} (user canceled interruption)`);155this._disposalPending = false;156}157return false;158}159});160};161}162163if (sessionContent.hasRequestHandler && !this.requestHandler) {164this.requestHandler = async (165request: IChatAgentRequest,166progress: (progress: IChatProgress[]) => void,167history: any[],168token: CancellationToken169) => {170// Clear previous progress and mark as active171this._progressObservable.set([], undefined);172this._isCompleteObservable.set(false, undefined);173174// Set up reactive progress observation before starting the request175let lastProgressLength = 0;176const progressDisposable = autorun(reader => {177const progressArray = this._progressObservable.read(reader);178const isComplete = this._isCompleteObservable.read(reader);179180if (progressArray.length > lastProgressLength) {181const newProgress = progressArray.slice(lastProgressLength);182progress(newProgress);183lastProgressLength = progressArray.length;184}185186if (isComplete) {187progressDisposable.dispose();188}189});190191try {192await this._proxy.$invokeChatSessionRequestHandler(this._providerHandle, this.sessionId, request, history, token);193194// Only mark as complete if there's no active response callback195// Sessions with active response callbacks should only complete when explicitly told to via handleProgressComplete196if (!this._isCompleteObservable.get() && !this.interruptActiveResponseCallback) {197this._markComplete();198}199} catch (error) {200const errorProgress: IChatProgress = {201kind: 'progressMessage',202content: { value: `Error: ${error instanceof Error ? error.message : String(error)}`, isTrusted: false }203};204205this._addProgress([errorProgress]);206this._markComplete();207throw error;208} finally {209// Ensure progress observation is cleaned up210progressDisposable.dispose();211}212};213}214215this._isInitialized = true;216217// Process any pending progress chunks218const hasActiveResponse = sessionContent.hasActiveResponseCallback;219const hasRequestHandler = sessionContent.hasRequestHandler;220const hasAnyCapability = hasActiveResponse || hasRequestHandler;221222for (const [requestId, chunks] of this._pendingProgressChunks) {223this._logService.debug(`Processing ${chunks.length} pending progress chunks for session ${this.sessionId}, requestId ${requestId}`);224this._addProgress(chunks);225}226this._pendingProgressChunks.clear();227228// If session has no active response callback and no request handler, mark it as complete229if (!hasAnyCapability) {230this._isCompleteObservable.set(true, undefined);231}232233} catch (error) {234this._logService.error(`Failed to initialize chat session ${this.sessionId}:`, error);235throw error;236}237}238239/**240* Handle progress chunks coming from the extension host.241* If the session is not initialized yet, the chunks will be queued.242*/243handleProgressChunk(requestId: string, progress: IChatProgress[]): void {244if (!this._isInitialized) {245const existing = this._pendingProgressChunks.get(requestId) || [];246this._pendingProgressChunks.set(requestId, [...existing, ...progress]);247this._logService.debug(`Queuing ${progress.length} progress chunks for session ${this.sessionId}, requestId ${requestId} (session not initialized)`);248return;249}250251this._addProgress(progress);252}253254/**255* Handle progress completion from the extension host.256*/257handleProgressComplete(requestId: string): void {258// Clean up any pending chunks for this request259this._pendingProgressChunks.delete(requestId);260261if (this._isInitialized) {262// Don't mark as complete if user canceled the interruption263if (!this._interruptionWasCanceled) {264this._markComplete();265} else {266// Reset the flag and don't mark as complete267this._interruptionWasCanceled = false;268}269}270}271272private _addProgress(progress: IChatProgress[]): void {273const currentProgress = this._progressObservable.get();274this._progressObservable.set([...currentProgress, ...progress], undefined);275}276277private _markComplete(): void {278if (!this._isCompleteObservable.get()) {279this._isCompleteObservable.set(true, undefined);280}281}282283override dispose(): void {284this._onWillDispose.fire();285this._onWillDispose.dispose();286this._pendingProgressChunks.clear();287288// If this session has an active response callback and disposal is happening,289// defer the actual session content disposal until we know the user's choice290if (this.interruptActiveResponseCallback && !this._interruptionWasCanceled) {291this._disposalPending = true;292// The actual disposal will happen in the interruption callback based on user's choice293} else {294// No active response callback or user already canceled interruption - dispose immediately295this._proxy.$disposeChatSessionContent(this._providerHandle, this.sessionId);296}297super.dispose();298}299}300301@extHostNamedCustomer(MainContext.MainThreadChatSessions)302export class MainThreadChatSessions extends Disposable implements MainThreadChatSessionsShape {303private readonly _itemProvidersRegistrations = this._register(new DisposableMap<number, IDisposable & {304readonly provider: IChatSessionItemProvider;305readonly onDidChangeItems: Emitter<void>;306}>());307private readonly _contentProvidersRegistrations = this._register(new DisposableMap<number>());308309private readonly _activeSessions = new Map<string, ObservableChatSession>();310private readonly _sessionDisposables = new Map<string, IDisposable>();311312private readonly _proxy: ExtHostChatSessionsShape;313314constructor(315private readonly _extHostContext: IExtHostContext,316@IChatSessionsService private readonly _chatSessionsService: IChatSessionsService,317@IDialogService private readonly _dialogService: IDialogService,318@IEditorService private readonly _editorService: IEditorService,319@ILogService private readonly _logService: ILogService,320@IViewsService private readonly _viewsService: IViewsService,321) {322super();323324this._proxy = this._extHostContext.getProxy(ExtHostContext.ExtHostChatSessions);325}326327$registerChatSessionItemProvider(handle: number, chatSessionType: string): void {328// Register the provider handle - this tracks that a provider exists329const disposables = new DisposableStore();330const changeEmitter = disposables.add(new Emitter<void>());331332const provider: IChatSessionItemProvider = {333chatSessionType,334onDidChangeChatSessionItems: changeEmitter.event,335provideChatSessionItems: (token) => this._provideChatSessionItems(handle, token),336provideNewChatSessionItem: (options, token) => this._provideNewChatSessionItem(handle, options, token)337};338disposables.add(this._chatSessionsService.registerChatSessionItemProvider(provider));339340this._itemProvidersRegistrations.set(handle, {341dispose: () => disposables.dispose(),342provider,343onDidChangeItems: changeEmitter,344});345}346347$onDidChangeChatSessionItems(handle: number): void {348this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire();349}350351private async _provideChatSessionItems(handle: number, token: CancellationToken): Promise<IChatSessionItem[]> {352try {353// Get all results as an array from the RPC call354const sessions = await this._proxy.$provideChatSessionItems(handle, token);355return sessions.map(session => ({356...session,357id: session.id,358iconPath: session.iconPath,359tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined360}));361} catch (error) {362this._logService.error('Error providing chat sessions:', error);363}364return [];365}366367private async _provideNewChatSessionItem(handle: number, options: { request: IChatAgentRequest; prompt?: string; history?: any[]; metadata?: any }, token: CancellationToken): Promise<IChatSessionItem> {368try {369const chatSessionItem = await this._proxy.$provideNewChatSessionItem(handle, options, token);370if (!chatSessionItem) {371throw new Error('Extension failed to create chat session');372}373return {374...chatSessionItem,375id: chatSessionItem.id,376iconPath: chatSessionItem.iconPath,377tooltip: chatSessionItem.tooltip ? this._reviveTooltip(chatSessionItem.tooltip) : undefined,378};379} catch (error) {380this._logService.error('Error creating chat session:', error);381throw error;382}383}384385private async _provideChatSessionContent(providerHandle: number, id: string, token: CancellationToken): Promise<ChatSession> {386const sessionKey = ObservableChatSession.generateSessionKey(providerHandle, id);387let session = this._activeSessions.get(sessionKey);388389if (!session) {390session = new ObservableChatSession(391id,392providerHandle,393this._proxy,394this._logService,395this._dialogService396);397this._activeSessions.set(sessionKey, session);398const disposable = session.onWillDispose(() => {399this._activeSessions.delete(sessionKey);400this._sessionDisposables.get(sessionKey)?.dispose();401this._sessionDisposables.delete(sessionKey);402});403this._sessionDisposables.set(sessionKey, disposable);404}405406try {407await session.initialize(token);408return session;409} catch (error) {410session.dispose();411this._logService.error(`Error providing chat session content for handle ${providerHandle} and id ${id}:`, error);412throw error;413}414}415416$unregisterChatSessionItemProvider(handle: number): void {417this._itemProvidersRegistrations.deleteAndDispose(handle);418}419420$registerChatSessionContentProvider(handle: number, chatSessionType: string): void {421const provider: IChatSessionContentProvider = {422provideChatSessionContent: (id, token) => this._provideChatSessionContent(handle, id, token)423};424425this._contentProvidersRegistrations.set(handle, this._chatSessionsService.registerChatSessionContentProvider(chatSessionType, provider));426}427428$unregisterChatSessionContentProvider(handle: number): void {429this._contentProvidersRegistrations.deleteAndDispose(handle);430// dispose all sessions from this provider and clean up its disposables431for (const [key, session] of this._activeSessions) {432if (session.providerHandle === handle) {433session.dispose();434this._activeSessions.delete(key);435}436}437}438439async $handleProgressChunk(handle: number, sessionId: string, requestId: string, chunks: (IChatProgressDto | [IChatProgressDto, number])[]): Promise<void> {440const sessionKey = ObservableChatSession.generateSessionKey(handle, sessionId);441const observableSession = this._activeSessions.get(sessionKey);442443const chatProgressParts: IChatProgress[] = chunks.map(chunk => {444const [progress] = Array.isArray(chunk) ? chunk : [chunk];445return revive(progress) as IChatProgress;446});447448if (observableSession) {449observableSession.handleProgressChunk(requestId, chatProgressParts);450} else {451this._logService.warn(`No session found for progress chunks: handle ${handle}, sessionId ${sessionId}, requestId ${requestId}`);452}453}454455$handleProgressComplete(handle: number, sessionId: string, requestId: string) {456const sessionKey = ObservableChatSession.generateSessionKey(handle, sessionId);457const observableSession = this._activeSessions.get(sessionKey);458459if (observableSession) {460observableSession.handleProgressComplete(requestId);461} else {462this._logService.warn(`No session found for progress completion: handle ${handle}, sessionId ${sessionId}, requestId ${requestId}`);463}464}465466$handleAnchorResolve(handle: number, sessionId: string, requestId: string, requestHandle: string, anchor: Dto<IChatContentInlineReference>): void {467// throw new Error('Method not implemented.');468}469470override dispose(): void {471for (const session of this._activeSessions.values()) {472session.dispose();473}474this._activeSessions.clear();475476for (const disposable of this._sessionDisposables.values()) {477disposable.dispose();478}479this._sessionDisposables.clear();480481super.dispose();482}483484private _reviveTooltip(tooltip: string | IMarkdownString | undefined): string | MarkdownString | undefined {485if (!tooltip) {486return undefined;487}488489// If it's already a string, return as-is490if (typeof tooltip === 'string') {491return tooltip;492}493494// If it's a serialized IMarkdownString, revive it to MarkdownString495if (typeof tooltip === 'object' && 'value' in tooltip) {496return MarkdownString.lift(tooltip);497}498499return undefined;500}501502async $showChatSession(chatSessionType: string, sessionId: string, position: EditorGroupColumn | undefined): Promise<void> {503const sessionUri = ChatSessionUri.forSession(chatSessionType, sessionId);504505if (typeof position === 'undefined') {506const chatPanel = await this._viewsService.openView<ChatViewPane>(ChatViewId);507await chatPanel?.loadSession(sessionUri);508} else {509await this._editorService.openEditor({510resource: sessionUri,511options: { pinned: true },512}, position);513}514}515}516517518