Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.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 assert from 'assert';
7
import * as sinon from 'sinon';
8
import { CancellationToken } from '../../../../base/common/cancellation.js';
9
import { DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
11
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
12
import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js';
13
import { ContextKeyService } from '../../../../platform/contextkey/browser/contextKeyService.js';
14
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
15
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
16
import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js';
17
import { ILogService, NullLogService } from '../../../../platform/log/common/log.js';
18
import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions.contribution.js';
19
import { IChatAgentRequest } from '../../../contrib/chat/common/chatAgents.js';
20
import { IChatProgress } from '../../../contrib/chat/common/chatService.js';
21
import { IChatSessionItem, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js';
22
import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js';
23
import { IEditorService } from '../../../services/editor/common/editorService.js';
24
import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js';
25
import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js';
26
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
27
import { IViewsService } from '../../../services/views/common/viewsService.js';
28
import { mock, TestExtensionService } from '../../../test/common/workbenchTestServices.js';
29
import { MainThreadChatSessions, ObservableChatSession } from '../../browser/mainThreadChatSessions.js';
30
import { ExtHostChatSessionsShape, IChatProgressDto } from '../../common/extHost.protocol.js';
31
32
suite('ObservableChatSession', function () {
33
let disposables: DisposableStore;
34
let logService: ILogService;
35
let dialogService: IDialogService;
36
let proxy: ExtHostChatSessionsShape;
37
38
setup(function () {
39
disposables = new DisposableStore();
40
logService = new NullLogService();
41
42
dialogService = new class extends mock<IDialogService>() {
43
override async confirm() {
44
return { confirmed: true };
45
}
46
};
47
48
proxy = {
49
$provideChatSessionContent: sinon.stub(),
50
$interruptChatSessionActiveResponse: sinon.stub(),
51
$invokeChatSessionRequestHandler: sinon.stub(),
52
$disposeChatSessionContent: sinon.stub(),
53
$provideChatSessionItems: sinon.stub(),
54
$provideNewChatSessionItem: sinon.stub().resolves({ id: 'new-session-id', label: 'New Session' } as IChatSessionItem)
55
};
56
});
57
58
teardown(function () {
59
disposables.dispose();
60
sinon.restore();
61
});
62
63
ensureNoDisposablesAreLeakedInTestSuite();
64
65
function createSessionContent(options: {
66
id?: string;
67
history?: any[];
68
hasActiveResponseCallback?: boolean;
69
hasRequestHandler?: boolean;
70
} = {}) {
71
return {
72
id: options.id || 'test-id',
73
history: options.history || [],
74
hasActiveResponseCallback: options.hasActiveResponseCallback || false,
75
hasRequestHandler: options.hasRequestHandler || false
76
};
77
}
78
79
async function createInitializedSession(sessionContent: any, sessionId = 'test-id'): Promise<ObservableChatSession> {
80
const session = new ObservableChatSession(sessionId, 1, proxy, logService, dialogService);
81
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
82
await session.initialize(CancellationToken.None);
83
return session;
84
}
85
86
test('constructor creates session with proper initial state', function () {
87
const session = disposables.add(new ObservableChatSession('test-id', 1, proxy, logService, dialogService));
88
89
assert.strictEqual(session.sessionId, 'test-id');
90
assert.strictEqual(session.providerHandle, 1);
91
assert.deepStrictEqual(session.history, []);
92
assert.ok(session.progressObs);
93
assert.ok(session.isCompleteObs);
94
95
// Initial state should be inactive and incomplete
96
assert.deepStrictEqual(session.progressObs.get(), []);
97
assert.strictEqual(session.isCompleteObs.get(), false);
98
});
99
100
test('session queues progress before initialization and processes it after', async function () {
101
const session = disposables.add(new ObservableChatSession('test-id', 1, proxy, logService, dialogService));
102
103
const progress1: IChatProgress = { kind: 'progressMessage', content: { value: 'Hello', isTrusted: false } };
104
const progress2: IChatProgress = { kind: 'progressMessage', content: { value: 'World', isTrusted: false } };
105
106
// Add progress before initialization - should be queued
107
session.handleProgressChunk('req1', [progress1]);
108
session.handleProgressChunk('req1', [progress2]);
109
110
// Progress should be queued, not visible yet
111
assert.deepStrictEqual(session.progressObs.get(), []);
112
113
// Initialize the session
114
const sessionContent = createSessionContent();
115
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
116
await session.initialize(CancellationToken.None);
117
118
// Now progress should be visible
119
assert.strictEqual(session.progressObs.get().length, 2);
120
assert.deepStrictEqual(session.progressObs.get(), [progress1, progress2]);
121
assert.strictEqual(session.isCompleteObs.get(), true); // Should be complete for sessions without active response callback or request handler
122
});
123
124
test('initialization loads session history and sets up capabilities', async function () {
125
const sessionHistory = [
126
{ type: 'request', prompt: 'Previous question' },
127
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Previous answer', isTrusted: false } }] }
128
];
129
130
const sessionContent = createSessionContent({
131
history: sessionHistory,
132
hasActiveResponseCallback: true,
133
hasRequestHandler: true
134
});
135
136
const session = disposables.add(await createInitializedSession(sessionContent));
137
138
// Verify history was loaded
139
assert.strictEqual(session.history.length, 2);
140
assert.strictEqual(session.history[0].type, 'request');
141
assert.strictEqual((session.history[0] as any).prompt, 'Previous question');
142
assert.strictEqual(session.history[1].type, 'response');
143
144
// Verify capabilities were set up
145
assert.ok(session.interruptActiveResponseCallback);
146
assert.ok(session.requestHandler);
147
});
148
149
test('initialization is idempotent and returns same promise', async function () {
150
const session = disposables.add(new ObservableChatSession('test-id', 1, proxy, logService, dialogService));
151
const sessionContent = createSessionContent();
152
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
153
154
const promise1 = session.initialize(CancellationToken.None);
155
const promise2 = session.initialize(CancellationToken.None);
156
157
assert.strictEqual(promise1, promise2);
158
await promise1;
159
160
// Should only call proxy once even though initialize was called twice
161
assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce);
162
});
163
164
test('progress handling works correctly after initialization', async function () {
165
const sessionContent = createSessionContent();
166
const session = disposables.add(await createInitializedSession(sessionContent));
167
168
const progress: IChatProgress = { kind: 'progressMessage', content: { value: 'New progress', isTrusted: false } };
169
170
// Add progress after initialization
171
session.handleProgressChunk('req1', [progress]);
172
173
assert.deepStrictEqual(session.progressObs.get(), [progress]);
174
// Session with no capabilities should remain complete
175
assert.strictEqual(session.isCompleteObs.get(), true);
176
});
177
178
test('progress completion updates session state correctly', async function () {
179
const sessionContent = createSessionContent();
180
const session = disposables.add(await createInitializedSession(sessionContent));
181
182
// Add some progress first
183
const progress: IChatProgress = { kind: 'progressMessage', content: { value: 'Processing...', isTrusted: false } };
184
session.handleProgressChunk('req1', [progress]);
185
186
// Session with no capabilities should already be complete
187
assert.strictEqual(session.isCompleteObs.get(), true);
188
session.handleProgressComplete('req1');
189
assert.strictEqual(session.isCompleteObs.get(), true);
190
});
191
192
test('session with active response callback becomes active when progress is added', async function () {
193
const sessionContent = createSessionContent({ hasActiveResponseCallback: true });
194
const session = disposables.add(await createInitializedSession(sessionContent));
195
196
// Session should start inactive and incomplete (has capabilities but no active progress)
197
assert.strictEqual(session.isCompleteObs.get(), false);
198
199
const progress: IChatProgress = { kind: 'progressMessage', content: { value: 'Processing...', isTrusted: false } };
200
session.handleProgressChunk('req1', [progress]);
201
202
assert.strictEqual(session.isCompleteObs.get(), false);
203
session.handleProgressComplete('req1');
204
205
assert.strictEqual(session.isCompleteObs.get(), true);
206
});
207
208
test('request handler forwards requests to proxy', async function () {
209
const sessionContent = createSessionContent({ hasRequestHandler: true });
210
const session = disposables.add(await createInitializedSession(sessionContent));
211
212
assert.ok(session.requestHandler);
213
214
const request = { requestId: 'req1', prompt: 'Test prompt' } as any;
215
const progressCallback = sinon.stub();
216
217
await session.requestHandler!(request, progressCallback, [], CancellationToken.None);
218
219
assert.ok((proxy.$invokeChatSessionRequestHandler as sinon.SinonStub).calledOnceWith(1, 'test-id', request, [], CancellationToken.None));
220
});
221
222
test('request handler forwards progress updates to external callback', async function () {
223
const sessionContent = createSessionContent({ hasRequestHandler: true });
224
const session = disposables.add(await createInitializedSession(sessionContent));
225
226
assert.ok(session.requestHandler);
227
228
const request = { requestId: 'req1', prompt: 'Test prompt' } as any;
229
const progressCallback = sinon.stub();
230
231
let resolveRequest: () => void;
232
const requestPromise = new Promise<void>(resolve => {
233
resolveRequest = resolve;
234
});
235
236
(proxy.$invokeChatSessionRequestHandler as sinon.SinonStub).returns(requestPromise);
237
238
const requestHandlerPromise = session.requestHandler!(request, progressCallback, [], CancellationToken.None);
239
240
const progress1: IChatProgress = { kind: 'progressMessage', content: { value: 'Progress 1', isTrusted: false } };
241
const progress2: IChatProgress = { kind: 'progressMessage', content: { value: 'Progress 2', isTrusted: false } };
242
243
session.handleProgressChunk('req1', [progress1]);
244
session.handleProgressChunk('req1', [progress2]);
245
246
// Wait a bit for autorun to trigger
247
await new Promise(resolve => setTimeout(resolve, 0));
248
249
assert.ok(progressCallback.calledTwice);
250
assert.deepStrictEqual(progressCallback.firstCall.args[0], [progress1]);
251
assert.deepStrictEqual(progressCallback.secondCall.args[0], [progress2]);
252
253
// Complete the request
254
resolveRequest!();
255
await requestHandlerPromise;
256
257
assert.strictEqual(session.isCompleteObs.get(), true);
258
});
259
260
test('dispose properly cleans up resources and notifies listeners', function () {
261
const session = new ObservableChatSession('test-id', 1, proxy, logService, dialogService);
262
263
let disposeEventFired = false;
264
const disposable = session.onWillDispose(() => {
265
disposeEventFired = true;
266
});
267
268
session.dispose();
269
270
assert.ok(disposeEventFired);
271
assert.ok((proxy.$disposeChatSessionContent as sinon.SinonStub).calledOnceWith(1, 'test-id'));
272
273
disposable.dispose();
274
});
275
276
test('session key generation is consistent', function () {
277
const session = new ObservableChatSession('test-id', 42, proxy, logService, dialogService);
278
279
assert.strictEqual(session.sessionKey, '42_test-id');
280
assert.strictEqual(ObservableChatSession.generateSessionKey(42, 'test-id'), '42_test-id');
281
282
session.dispose();
283
});
284
285
test('session with multiple request/response pairs in history', async function () {
286
const sessionHistory = [
287
{ type: 'request', prompt: 'First question' },
288
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }] },
289
{ type: 'request', prompt: 'Second question' },
290
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }] }
291
];
292
293
const sessionContent = createSessionContent({
294
history: sessionHistory,
295
hasActiveResponseCallback: false,
296
hasRequestHandler: false
297
});
298
299
const session = disposables.add(await createInitializedSession(sessionContent));
300
301
// Verify all history was loaded correctly
302
assert.strictEqual(session.history.length, 4);
303
assert.strictEqual(session.history[0].type, 'request');
304
assert.strictEqual((session.history[0] as any).prompt, 'First question');
305
assert.strictEqual(session.history[1].type, 'response');
306
assert.strictEqual((session.history[1].parts[0] as any).content.value, 'First answer');
307
assert.strictEqual(session.history[2].type, 'request');
308
assert.strictEqual((session.history[2] as any).prompt, 'Second question');
309
assert.strictEqual(session.history[3].type, 'response');
310
assert.strictEqual((session.history[3].parts[0] as any).content.value, 'Second answer');
311
312
// Session should be complete since it has no capabilities
313
assert.strictEqual(session.isCompleteObs.get(), true);
314
});
315
});
316
317
suite('MainThreadChatSessions', function () {
318
let instantiationService: TestInstantiationService;
319
let mainThread: MainThreadChatSessions;
320
let proxy: ExtHostChatSessionsShape;
321
let chatSessionsService: IChatSessionsService;
322
let disposables: DisposableStore;
323
324
setup(function () {
325
disposables = new DisposableStore();
326
instantiationService = new TestInstantiationService();
327
328
proxy = {
329
$provideChatSessionContent: sinon.stub(),
330
$interruptChatSessionActiveResponse: sinon.stub(),
331
$invokeChatSessionRequestHandler: sinon.stub(),
332
$disposeChatSessionContent: sinon.stub(),
333
$provideChatSessionItems: sinon.stub(),
334
$provideNewChatSessionItem: sinon.stub().resolves({ id: 'new-session-id', label: 'New Session' } as IChatSessionItem)
335
};
336
337
const extHostContext = new class implements IExtHostContext {
338
remoteAuthority = '';
339
extensionHostKind = ExtensionHostKind.LocalProcess;
340
dispose() { }
341
assertRegistered() { }
342
set(v: any): any { return null; }
343
getProxy(): any { return proxy; }
344
drain(): any { return null; }
345
};
346
347
instantiationService.stub(IConfigurationService, new TestConfigurationService());
348
instantiationService.stub(IContextKeyService, disposables.add(instantiationService.createInstance(ContextKeyService)));
349
instantiationService.stub(ILogService, new NullLogService());
350
instantiationService.stub(IEditorService, new class extends mock<IEditorService>() { });
351
instantiationService.stub(IExtensionService, new TestExtensionService());
352
instantiationService.stub(IViewsService, new class extends mock<IViewsService>() {
353
override async openView() { return null; }
354
});
355
instantiationService.stub(IDialogService, new class extends mock<IDialogService>() {
356
override async confirm() {
357
return { confirmed: true };
358
}
359
});
360
361
chatSessionsService = disposables.add(instantiationService.createInstance(ChatSessionsService));
362
instantiationService.stub(IChatSessionsService, chatSessionsService);
363
mainThread = disposables.add(instantiationService.createInstance(MainThreadChatSessions, extHostContext));
364
});
365
366
teardown(function () {
367
disposables.dispose();
368
instantiationService.dispose();
369
sinon.restore();
370
});
371
372
ensureNoDisposablesAreLeakedInTestSuite();
373
374
test('provideNewChatSessionItem creates a new chat session', async function () {
375
mainThread.$registerChatSessionItemProvider(1, 'test-type');
376
377
// Create a mock IChatAgentRequest
378
const mockRequest: IChatAgentRequest = {
379
sessionId: 'test-session',
380
requestId: 'test-request',
381
agentId: 'test-agent',
382
message: 'my prompt',
383
location: ChatAgentLocation.Panel,
384
variables: { variables: [] }
385
};
386
387
// Valid
388
const chatSessionItem = await chatSessionsService.provideNewChatSessionItem('test-type', {
389
request: mockRequest,
390
prompt: 'my prompt',
391
metadata: {}
392
}, CancellationToken.None);
393
assert.strictEqual(chatSessionItem.id, 'new-session-id');
394
assert.strictEqual(chatSessionItem.label, 'New Session');
395
396
// Invalid session type should throw
397
await assert.rejects(
398
chatSessionsService.provideNewChatSessionItem('invalid-type', {
399
request: mockRequest,
400
prompt: 'my prompt',
401
metadata: {}
402
}, CancellationToken.None)
403
);
404
405
mainThread.$unregisterChatSessionItemProvider(1);
406
});
407
408
test('provideChatSessionContent creates and initializes session', async function () {
409
mainThread.$registerChatSessionContentProvider(1, 'test-type');
410
411
const sessionContent = {
412
id: 'test-session',
413
history: [],
414
hasActiveResponseCallback: false,
415
hasRequestHandler: false
416
};
417
418
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
419
const session1 = await chatSessionsService.provideChatSessionContent('test-type', 'test-session', CancellationToken.None);
420
421
assert.ok(session1);
422
assert.strictEqual(session1.sessionId, 'test-session');
423
424
const session2 = await chatSessionsService.provideChatSessionContent('test-type', 'test-session', CancellationToken.None);
425
assert.strictEqual(session1, session2);
426
427
assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce);
428
mainThread.$unregisterChatSessionContentProvider(1);
429
});
430
431
test('$handleProgressChunk routes to correct session', async function () {
432
mainThread.$registerChatSessionContentProvider(1, 'test-type');
433
434
const sessionContent = {
435
id: 'test-session',
436
history: [],
437
hasActiveResponseCallback: false,
438
hasRequestHandler: false
439
};
440
441
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
442
443
const session = await chatSessionsService.provideChatSessionContent('test-type', 'test-session', CancellationToken.None) as ObservableChatSession;
444
445
const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } };
446
await mainThread.$handleProgressChunk(1, 'test-session', 'req1', [progressDto]);
447
448
assert.strictEqual(session.progressObs.get().length, 1);
449
assert.strictEqual(session.progressObs.get()[0].kind, 'progressMessage');
450
451
mainThread.$unregisterChatSessionContentProvider(1);
452
});
453
454
test('$handleProgressComplete marks session complete', async function () {
455
mainThread.$registerChatSessionContentProvider(1, 'test-type');
456
457
const sessionContent = {
458
id: 'test-session',
459
history: [],
460
hasActiveResponseCallback: false,
461
hasRequestHandler: false
462
};
463
464
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
465
466
const session = await chatSessionsService.provideChatSessionContent('test-type', 'test-session', CancellationToken.None) as ObservableChatSession;
467
468
const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } };
469
await mainThread.$handleProgressChunk(1, 'test-session', 'req1', [progressDto]);
470
mainThread.$handleProgressComplete(1, 'test-session', 'req1');
471
472
assert.strictEqual(session.isCompleteObs.get(), true);
473
474
mainThread.$unregisterChatSessionContentProvider(1);
475
});
476
477
test('integration with multiple request/response pairs', async function () {
478
mainThread.$registerChatSessionContentProvider(1, 'test-type');
479
480
const sessionContent = {
481
id: 'multi-turn-session',
482
history: [
483
{ type: 'request', prompt: 'First question' },
484
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }] },
485
{ type: 'request', prompt: 'Second question' },
486
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }] }
487
],
488
hasActiveResponseCallback: false,
489
hasRequestHandler: false
490
};
491
492
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
493
494
const session = await chatSessionsService.provideChatSessionContent('test-type', 'multi-turn-session', CancellationToken.None) as ObservableChatSession;
495
496
// Verify the session loaded correctly
497
assert.ok(session);
498
assert.strictEqual(session.sessionId, 'multi-turn-session');
499
assert.strictEqual(session.history.length, 4);
500
501
// Verify all history items are correctly loaded
502
assert.strictEqual(session.history[0].type, 'request');
503
assert.strictEqual((session.history[0] as any).prompt, 'First question');
504
assert.strictEqual(session.history[1].type, 'response');
505
assert.strictEqual(session.history[2].type, 'request');
506
assert.strictEqual((session.history[2] as any).prompt, 'Second question');
507
assert.strictEqual(session.history[3].type, 'response');
508
509
// Session should be complete since it has no active capabilities
510
assert.strictEqual(session.isCompleteObs.get(), true);
511
512
mainThread.$unregisterChatSessionContentProvider(1);
513
});
514
});
515
516