Path: blob/main/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.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 assert from 'assert';6import * as sinon from 'sinon';7import { CancellationToken } from '../../../../base/common/cancellation.js';8import { DisposableStore } from '../../../../base/common/lifecycle.js';9import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';10import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';11import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js';12import { ContextKeyService } from '../../../../platform/contextkey/browser/contextKeyService.js';13import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';14import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';15import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js';16import { ILogService, NullLogService } from '../../../../platform/log/common/log.js';17import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions.contribution.js';18import { IChatAgentRequest } from '../../../contrib/chat/common/chatAgents.js';19import { IChatProgress } from '../../../contrib/chat/common/chatService.js';20import { IChatSessionItem, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js';21import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js';22import { IEditorService } from '../../../services/editor/common/editorService.js';23import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js';24import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js';25import { IExtensionService } from '../../../services/extensions/common/extensions.js';26import { IViewsService } from '../../../services/views/common/viewsService.js';27import { mock, TestExtensionService } from '../../../test/common/workbenchTestServices.js';28import { MainThreadChatSessions, ObservableChatSession } from '../../browser/mainThreadChatSessions.js';29import { ExtHostChatSessionsShape, IChatProgressDto } from '../../common/extHost.protocol.js';3031suite('ObservableChatSession', function () {32let disposables: DisposableStore;33let logService: ILogService;34let dialogService: IDialogService;35let proxy: ExtHostChatSessionsShape;3637setup(function () {38disposables = new DisposableStore();39logService = new NullLogService();4041dialogService = new class extends mock<IDialogService>() {42override async confirm() {43return { confirmed: true };44}45};4647proxy = {48$provideChatSessionContent: sinon.stub(),49$interruptChatSessionActiveResponse: sinon.stub(),50$invokeChatSessionRequestHandler: sinon.stub(),51$disposeChatSessionContent: sinon.stub(),52$provideChatSessionItems: sinon.stub(),53$provideNewChatSessionItem: sinon.stub().resolves({ id: 'new-session-id', label: 'New Session' } as IChatSessionItem)54};55});5657teardown(function () {58disposables.dispose();59sinon.restore();60});6162ensureNoDisposablesAreLeakedInTestSuite();6364function createSessionContent(options: {65id?: string;66history?: any[];67hasActiveResponseCallback?: boolean;68hasRequestHandler?: boolean;69} = {}) {70return {71id: options.id || 'test-id',72history: options.history || [],73hasActiveResponseCallback: options.hasActiveResponseCallback || false,74hasRequestHandler: options.hasRequestHandler || false75};76}7778async function createInitializedSession(sessionContent: any, sessionId = 'test-id'): Promise<ObservableChatSession> {79const session = new ObservableChatSession(sessionId, 1, proxy, logService, dialogService);80(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);81await session.initialize(CancellationToken.None);82return session;83}8485test('constructor creates session with proper initial state', function () {86const session = disposables.add(new ObservableChatSession('test-id', 1, proxy, logService, dialogService));8788assert.strictEqual(session.sessionId, 'test-id');89assert.strictEqual(session.providerHandle, 1);90assert.deepStrictEqual(session.history, []);91assert.ok(session.progressObs);92assert.ok(session.isCompleteObs);9394// Initial state should be inactive and incomplete95assert.deepStrictEqual(session.progressObs.get(), []);96assert.strictEqual(session.isCompleteObs.get(), false);97});9899test('session queues progress before initialization and processes it after', async function () {100const session = disposables.add(new ObservableChatSession('test-id', 1, proxy, logService, dialogService));101102const progress1: IChatProgress = { kind: 'progressMessage', content: { value: 'Hello', isTrusted: false } };103const progress2: IChatProgress = { kind: 'progressMessage', content: { value: 'World', isTrusted: false } };104105// Add progress before initialization - should be queued106session.handleProgressChunk('req1', [progress1]);107session.handleProgressChunk('req1', [progress2]);108109// Progress should be queued, not visible yet110assert.deepStrictEqual(session.progressObs.get(), []);111112// Initialize the session113const sessionContent = createSessionContent();114(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);115await session.initialize(CancellationToken.None);116117// Now progress should be visible118assert.strictEqual(session.progressObs.get().length, 2);119assert.deepStrictEqual(session.progressObs.get(), [progress1, progress2]);120assert.strictEqual(session.isCompleteObs.get(), true); // Should be complete for sessions without active response callback or request handler121});122123test('initialization loads session history and sets up capabilities', async function () {124const sessionHistory = [125{ type: 'request', prompt: 'Previous question' },126{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Previous answer', isTrusted: false } }] }127];128129const sessionContent = createSessionContent({130history: sessionHistory,131hasActiveResponseCallback: true,132hasRequestHandler: true133});134135const session = disposables.add(await createInitializedSession(sessionContent));136137// Verify history was loaded138assert.strictEqual(session.history.length, 2);139assert.strictEqual(session.history[0].type, 'request');140assert.strictEqual((session.history[0] as any).prompt, 'Previous question');141assert.strictEqual(session.history[1].type, 'response');142143// Verify capabilities were set up144assert.ok(session.interruptActiveResponseCallback);145assert.ok(session.requestHandler);146});147148test('initialization is idempotent and returns same promise', async function () {149const session = disposables.add(new ObservableChatSession('test-id', 1, proxy, logService, dialogService));150const sessionContent = createSessionContent();151(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);152153const promise1 = session.initialize(CancellationToken.None);154const promise2 = session.initialize(CancellationToken.None);155156assert.strictEqual(promise1, promise2);157await promise1;158159// Should only call proxy once even though initialize was called twice160assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce);161});162163test('progress handling works correctly after initialization', async function () {164const sessionContent = createSessionContent();165const session = disposables.add(await createInitializedSession(sessionContent));166167const progress: IChatProgress = { kind: 'progressMessage', content: { value: 'New progress', isTrusted: false } };168169// Add progress after initialization170session.handleProgressChunk('req1', [progress]);171172assert.deepStrictEqual(session.progressObs.get(), [progress]);173// Session with no capabilities should remain complete174assert.strictEqual(session.isCompleteObs.get(), true);175});176177test('progress completion updates session state correctly', async function () {178const sessionContent = createSessionContent();179const session = disposables.add(await createInitializedSession(sessionContent));180181// Add some progress first182const progress: IChatProgress = { kind: 'progressMessage', content: { value: 'Processing...', isTrusted: false } };183session.handleProgressChunk('req1', [progress]);184185// Session with no capabilities should already be complete186assert.strictEqual(session.isCompleteObs.get(), true);187session.handleProgressComplete('req1');188assert.strictEqual(session.isCompleteObs.get(), true);189});190191test('session with active response callback becomes active when progress is added', async function () {192const sessionContent = createSessionContent({ hasActiveResponseCallback: true });193const session = disposables.add(await createInitializedSession(sessionContent));194195// Session should start inactive and incomplete (has capabilities but no active progress)196assert.strictEqual(session.isCompleteObs.get(), false);197198const progress: IChatProgress = { kind: 'progressMessage', content: { value: 'Processing...', isTrusted: false } };199session.handleProgressChunk('req1', [progress]);200201assert.strictEqual(session.isCompleteObs.get(), false);202session.handleProgressComplete('req1');203204assert.strictEqual(session.isCompleteObs.get(), true);205});206207test('request handler forwards requests to proxy', async function () {208const sessionContent = createSessionContent({ hasRequestHandler: true });209const session = disposables.add(await createInitializedSession(sessionContent));210211assert.ok(session.requestHandler);212213const request = { requestId: 'req1', prompt: 'Test prompt' } as any;214const progressCallback = sinon.stub();215216await session.requestHandler!(request, progressCallback, [], CancellationToken.None);217218assert.ok((proxy.$invokeChatSessionRequestHandler as sinon.SinonStub).calledOnceWith(1, 'test-id', request, [], CancellationToken.None));219});220221test('request handler forwards progress updates to external callback', async function () {222const sessionContent = createSessionContent({ hasRequestHandler: true });223const session = disposables.add(await createInitializedSession(sessionContent));224225assert.ok(session.requestHandler);226227const request = { requestId: 'req1', prompt: 'Test prompt' } as any;228const progressCallback = sinon.stub();229230let resolveRequest: () => void;231const requestPromise = new Promise<void>(resolve => {232resolveRequest = resolve;233});234235(proxy.$invokeChatSessionRequestHandler as sinon.SinonStub).returns(requestPromise);236237const requestHandlerPromise = session.requestHandler!(request, progressCallback, [], CancellationToken.None);238239const progress1: IChatProgress = { kind: 'progressMessage', content: { value: 'Progress 1', isTrusted: false } };240const progress2: IChatProgress = { kind: 'progressMessage', content: { value: 'Progress 2', isTrusted: false } };241242session.handleProgressChunk('req1', [progress1]);243session.handleProgressChunk('req1', [progress2]);244245// Wait a bit for autorun to trigger246await new Promise(resolve => setTimeout(resolve, 0));247248assert.ok(progressCallback.calledTwice);249assert.deepStrictEqual(progressCallback.firstCall.args[0], [progress1]);250assert.deepStrictEqual(progressCallback.secondCall.args[0], [progress2]);251252// Complete the request253resolveRequest!();254await requestHandlerPromise;255256assert.strictEqual(session.isCompleteObs.get(), true);257});258259test('dispose properly cleans up resources and notifies listeners', function () {260const session = new ObservableChatSession('test-id', 1, proxy, logService, dialogService);261262let disposeEventFired = false;263const disposable = session.onWillDispose(() => {264disposeEventFired = true;265});266267session.dispose();268269assert.ok(disposeEventFired);270assert.ok((proxy.$disposeChatSessionContent as sinon.SinonStub).calledOnceWith(1, 'test-id'));271272disposable.dispose();273});274275test('session key generation is consistent', function () {276const session = new ObservableChatSession('test-id', 42, proxy, logService, dialogService);277278assert.strictEqual(session.sessionKey, '42_test-id');279assert.strictEqual(ObservableChatSession.generateSessionKey(42, 'test-id'), '42_test-id');280281session.dispose();282});283284test('session with multiple request/response pairs in history', async function () {285const sessionHistory = [286{ type: 'request', prompt: 'First question' },287{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }] },288{ type: 'request', prompt: 'Second question' },289{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }] }290];291292const sessionContent = createSessionContent({293history: sessionHistory,294hasActiveResponseCallback: false,295hasRequestHandler: false296});297298const session = disposables.add(await createInitializedSession(sessionContent));299300// Verify all history was loaded correctly301assert.strictEqual(session.history.length, 4);302assert.strictEqual(session.history[0].type, 'request');303assert.strictEqual((session.history[0] as any).prompt, 'First question');304assert.strictEqual(session.history[1].type, 'response');305assert.strictEqual((session.history[1].parts[0] as any).content.value, 'First answer');306assert.strictEqual(session.history[2].type, 'request');307assert.strictEqual((session.history[2] as any).prompt, 'Second question');308assert.strictEqual(session.history[3].type, 'response');309assert.strictEqual((session.history[3].parts[0] as any).content.value, 'Second answer');310311// Session should be complete since it has no capabilities312assert.strictEqual(session.isCompleteObs.get(), true);313});314});315316suite('MainThreadChatSessions', function () {317let instantiationService: TestInstantiationService;318let mainThread: MainThreadChatSessions;319let proxy: ExtHostChatSessionsShape;320let chatSessionsService: IChatSessionsService;321let disposables: DisposableStore;322323setup(function () {324disposables = new DisposableStore();325instantiationService = new TestInstantiationService();326327proxy = {328$provideChatSessionContent: sinon.stub(),329$interruptChatSessionActiveResponse: sinon.stub(),330$invokeChatSessionRequestHandler: sinon.stub(),331$disposeChatSessionContent: sinon.stub(),332$provideChatSessionItems: sinon.stub(),333$provideNewChatSessionItem: sinon.stub().resolves({ id: 'new-session-id', label: 'New Session' } as IChatSessionItem)334};335336const extHostContext = new class implements IExtHostContext {337remoteAuthority = '';338extensionHostKind = ExtensionHostKind.LocalProcess;339dispose() { }340assertRegistered() { }341set(v: any): any { return null; }342getProxy(): any { return proxy; }343drain(): any { return null; }344};345346instantiationService.stub(IConfigurationService, new TestConfigurationService());347instantiationService.stub(IContextKeyService, disposables.add(instantiationService.createInstance(ContextKeyService)));348instantiationService.stub(ILogService, new NullLogService());349instantiationService.stub(IEditorService, new class extends mock<IEditorService>() { });350instantiationService.stub(IExtensionService, new TestExtensionService());351instantiationService.stub(IViewsService, new class extends mock<IViewsService>() {352override async openView() { return null; }353});354instantiationService.stub(IDialogService, new class extends mock<IDialogService>() {355override async confirm() {356return { confirmed: true };357}358});359360chatSessionsService = disposables.add(instantiationService.createInstance(ChatSessionsService));361instantiationService.stub(IChatSessionsService, chatSessionsService);362mainThread = disposables.add(instantiationService.createInstance(MainThreadChatSessions, extHostContext));363});364365teardown(function () {366disposables.dispose();367instantiationService.dispose();368sinon.restore();369});370371ensureNoDisposablesAreLeakedInTestSuite();372373test('provideNewChatSessionItem creates a new chat session', async function () {374mainThread.$registerChatSessionItemProvider(1, 'test-type');375376// Create a mock IChatAgentRequest377const mockRequest: IChatAgentRequest = {378sessionId: 'test-session',379requestId: 'test-request',380agentId: 'test-agent',381message: 'my prompt',382location: ChatAgentLocation.Panel,383variables: { variables: [] }384};385386// Valid387const chatSessionItem = await chatSessionsService.provideNewChatSessionItem('test-type', {388request: mockRequest,389prompt: 'my prompt',390metadata: {}391}, CancellationToken.None);392assert.strictEqual(chatSessionItem.id, 'new-session-id');393assert.strictEqual(chatSessionItem.label, 'New Session');394395// Invalid session type should throw396await assert.rejects(397chatSessionsService.provideNewChatSessionItem('invalid-type', {398request: mockRequest,399prompt: 'my prompt',400metadata: {}401}, CancellationToken.None)402);403404mainThread.$unregisterChatSessionItemProvider(1);405});406407test('provideChatSessionContent creates and initializes session', async function () {408mainThread.$registerChatSessionContentProvider(1, 'test-type');409410const sessionContent = {411id: 'test-session',412history: [],413hasActiveResponseCallback: false,414hasRequestHandler: false415};416417(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);418const session1 = await chatSessionsService.provideChatSessionContent('test-type', 'test-session', CancellationToken.None);419420assert.ok(session1);421assert.strictEqual(session1.sessionId, 'test-session');422423const session2 = await chatSessionsService.provideChatSessionContent('test-type', 'test-session', CancellationToken.None);424assert.strictEqual(session1, session2);425426assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce);427mainThread.$unregisterChatSessionContentProvider(1);428});429430test('$handleProgressChunk routes to correct session', async function () {431mainThread.$registerChatSessionContentProvider(1, 'test-type');432433const sessionContent = {434id: 'test-session',435history: [],436hasActiveResponseCallback: false,437hasRequestHandler: false438};439440(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);441442const session = await chatSessionsService.provideChatSessionContent('test-type', 'test-session', CancellationToken.None) as ObservableChatSession;443444const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } };445await mainThread.$handleProgressChunk(1, 'test-session', 'req1', [progressDto]);446447assert.strictEqual(session.progressObs.get().length, 1);448assert.strictEqual(session.progressObs.get()[0].kind, 'progressMessage');449450mainThread.$unregisterChatSessionContentProvider(1);451});452453test('$handleProgressComplete marks session complete', async function () {454mainThread.$registerChatSessionContentProvider(1, 'test-type');455456const sessionContent = {457id: 'test-session',458history: [],459hasActiveResponseCallback: false,460hasRequestHandler: false461};462463(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);464465const session = await chatSessionsService.provideChatSessionContent('test-type', 'test-session', CancellationToken.None) as ObservableChatSession;466467const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } };468await mainThread.$handleProgressChunk(1, 'test-session', 'req1', [progressDto]);469mainThread.$handleProgressComplete(1, 'test-session', 'req1');470471assert.strictEqual(session.isCompleteObs.get(), true);472473mainThread.$unregisterChatSessionContentProvider(1);474});475476test('integration with multiple request/response pairs', async function () {477mainThread.$registerChatSessionContentProvider(1, 'test-type');478479const sessionContent = {480id: 'multi-turn-session',481history: [482{ type: 'request', prompt: 'First question' },483{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }] },484{ type: 'request', prompt: 'Second question' },485{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }] }486],487hasActiveResponseCallback: false,488hasRequestHandler: false489};490491(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);492493const session = await chatSessionsService.provideChatSessionContent('test-type', 'multi-turn-session', CancellationToken.None) as ObservableChatSession;494495// Verify the session loaded correctly496assert.ok(session);497assert.strictEqual(session.sessionId, 'multi-turn-session');498assert.strictEqual(session.history.length, 4);499500// Verify all history items are correctly loaded501assert.strictEqual(session.history[0].type, 'request');502assert.strictEqual((session.history[0] as any).prompt, 'First question');503assert.strictEqual(session.history[1].type, 'response');504assert.strictEqual(session.history[2].type, 'request');505assert.strictEqual((session.history[2] as any).prompt, 'Second question');506assert.strictEqual(session.history[3].type, 'response');507508// Session should be complete since it has no active capabilities509assert.strictEqual(session.isCompleteObs.get(), true);510511mainThread.$unregisterChatSessionContentProvider(1);512});513});514515516