Path: blob/main/src/vs/workbench/contrib/chat/test/browser/chatEditingService.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 { CancellationToken } from '../../../../../base/common/cancellation.js';7import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js';8import { waitForState } from '../../../../../base/common/observable.js';9import { isEqual } from '../../../../../base/common/resources.js';10import { assertType } from '../../../../../base/common/types.js';11import { URI } from '../../../../../base/common/uri.js';12import { mock } from '../../../../../base/test/common/mock.js';13import { assertThrowsAsync, ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';14import { Range } from '../../../../../editor/common/core/range.js';15import { IModelService } from '../../../../../editor/common/services/model.js';16import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';17import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js';18import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';19import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js';20import { NullWorkbenchAssignmentService } from '../../../../services/assignment/test/common/nullAssignmentService.js';21import { nullExtensionDescription } from '../../../../services/extensions/common/extensions.js';22import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';23import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js';24import { NotebookTextModel } from '../../../notebook/common/model/notebookTextModel.js';25import { INotebookService } from '../../../notebook/common/notebookService.js';26import { ChatEditingService } from '../../browser/chatEditing/chatEditingServiceImpl.js';27import { ChatAgentService, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../common/chatAgents.js';28import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../../common/chatEditingService.js';29import { IChatService } from '../../common/chatService.js';30import { ChatService } from '../../common/chatServiceImpl.js';31import { IChatSlashCommandService } from '../../common/chatSlashCommands.js';32import { ChatTransferService, IChatTransferService } from '../../common/chatTransferService.js';33import { IChatVariablesService } from '../../common/chatVariables.js';34import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js';35import { ILanguageModelsService } from '../../common/languageModels.js';36import { NullLanguageModelsService } from '../common/languageModels.js';37import { MockChatVariablesService } from '../common/mockChatVariables.js';38import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js';39import { TestWorkerService } from '../../../inlineChat/test/browser/testWorkerService.js';40import { EditOperation } from '../../../../../editor/common/core/editOperation.js';41import { Position } from '../../../../../editor/common/core/position.js';42import { ChatModel } from '../../common/chatModel.js';43import { TextEdit } from '../../../../../editor/common/languages.js';44import { IMcpService } from '../../../mcp/common/mcpTypes.js';45import { TestMcpService } from '../../../mcp/test/common/testMcpService.js';46import { IChatSessionsService } from '../../common/chatSessionsService.js';47import { ChatSessionsService } from '../../browser/chatSessions.contribution.js';4849function getAgentData(id: string): IChatAgentData {50return {51name: id,52id: id,53extensionId: nullExtensionDescription.identifier,54extensionVersion: undefined,55extensionPublisherId: '',56publisherDisplayName: '',57extensionDisplayName: '',58locations: [ChatAgentLocation.Panel],59modes: [ChatModeKind.Ask],60metadata: {},61slashCommands: [],62disambiguation: [],63};64}6566suite('ChatEditingService', function () {6768const store = new DisposableStore();69let editingService: ChatEditingService;70let chatService: IChatService;71let textModelService: ITextModelService;7273setup(function () {74const collection = new ServiceCollection();75collection.set(IWorkbenchAssignmentService, new NullWorkbenchAssignmentService());76collection.set(IChatAgentService, new SyncDescriptor(ChatAgentService));77collection.set(IChatVariablesService, new MockChatVariablesService());78collection.set(IChatSlashCommandService, new class extends mock<IChatSlashCommandService>() { });79collection.set(IChatTransferService, new SyncDescriptor(ChatTransferService));80collection.set(IChatSessionsService, new SyncDescriptor(ChatSessionsService));81collection.set(IChatEditingService, new SyncDescriptor(ChatEditingService));82collection.set(IEditorWorkerService, new SyncDescriptor(TestWorkerService));83collection.set(IChatService, new SyncDescriptor(ChatService));84collection.set(IMcpService, new TestMcpService());85collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService));86collection.set(IMultiDiffSourceResolverService, new class extends mock<IMultiDiffSourceResolverService>() {87override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable {88return Disposable.None;89}90});91collection.set(INotebookService, new class extends mock<INotebookService>() {92override getNotebookTextModel(_uri: URI): NotebookTextModel | undefined {93return undefined;94}95override hasSupportedNotebooks(_resource: URI): boolean {96return false;97}98});99const insta = store.add(store.add(workbenchInstantiationService(undefined, store)).createChild(collection));100store.add(insta.get(IEditorWorkerService) as TestWorkerService);101const value = insta.get(IChatEditingService);102assert.ok(value instanceof ChatEditingService);103editingService = value;104105chatService = insta.get(IChatService);106107store.add(insta.get(IChatSessionsService) as ChatSessionsService); // Needs to be disposed in between test runs to clear extensionPoint contribution108109const chatAgentService = insta.get(IChatAgentService);110111const agent: IChatAgentImplementation = {112async invoke(request, progress, history, token) {113return {};114},115};116store.add(chatAgentService.registerAgent('testAgent', { ...getAgentData('testAgent'), isDefault: true }));117store.add(chatAgentService.registerAgentImplementation('testAgent', agent));118119textModelService = insta.get(ITextModelService);120121const modelService = insta.get(IModelService);122123store.add(textModelService.registerTextModelContentProvider('test', {124async provideTextContent(resource) {125return store.add(modelService.createModel(resource.path.repeat(10), null, resource, false));126},127}));128});129130teardown(() => {131store.clear();132});133134ensureNoDisposablesAreLeakedInTestSuite();135136test('create session', async function () {137assert.ok(editingService);138139const model = chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None);140const session = await editingService.createEditingSession(model, true);141142assert.strictEqual(session.chatSessionId, model.sessionId);143assert.strictEqual(session.isGlobalEditingSession, true);144145await assertThrowsAsync(async () => {146// DUPE not allowed147await editingService.createEditingSession(model);148});149150session.dispose();151model.dispose();152});153154test('create session, file entry & isCurrentlyBeingModifiedBy', async function () {155assert.ok(editingService);156157const uri = URI.from({ scheme: 'test', path: 'HelloWorld' });158159const model = chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None);160const session = await model.editingSessionObs?.promise;161if (!session) {162assert.fail('session not created');163}164165const chatRequest = model?.addRequest({ text: '', parts: [] }, { variables: [] }, 0);166assertType(chatRequest.response);167chatRequest.response.updateContent({ kind: 'textEdit', uri, edits: [], done: false });168chatRequest.response.updateContent({ kind: 'textEdit', uri, edits: [{ range: new Range(1, 1, 1, 1), text: 'FarBoo\n' }], done: false });169chatRequest.response.updateContent({ kind: 'textEdit', uri, edits: [], done: true });170171const entry = await waitForState(session.entries.map(value => value.find(a => isEqual(a.modifiedURI, uri))));172173assert.ok(isEqual(entry.modifiedURI, uri));174175await waitForState(entry.isCurrentlyBeingModifiedBy.map(value => value === chatRequest.response));176assert.ok(entry.isCurrentlyBeingModifiedBy.get() === chatRequest.response);177178const unset = waitForState(entry.isCurrentlyBeingModifiedBy.map(res => res === undefined));179180chatRequest.response.complete();181182await unset;183184await entry.reject();185186model.dispose();187});188189async function idleAfterEdit(session: IChatEditingSession, model: ChatModel, uri: URI, edits: TextEdit[]) {190const isStreaming = waitForState(session.state.map(s => s === ChatEditingSessionState.StreamingEdits), Boolean);191192const chatRequest = model.addRequest({ text: '', parts: [] }, { variables: [] }, 0);193assertType(chatRequest.response);194195chatRequest.response.updateContent({ kind: 'textEdit', uri, edits, done: true });196197const entry = await waitForState(session.entries.map(value => value.find(a => isEqual(a.modifiedURI, uri))));198199assert.ok(isEqual(entry.modifiedURI, uri));200201chatRequest.response.complete();202203await isStreaming;204205const isIdle = waitForState(session.state.map(s => s === ChatEditingSessionState.Idle), Boolean);206await isIdle;207208return entry;209}210211test('mirror typing outside -> accept', async function () {212assert.ok(editingService);213214const uri = URI.from({ scheme: 'test', path: 'abc\n' });215216const model = store.add(chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None));217const session = await model.editingSessionObs?.promise;218assertType(session, 'session not created');219220const entry = await idleAfterEdit(session, model, uri, [{ range: new Range(1, 1, 1, 1), text: 'FarBoo\n' }]);221const original = store.add(await textModelService.createModelReference(entry.originalURI)).object.textEditorModel;222const modified = store.add(await textModelService.createModelReference(entry.modifiedURI)).object.textEditorModel;223224assert.strictEqual(entry.state.get(), ModifiedFileEntryState.Modified);225226assert.strictEqual(original.getValue(), 'abc\n'.repeat(10));227assert.strictEqual(modified.getValue(), 'FarBoo\n' + 'abc\n'.repeat(10));228229modified.pushEditOperations(null, [EditOperation.insert(new Position(3, 1), 'USER_TYPE\n')], () => null);230231assert.ok(modified.getValue().includes('USER_TYPE'));232assert.ok(original.getValue().includes('USER_TYPE'));233234await entry.accept();235assert.strictEqual(modified.getValue(), original.getValue());236assert.strictEqual(entry.state.get(), ModifiedFileEntryState.Accepted);237238assert.ok(modified.getValue().includes('FarBoo'));239assert.ok(original.getValue().includes('FarBoo'));240});241242test('mirror typing outside -> reject', async function () {243assert.ok(editingService);244245const uri = URI.from({ scheme: 'test', path: 'abc\n' });246247const model = store.add(chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None));248const session = await model.editingSessionObs?.promise;249assertType(session, 'session not created');250251const entry = await idleAfterEdit(session, model, uri, [{ range: new Range(1, 1, 1, 1), text: 'FarBoo\n' }]);252const original = store.add(await textModelService.createModelReference(entry.originalURI)).object.textEditorModel;253const modified = store.add(await textModelService.createModelReference(entry.modifiedURI)).object.textEditorModel;254255assert.strictEqual(entry.state.get(), ModifiedFileEntryState.Modified);256257assert.strictEqual(original.getValue(), 'abc\n'.repeat(10));258assert.strictEqual(modified.getValue(), 'FarBoo\n' + 'abc\n'.repeat(10));259260modified.pushEditOperations(null, [EditOperation.insert(new Position(3, 1), 'USER_TYPE\n')], () => null);261262assert.ok(modified.getValue().includes('USER_TYPE'));263assert.ok(original.getValue().includes('USER_TYPE'));264265await entry.reject();266assert.strictEqual(modified.getValue(), original.getValue());267assert.strictEqual(entry.state.get(), ModifiedFileEntryState.Rejected);268269assert.ok(!modified.getValue().includes('FarBoo'));270assert.ok(!original.getValue().includes('FarBoo'));271});272273test('NO mirror typing inside -> accept', async function () {274assert.ok(editingService);275276const uri = URI.from({ scheme: 'test', path: 'abc\n' });277278const model = store.add(chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None));279const session = await model.editingSessionObs?.promise;280assertType(session, 'session not created');281282const entry = await idleAfterEdit(session, model, uri, [{ range: new Range(1, 1, 1, 1), text: 'FarBoo\n' }]);283const original = store.add(await textModelService.createModelReference(entry.originalURI)).object.textEditorModel;284const modified = store.add(await textModelService.createModelReference(entry.modifiedURI)).object.textEditorModel;285286assert.strictEqual(entry.state.get(), ModifiedFileEntryState.Modified);287288assert.strictEqual(original.getValue(), 'abc\n'.repeat(10));289assert.strictEqual(modified.getValue(), 'FarBoo\n' + 'abc\n'.repeat(10));290291modified.pushEditOperations(null, [EditOperation.replace(new Range(1, 2, 1, 7), 'ooBar')], () => null);292293assert.ok(modified.getValue().includes('FooBar'));294assert.ok(!original.getValue().includes('FooBar')); // typed in the AI edits, DO NOT transpose295296await entry.accept();297assert.strictEqual(modified.getValue(), original.getValue());298assert.strictEqual(entry.state.get(), ModifiedFileEntryState.Accepted);299300assert.ok(modified.getValue().includes('FooBar'));301assert.ok(original.getValue().includes('FooBar'));302});303304});305306307