Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/api/browser/mainThreadChatSessions.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { raceCancellationError } from '../../../base/common/async.js';
7
import { CancellationToken } from '../../../base/common/cancellation.js';
8
import { Emitter } from '../../../base/common/event.js';
9
import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js';
10
import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
11
import { revive } from '../../../base/common/marshalling.js';
12
import { autorun, IObservable, observableValue } from '../../../base/common/observable.js';
13
import { localize } from '../../../nls.js';
14
import { IDialogService } from '../../../platform/dialogs/common/dialogs.js';
15
import { ILogService } from '../../../platform/log/common/log.js';
16
import { ChatViewId } from '../../contrib/chat/browser/chat.js';
17
import { ChatViewPane } from '../../contrib/chat/browser/chatViewPane.js';
18
import { IChatAgentRequest } from '../../contrib/chat/common/chatAgents.js';
19
import { IChatContentInlineReference, IChatProgress } from '../../contrib/chat/common/chatService.js';
20
import { ChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js';
21
import { ChatSessionUri } from '../../contrib/chat/common/chatUri.js';
22
import { EditorGroupColumn } from '../../services/editor/common/editorGroupColumn.js';
23
import { IEditorService } from '../../services/editor/common/editorService.js';
24
import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
25
import { Dto } from '../../services/extensions/common/proxyIdentifier.js';
26
import { IViewsService } from '../../services/views/common/viewsService.js';
27
import { ExtHostChatSessionsShape, ExtHostContext, IChatProgressDto, IChatSessionHistoryItemDto, MainContext, MainThreadChatSessionsShape } from '../common/extHost.protocol.js';
28
29
export class ObservableChatSession extends Disposable implements ChatSession {
30
static generateSessionKey(providerHandle: number, sessionId: string) {
31
return `${providerHandle}_${sessionId}`;
32
}
33
34
readonly sessionId: string;
35
readonly providerHandle: number;
36
readonly history: Array<IChatSessionHistoryItem>;
37
38
private readonly _progressObservable = observableValue<IChatProgress[]>(this, []);
39
private readonly _isCompleteObservable = observableValue<boolean>(this, false);
40
41
private readonly _onWillDispose = new Emitter<void>();
42
readonly onWillDispose = this._onWillDispose.event;
43
44
private readonly _pendingProgressChunks = new Map<string, IChatProgress[]>();
45
private _isInitialized = false;
46
private _interruptionWasCanceled = false;
47
private _disposalPending = false;
48
49
private _initializationPromise?: Promise<void>;
50
51
interruptActiveResponseCallback?: () => Promise<boolean>;
52
requestHandler?: (
53
request: IChatAgentRequest,
54
progress: (progress: IChatProgress[]) => void,
55
history: any[],
56
token: CancellationToken
57
) => Promise<void>;
58
59
private readonly _proxy: ExtHostChatSessionsShape;
60
private readonly _providerHandle: number;
61
private readonly _logService: ILogService;
62
private readonly _dialogService: IDialogService;
63
64
get sessionKey(): string {
65
return ObservableChatSession.generateSessionKey(this.providerHandle, this.sessionId);
66
}
67
68
get progressObs(): IObservable<IChatProgress[]> {
69
return this._progressObservable;
70
}
71
72
get isCompleteObs(): IObservable<boolean> {
73
return this._isCompleteObservable;
74
}
75
76
constructor(
77
id: string,
78
providerHandle: number,
79
proxy: ExtHostChatSessionsShape,
80
logService: ILogService,
81
dialogService: IDialogService
82
) {
83
super();
84
85
this.sessionId = id;
86
this.providerHandle = providerHandle;
87
this.history = [];
88
this._proxy = proxy;
89
this._providerHandle = providerHandle;
90
this._logService = logService;
91
this._dialogService = dialogService;
92
}
93
94
initialize(token: CancellationToken): Promise<void> {
95
if (!this._initializationPromise) {
96
this._initializationPromise = this._doInitializeContent(token);
97
}
98
return this._initializationPromise;
99
}
100
101
private async _doInitializeContent(token: CancellationToken): Promise<void> {
102
try {
103
const sessionContent = await raceCancellationError(
104
this._proxy.$provideChatSessionContent(this._providerHandle, this.sessionId, token),
105
token
106
);
107
108
this.history.length = 0;
109
this.history.push(...sessionContent.history.map((turn: IChatSessionHistoryItemDto) => {
110
if (turn.type === 'request') {
111
return { type: 'request' as const, prompt: turn.prompt, participant: turn.participant };
112
}
113
114
return {
115
type: 'response' as const,
116
parts: turn.parts.map((part: IChatProgressDto) => revive(part) as IChatProgress),
117
participant: turn.participant
118
};
119
}));
120
121
if (sessionContent.hasActiveResponseCallback && !this.interruptActiveResponseCallback) {
122
this.interruptActiveResponseCallback = async () => {
123
const confirmInterrupt = () => {
124
if (this._disposalPending) {
125
this._proxy.$disposeChatSessionContent(this._providerHandle, this.sessionId);
126
this._disposalPending = false;
127
}
128
this._proxy.$interruptChatSessionActiveResponse(this._providerHandle, this.sessionId, 'ongoing');
129
return true;
130
};
131
132
if (sessionContent.supportsInterruption) {
133
// If the session supports hot reload, interrupt without confirmation
134
return confirmInterrupt();
135
}
136
137
// Prompt the user to confirm interruption
138
return this._dialogService.confirm({
139
message: localize('interruptActiveResponse', 'Are you sure you want to interrupt the active session?')
140
}).then(confirmed => {
141
if (confirmed.confirmed) {
142
// User confirmed interruption - dispose the session content on extension host
143
return confirmInterrupt();
144
} else {
145
// When user cancels the interruption, fire an empty progress message to keep the session alive
146
// This matches the behavior of the old implementation
147
this._addProgress([{
148
kind: 'progressMessage',
149
content: { value: '', isTrusted: false }
150
}]);
151
// Set flag to prevent completion when extension host calls handleProgressComplete
152
this._interruptionWasCanceled = true;
153
// User canceled interruption - cancel the deferred disposal
154
if (this._disposalPending) {
155
this._logService.info(`Canceling deferred disposal for session ${this.sessionId} (user canceled interruption)`);
156
this._disposalPending = false;
157
}
158
return false;
159
}
160
});
161
};
162
}
163
164
if (sessionContent.hasRequestHandler && !this.requestHandler) {
165
this.requestHandler = async (
166
request: IChatAgentRequest,
167
progress: (progress: IChatProgress[]) => void,
168
history: any[],
169
token: CancellationToken
170
) => {
171
// Clear previous progress and mark as active
172
this._progressObservable.set([], undefined);
173
this._isCompleteObservable.set(false, undefined);
174
175
// Set up reactive progress observation before starting the request
176
let lastProgressLength = 0;
177
const progressDisposable = autorun(reader => {
178
const progressArray = this._progressObservable.read(reader);
179
const isComplete = this._isCompleteObservable.read(reader);
180
181
if (progressArray.length > lastProgressLength) {
182
const newProgress = progressArray.slice(lastProgressLength);
183
progress(newProgress);
184
lastProgressLength = progressArray.length;
185
}
186
187
if (isComplete) {
188
progressDisposable.dispose();
189
}
190
});
191
192
try {
193
await this._proxy.$invokeChatSessionRequestHandler(this._providerHandle, this.sessionId, request, history, token);
194
195
// Only mark as complete if there's no active response callback
196
// Sessions with active response callbacks should only complete when explicitly told to via handleProgressComplete
197
if (!this._isCompleteObservable.get() && !this.interruptActiveResponseCallback) {
198
this._markComplete();
199
}
200
} catch (error) {
201
const errorProgress: IChatProgress = {
202
kind: 'progressMessage',
203
content: { value: `Error: ${error instanceof Error ? error.message : String(error)}`, isTrusted: false }
204
};
205
206
this._addProgress([errorProgress]);
207
this._markComplete();
208
throw error;
209
} finally {
210
// Ensure progress observation is cleaned up
211
progressDisposable.dispose();
212
}
213
};
214
}
215
216
this._isInitialized = true;
217
218
// Process any pending progress chunks
219
const hasActiveResponse = sessionContent.hasActiveResponseCallback;
220
const hasRequestHandler = sessionContent.hasRequestHandler;
221
const hasAnyCapability = hasActiveResponse || hasRequestHandler;
222
223
for (const [requestId, chunks] of this._pendingProgressChunks) {
224
this._logService.debug(`Processing ${chunks.length} pending progress chunks for session ${this.sessionId}, requestId ${requestId}`);
225
this._addProgress(chunks);
226
}
227
this._pendingProgressChunks.clear();
228
229
// If session has no active response callback and no request handler, mark it as complete
230
if (!hasAnyCapability) {
231
this._isCompleteObservable.set(true, undefined);
232
}
233
234
} catch (error) {
235
this._logService.error(`Failed to initialize chat session ${this.sessionId}:`, error);
236
throw error;
237
}
238
}
239
240
/**
241
* Handle progress chunks coming from the extension host.
242
* If the session is not initialized yet, the chunks will be queued.
243
*/
244
handleProgressChunk(requestId: string, progress: IChatProgress[]): void {
245
if (!this._isInitialized) {
246
const existing = this._pendingProgressChunks.get(requestId) || [];
247
this._pendingProgressChunks.set(requestId, [...existing, ...progress]);
248
this._logService.debug(`Queuing ${progress.length} progress chunks for session ${this.sessionId}, requestId ${requestId} (session not initialized)`);
249
return;
250
}
251
252
this._addProgress(progress);
253
}
254
255
/**
256
* Handle progress completion from the extension host.
257
*/
258
handleProgressComplete(requestId: string): void {
259
// Clean up any pending chunks for this request
260
this._pendingProgressChunks.delete(requestId);
261
262
if (this._isInitialized) {
263
// Don't mark as complete if user canceled the interruption
264
if (!this._interruptionWasCanceled) {
265
this._markComplete();
266
} else {
267
// Reset the flag and don't mark as complete
268
this._interruptionWasCanceled = false;
269
}
270
}
271
}
272
273
private _addProgress(progress: IChatProgress[]): void {
274
const currentProgress = this._progressObservable.get();
275
this._progressObservable.set([...currentProgress, ...progress], undefined);
276
}
277
278
private _markComplete(): void {
279
if (!this._isCompleteObservable.get()) {
280
this._isCompleteObservable.set(true, undefined);
281
}
282
}
283
284
override dispose(): void {
285
this._onWillDispose.fire();
286
this._onWillDispose.dispose();
287
this._pendingProgressChunks.clear();
288
289
// If this session has an active response callback and disposal is happening,
290
// defer the actual session content disposal until we know the user's choice
291
if (this.interruptActiveResponseCallback && !this._interruptionWasCanceled) {
292
this._disposalPending = true;
293
// The actual disposal will happen in the interruption callback based on user's choice
294
} else {
295
// No active response callback or user already canceled interruption - dispose immediately
296
this._proxy.$disposeChatSessionContent(this._providerHandle, this.sessionId);
297
}
298
super.dispose();
299
}
300
}
301
302
@extHostNamedCustomer(MainContext.MainThreadChatSessions)
303
export class MainThreadChatSessions extends Disposable implements MainThreadChatSessionsShape {
304
private readonly _itemProvidersRegistrations = this._register(new DisposableMap<number, IDisposable & {
305
readonly provider: IChatSessionItemProvider;
306
readonly onDidChangeItems: Emitter<void>;
307
}>());
308
private readonly _contentProvidersRegistrations = this._register(new DisposableMap<number>());
309
310
private readonly _activeSessions = new Map<string, ObservableChatSession>();
311
private readonly _sessionDisposables = new Map<string, IDisposable>();
312
313
private readonly _proxy: ExtHostChatSessionsShape;
314
315
constructor(
316
private readonly _extHostContext: IExtHostContext,
317
@IChatSessionsService private readonly _chatSessionsService: IChatSessionsService,
318
@IDialogService private readonly _dialogService: IDialogService,
319
@IEditorService private readonly _editorService: IEditorService,
320
@ILogService private readonly _logService: ILogService,
321
@IViewsService private readonly _viewsService: IViewsService,
322
) {
323
super();
324
325
this._proxy = this._extHostContext.getProxy(ExtHostContext.ExtHostChatSessions);
326
}
327
328
$registerChatSessionItemProvider(handle: number, chatSessionType: string): void {
329
// Register the provider handle - this tracks that a provider exists
330
const disposables = new DisposableStore();
331
const changeEmitter = disposables.add(new Emitter<void>());
332
333
const provider: IChatSessionItemProvider = {
334
chatSessionType,
335
onDidChangeChatSessionItems: changeEmitter.event,
336
provideChatSessionItems: (token) => this._provideChatSessionItems(handle, token),
337
provideNewChatSessionItem: (options, token) => this._provideNewChatSessionItem(handle, options, token)
338
};
339
disposables.add(this._chatSessionsService.registerChatSessionItemProvider(provider));
340
341
this._itemProvidersRegistrations.set(handle, {
342
dispose: () => disposables.dispose(),
343
provider,
344
onDidChangeItems: changeEmitter,
345
});
346
}
347
348
$onDidChangeChatSessionItems(handle: number): void {
349
this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire();
350
}
351
352
private async _provideChatSessionItems(handle: number, token: CancellationToken): Promise<IChatSessionItem[]> {
353
try {
354
// Get all results as an array from the RPC call
355
const sessions = await this._proxy.$provideChatSessionItems(handle, token);
356
return sessions.map(session => ({
357
...session,
358
id: session.id,
359
iconPath: session.iconPath,
360
tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined
361
}));
362
} catch (error) {
363
this._logService.error('Error providing chat sessions:', error);
364
}
365
return [];
366
}
367
368
private async _provideNewChatSessionItem(handle: number, options: { request: IChatAgentRequest; prompt?: string; history?: any[]; metadata?: any }, token: CancellationToken): Promise<IChatSessionItem> {
369
try {
370
const chatSessionItem = await this._proxy.$provideNewChatSessionItem(handle, options, token);
371
if (!chatSessionItem) {
372
throw new Error('Extension failed to create chat session');
373
}
374
return {
375
...chatSessionItem,
376
id: chatSessionItem.id,
377
iconPath: chatSessionItem.iconPath,
378
tooltip: chatSessionItem.tooltip ? this._reviveTooltip(chatSessionItem.tooltip) : undefined,
379
};
380
} catch (error) {
381
this._logService.error('Error creating chat session:', error);
382
throw error;
383
}
384
}
385
386
private async _provideChatSessionContent(providerHandle: number, id: string, token: CancellationToken): Promise<ChatSession> {
387
const sessionKey = ObservableChatSession.generateSessionKey(providerHandle, id);
388
let session = this._activeSessions.get(sessionKey);
389
390
if (!session) {
391
session = new ObservableChatSession(
392
id,
393
providerHandle,
394
this._proxy,
395
this._logService,
396
this._dialogService
397
);
398
this._activeSessions.set(sessionKey, session);
399
const disposable = session.onWillDispose(() => {
400
this._activeSessions.delete(sessionKey);
401
this._sessionDisposables.get(sessionKey)?.dispose();
402
this._sessionDisposables.delete(sessionKey);
403
});
404
this._sessionDisposables.set(sessionKey, disposable);
405
}
406
407
try {
408
await session.initialize(token);
409
return session;
410
} catch (error) {
411
session.dispose();
412
this._logService.error(`Error providing chat session content for handle ${providerHandle} and id ${id}:`, error);
413
throw error;
414
}
415
}
416
417
$unregisterChatSessionItemProvider(handle: number): void {
418
this._itemProvidersRegistrations.deleteAndDispose(handle);
419
}
420
421
$registerChatSessionContentProvider(handle: number, chatSessionType: string): void {
422
const provider: IChatSessionContentProvider = {
423
provideChatSessionContent: (id, token) => this._provideChatSessionContent(handle, id, token)
424
};
425
426
this._contentProvidersRegistrations.set(handle, this._chatSessionsService.registerChatSessionContentProvider(chatSessionType, provider));
427
}
428
429
$unregisterChatSessionContentProvider(handle: number): void {
430
this._contentProvidersRegistrations.deleteAndDispose(handle);
431
// dispose all sessions from this provider and clean up its disposables
432
for (const [key, session] of this._activeSessions) {
433
if (session.providerHandle === handle) {
434
session.dispose();
435
this._activeSessions.delete(key);
436
}
437
}
438
}
439
440
async $handleProgressChunk(handle: number, sessionId: string, requestId: string, chunks: (IChatProgressDto | [IChatProgressDto, number])[]): Promise<void> {
441
const sessionKey = ObservableChatSession.generateSessionKey(handle, sessionId);
442
const observableSession = this._activeSessions.get(sessionKey);
443
444
const chatProgressParts: IChatProgress[] = chunks.map(chunk => {
445
const [progress] = Array.isArray(chunk) ? chunk : [chunk];
446
return revive(progress) as IChatProgress;
447
});
448
449
if (observableSession) {
450
observableSession.handleProgressChunk(requestId, chatProgressParts);
451
} else {
452
this._logService.warn(`No session found for progress chunks: handle ${handle}, sessionId ${sessionId}, requestId ${requestId}`);
453
}
454
}
455
456
$handleProgressComplete(handle: number, sessionId: string, requestId: string) {
457
const sessionKey = ObservableChatSession.generateSessionKey(handle, sessionId);
458
const observableSession = this._activeSessions.get(sessionKey);
459
460
if (observableSession) {
461
observableSession.handleProgressComplete(requestId);
462
} else {
463
this._logService.warn(`No session found for progress completion: handle ${handle}, sessionId ${sessionId}, requestId ${requestId}`);
464
}
465
}
466
467
$handleAnchorResolve(handle: number, sessionId: string, requestId: string, requestHandle: string, anchor: Dto<IChatContentInlineReference>): void {
468
// throw new Error('Method not implemented.');
469
}
470
471
override dispose(): void {
472
for (const session of this._activeSessions.values()) {
473
session.dispose();
474
}
475
this._activeSessions.clear();
476
477
for (const disposable of this._sessionDisposables.values()) {
478
disposable.dispose();
479
}
480
this._sessionDisposables.clear();
481
482
super.dispose();
483
}
484
485
private _reviveTooltip(tooltip: string | IMarkdownString | undefined): string | MarkdownString | undefined {
486
if (!tooltip) {
487
return undefined;
488
}
489
490
// If it's already a string, return as-is
491
if (typeof tooltip === 'string') {
492
return tooltip;
493
}
494
495
// If it's a serialized IMarkdownString, revive it to MarkdownString
496
if (typeof tooltip === 'object' && 'value' in tooltip) {
497
return MarkdownString.lift(tooltip);
498
}
499
500
return undefined;
501
}
502
503
async $showChatSession(chatSessionType: string, sessionId: string, position: EditorGroupColumn | undefined): Promise<void> {
504
const sessionUri = ChatSessionUri.forSession(chatSessionType, sessionId);
505
506
if (typeof position === 'undefined') {
507
const chatPanel = await this._viewsService.openView<ChatViewPane>(ChatViewId);
508
await chatPanel?.loadSession(sessionUri);
509
} else {
510
await this._editorService.openEditor({
511
resource: sessionUri,
512
options: { pinned: true },
513
}, position);
514
}
515
}
516
}
517
518