Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts
13405 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*--------------------------------------------------------------------------------------------*/4import { outdent } from 'outdent';5import { afterAll, assert, beforeAll, describe, expect, it } from 'vitest';6import { IConfigurationService } from '../../../../platform/configuration/common/configurationService';7import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';8import { IGitExtensionService } from '../../../../platform/git/common/gitExtensionService';9import { NullGitExtensionService } from '../../../../platform/git/common/nullGitExtensionService';10import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId';11import { InlineEditRequestLogContext } from '../../../../platform/inlineEdits/common/inlineEditLogContext';12import { ObservableGit } from '../../../../platform/inlineEdits/common/observableGit';13import { MutableObservableWorkspace } from '../../../../platform/inlineEdits/common/observableWorkspace';14import { IStatelessNextEditProvider, NoNextEditReason, StatelessNextEditRequest, StatelessNextEditTelemetryBuilder, WithStatelessProviderTelemetry } from '../../../../platform/inlineEdits/common/statelessNextEditProvider';15import { NesHistoryContextProvider } from '../../../../platform/inlineEdits/common/workspaceEditTracker/nesHistoryContextProvider';16import { NesXtabHistoryTracker } from '../../../../platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker';17import { ILogger, ILogService, LogServiceImpl } from '../../../../platform/log/common/logService';18import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger';19import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';20import { ISnippyService, NullSnippyService } from '../../../../platform/snippy/common/snippyService';21import { IExperimentationService, NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';22import { mockNotebookService } from '../../../../platform/test/common/testNotebookService';23import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';24import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';25import { Result } from '../../../../util/common/result';26import { CancellationToken } from '../../../../util/vs/base/common/cancellation';27import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';28import { URI } from '../../../../util/vs/base/common/uri';29import { generateUuid } from '../../../../util/vs/base/common/uuid';30import { LineEdit, LineReplacement } from '../../../../util/vs/editor/common/core/edits/lineEdit';31import { StringEdit } from '../../../../util/vs/editor/common/core/edits/stringEdit';32import { LineRange } from '../../../../util/vs/editor/common/core/ranges/lineRange';33import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange';34import { NESInlineCompletionContext, NextEditProvider } from '../../node/nextEditProvider';35import { NextEditProviderTelemetryBuilder } from '../../node/nextEditProviderTelemetry';3637describe('NextEditProvider Caching', () => {3839let configService: IConfigurationService;40let snippyService: ISnippyService;41let gitExtensionService: IGitExtensionService;42let logService: ILogService;43let expService: IExperimentationService;44let disposableStore: DisposableStore;45let workspaceService: IWorkspaceService;46let requestLogger: IRequestLogger;47beforeAll(() => {48disposableStore = new DisposableStore();49workspaceService = disposableStore.add(new TestWorkspaceService());50configService = new DefaultsOnlyConfigurationService();51snippyService = new NullSnippyService();52gitExtensionService = new NullGitExtensionService();53logService = new LogServiceImpl([]);54expService = new NullExperimentationService();55requestLogger = new NullRequestLogger();56});57afterAll(() => {58disposableStore.dispose();59});60function createStatelessNextEditProvider(): IStatelessNextEditProvider {61return {62ID: 'TestNextEditProvider',63provideNextEdit: async function*(request: StatelessNextEditRequest, logger: ILogger, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) {64const telemetryBuilder = new StatelessNextEditTelemetryBuilder(request.headerRequestId);65const lineEdit = LineEdit.createFromUnsorted(66[67new LineReplacement(68new LineRange(11, 12),69['const myPoint = new Point3D(0, 1, 2);']70),71new LineReplacement(72new LineRange(5, 5),73['\t\tprivate readonly z: number,']74),75new LineReplacement(76new LineRange(6, 9),77[78'\tgetDistance() {',79'\t\treturn Math.sqrt(this.x ** 2 + this.y ** 2 + this.z ** 2);',80'\t}'81]82)83]84);85for (const edit of lineEdit.replacements) {86yield new WithStatelessProviderTelemetry({ targetDocument: request.getActiveDocument().id, edit, isFromCursorJump: false }, telemetryBuilder.build(Result.ok(undefined)));87}88const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, undefined);89return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions)));90}91};92}9394it('caches a response with multiple edits and reuses them correctly with rebasing', async () => {95const obsWorkspace = new MutableObservableWorkspace();96const obsGit = new ObservableGit(gitExtensionService);97const statelessNextEditProvider = createStatelessNextEditProvider();9899const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger);100101const doc = obsWorkspace.addDocument({102id: DocumentId.create(URI.file('/test/test.ts').toString()),103initialValue: outdent`104class Point {105constructor(106private readonly x: number,107private readonly y: number,108) { }109getDistance() {110return Math.sqrt(this.x ** 2 + this.y ** 2);111}112}113114const myPoint = new Point(0, 1);`.trimStart()115});116doc.setSelection([new OffsetRange(1, 1)], undefined);117118doc.applyEdit(StringEdit.insert(11, '3D'));119120const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false };121const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context);122const cancellationToken = CancellationToken.None;123const tb1 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc);124125let result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb1.nesBuilder);126127tb1.dispose();128129assert(result.result?.edit);130131doc.applyEdit(result.result.edit.toEdit());132133expect(doc.value.get().value).toMatchInlineSnapshot(`134"class Point3D {135constructor(136private readonly x: number,137private readonly y: number,138private readonly z: number,139) { }140getDistance() {141return Math.sqrt(this.x ** 2 + this.y ** 2);142}143}144145const myPoint = new Point(0, 1);"146`);147148const tb2 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc);149150result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb2.nesBuilder);151152tb2.dispose();153154assert(result.result?.edit);155156doc.applyEdit(result.result.edit.toEdit());157158expect(doc.value.get().value).toMatchInlineSnapshot(`159"class Point3D {160constructor(161private readonly x: number,162private readonly y: number,163private readonly z: number,164) { }165getDistance() {166return Math.sqrt(this.x ** 2 + this.y ** 2 + this.z ** 2);167}168}169170const myPoint = new Point(0, 1);"171`);172173const tb3 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc);174175result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb3.nesBuilder);176177tb3.dispose();178179assert(result.result?.edit);180181doc.applyEdit(result.result.edit.toEdit());182183expect(doc.value.get().value).toMatchInlineSnapshot(`184"class Point3D {185constructor(186private readonly x: number,187private readonly y: number,188private readonly z: number,189) { }190getDistance() {191return Math.sqrt(this.x ** 2 + this.y ** 2 + this.z ** 2);192}193}194195const myPoint = new Point3D(0, 1, 2);"196`);197});198199it('caches a response with multiple edits correctly when document uses CRLF line endings', async () => {200const obsWorkspace = new MutableObservableWorkspace();201const obsGit = new ObservableGit(gitExtensionService);202const statelessNextEditProvider = createStatelessNextEditProvider();203204const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger);205206// Use \r\n line endings to simulate a Windows document207const initialValue = [208'class Point {',209'\tconstructor(',210'\t\tprivate readonly x: number,',211'\t\tprivate readonly y: number,',212'\t) { }',213'\tgetDistance() {',214'\t\treturn Math.sqrt(this.x ** 2 + this.y ** 2);',215'\t}',216'}',217'',218'const myPoint = new Point(0, 1);',219].join('\r\n');220221const doc = obsWorkspace.addDocument({222id: DocumentId.create(URI.file('/test/test.ts').toString()),223initialValue,224});225doc.setSelection([new OffsetRange(1, 1)], undefined);226227// Insert "3D" after "Point" at offset 11 (same offset, within first line before any line ending)228doc.applyEdit(StringEdit.insert(11, '3D'));229230const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false };231const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context);232const cancellationToken = CancellationToken.None;233const tb1 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc);234235// First edit: should add z parameter236let result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb1.nesBuilder);237tb1.dispose();238assert(result.result?.edit);239doc.applyEdit(result.result.edit.toEdit());240241// Verify CRLF line endings are preserved242expect(doc.value.get().value).toContain('\r\n');243expect(doc.value.get().value).not.toMatch(/[^\r]\n/);244245// Second edit: should update getDistance method — this uses a cached edit246const tb2 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc);247result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb2.nesBuilder);248tb2.dispose();249assert(result.result?.edit, 'second cached edit should be found');250doc.applyEdit(result.result.edit.toEdit());251252expect(doc.value.get().value).not.toMatch(/[^\r]\n/);253254// Third edit: should update the variable — also from cache255const tb3 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc);256result = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb3.nesBuilder);257tb3.dispose();258assert(result.result?.edit, 'third cached edit should be found');259doc.applyEdit(result.result.edit.toEdit());260261// Final state should match expected content with CRLF throughout262const expectedLines = [263'class Point3D {',264'\tconstructor(',265'\t\tprivate readonly x: number,',266'\t\tprivate readonly y: number,',267'\t\tprivate readonly z: number,',268'\t) { }',269'\tgetDistance() {',270'\t\treturn Math.sqrt(this.x ** 2 + this.y ** 2 + this.z ** 2);',271'\t}',272'}',273'',274'const myPoint = new Point3D(0, 1, 2);',275].join('\r\n');276expect(doc.value.get().value).toBe(expectedLines);277});278279it('exposes the cache entry on NextEditResult and preserves the wasRenderedAsInlineSuggestion flag across lookups', async () => {280const obsWorkspace = new MutableObservableWorkspace();281const obsGit = new ObservableGit(gitExtensionService);282const statelessNextEditProvider = createStatelessNextEditProvider();283284const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger);285286const doc = obsWorkspace.addDocument({287id: DocumentId.create(URI.file('/test/test.ts').toString()),288initialValue: outdent`289class Point {290constructor(291private readonly x: number,292private readonly y: number,293) { }294getDistance() {295return Math.sqrt(this.x ** 2 + this.y ** 2);296}297}298299const myPoint = new Point(0, 1);`.trimStart()300});301doc.setSelection([new OffsetRange(1, 1)], undefined);302303doc.applyEdit(StringEdit.insert(11, '3D'));304305const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false };306const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context);307const cancellationToken = CancellationToken.None;308309// First call: edit comes fresh from the (mock) provider but is also cached.310const tb1 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc);311const first = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb1.nesBuilder);312tb1.dispose();313assert(first.result?.edit);314const firstCacheEntry = first.result.cacheEntry;315assert(firstCacheEntry, 'expected a cacheEntry reference on the first (fresh) NextEditResult');316expect(firstCacheEntry.wasRenderedAsInlineSuggestion).toBeFalsy();317318// Simulate the inline-completion-provider marking the entry as having been319// rendered as an inline (ghost text) suggestion.320firstCacheEntry.wasRenderedAsInlineSuggestion = true;321322// Second call (no document changes): we should still get the same cached323// edit back, and the flag must have been preserved on the same entry.324const tb2 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc);325const second = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb2.nesBuilder);326tb2.dispose();327assert(second.result?.edit);328const secondCacheEntry = second.result.cacheEntry;329assert(secondCacheEntry, 'expected a cacheEntry reference on the second (cached) NextEditResult');330expect(secondCacheEntry).toBe(firstCacheEntry);331expect(secondCacheEntry.wasRenderedAsInlineSuggestion).toBe(true);332});333});334335336