Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderSpeculative.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*--------------------------------------------------------------------------------------------*/45import { afterEach, assert, beforeEach, describe, expect, it } from 'vitest';6import { ConfigKey } from '../../../../platform/configuration/common/configurationService';7import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';8import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';9import { IGitExtensionService } from '../../../../platform/git/common/gitExtensionService';10import { NullGitExtensionService } from '../../../../platform/git/common/nullGitExtensionService';11import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId';12import { SpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsEnablement } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';13import { InlineEditRequestLogContext } from '../../../../platform/inlineEdits/common/inlineEditLogContext';14import { ObservableGit } from '../../../../platform/inlineEdits/common/observableGit';15import { MutableObservableWorkspace } from '../../../../platform/inlineEdits/common/observableWorkspace';16import { EditStreamingWithTelemetry, IStatelessNextEditProvider, NoNextEditReason, RequestEditWindow, RequestEditWindowWithCursorJump, StatelessNextEditRequest, StatelessNextEditTelemetryBuilder, WithStatelessProviderTelemetry } from '../../../../platform/inlineEdits/common/statelessNextEditProvider';17import { NesHistoryContextProvider } from '../../../../platform/inlineEdits/common/workspaceEditTracker/nesHistoryContextProvider';18import { NesXtabHistoryTracker } from '../../../../platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker';19import { ILogger, ILogService, LogServiceImpl } from '../../../../platform/log/common/logService';20import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger';21import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';22import { ISnippyService, NullSnippyService } from '../../../../platform/snippy/common/snippyService';23import { IExperimentationService, NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';24import { mockNotebookService } from '../../../../platform/test/common/testNotebookService';25import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';26import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';27import { Result } from '../../../../util/common/result';28import { DeferredPromise } from '../../../../util/vs/base/common/async';29import { CancellationToken } from '../../../../util/vs/base/common/cancellation';30import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';31import { URI } from '../../../../util/vs/base/common/uri';32import { generateUuid } from '../../../../util/vs/base/common/uuid';33import { LineReplacement } from '../../../../util/vs/editor/common/core/edits/lineEdit';34import { StringEdit } from '../../../../util/vs/editor/common/core/edits/stringEdit';35import { LineRange } from '../../../../util/vs/editor/common/core/ranges/lineRange';36import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange';37import { NESInlineCompletionContext, NextEditProvider } from '../../node/nextEditProvider';38import { ILlmNESTelemetry, NextEditProviderTelemetryBuilder, ReusedRequestKind } from '../../node/nextEditProviderTelemetry';3940interface ICallRecord {41readonly request: StatelessNextEditRequest;42readonly cancellationRequested: DeferredPromise<void>;43readonly completed: DeferredPromise<void>;44wasCancelled: boolean;45}4647type ProviderBehavior =48| {49kind: 'yieldEditThenNoSuggestions';50edit: LineReplacement;51}52| {53kind: 'yieldEditThenWait';54edit: LineReplacement;55continueSignal: DeferredPromise<void>;56}57| {58kind: 'yieldEditThenWaitThenYieldEditsThenNoSuggestions';59firstEdit: LineReplacement;60continueSignal: DeferredPromise<void>;61remainingEdits: readonly LineReplacement[];62}63| {64kind: 'waitForCancellation';65};6667class TestStatelessNextEditProvider implements IStatelessNextEditProvider {68public readonly ID = 'TestStatelessNextEditProvider';6970private readonly _behaviors: ProviderBehavior[] = [];71public readonly calls: ICallRecord[] = [];72private readonly _callDeferreds: DeferredPromise<void>[] = [];7374/**75* When set, each `provideNextEdit` call will assign this to `request.requestEditWindow`76* (mirroring how the real XtabProvider sets the edit window early in its execution).77*/78public editWindow: RequestEditWindow | RequestEditWindowWithCursorJump | undefined;7980public enqueueBehavior(behavior: ProviderBehavior): void {81this._behaviors.push(behavior);82}8384/** Returns a promise that resolves when the Nth call (1-based) arrives. */85public waitForCall(callNumber: number): Promise<void> {86if (this.calls.length >= callNumber) {87return Promise.resolve();88}89while (this._callDeferreds.length < callNumber) {90this._callDeferreds.push(new DeferredPromise<void>());91}92return this._callDeferreds[callNumber - 1].p;93}9495private _resolveCallDeferred(): void {96const callIdx = this.calls.length - 1;97if (callIdx < this._callDeferreds.length) {98this._callDeferreds[callIdx].complete();99}100}101102public async *provideNextEdit(request: StatelessNextEditRequest, _logger: ILogger, _logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken): EditStreamingWithTelemetry {103const behavior = this._behaviors.shift();104if (!behavior) {105throw new Error('Missing provider behavior');106}107108if (this.editWindow) {109request.requestEditWindow = this.editWindow;110}111112const streamedEditWindow = this.editWindow?.window;113const streamedOriginalWindow = this.editWindow instanceof RequestEditWindowWithCursorJump ? this.editWindow.originalWindow : undefined;114const telemetryBuilder = new StatelessNextEditTelemetryBuilder(request.headerRequestId);115const activeDocId = request.getActiveDocument().id;116const cancellationRequested = new DeferredPromise<void>();117const completed = new DeferredPromise<void>();118const call: ICallRecord = {119request,120cancellationRequested,121completed,122wasCancelled: false,123};124125this.calls.push(call);126this._resolveCallDeferred();127const cancellationDisposable = cancellationToken.onCancellationRequested(() => {128call.wasCancelled = true;129if (!cancellationRequested.isSettled) {130cancellationRequested.complete();131}132});133134try {135if (behavior.kind === 'waitForCancellation') {136await cancellationRequested.p;137const cancelled = new NoNextEditReason.GotCancelled('testCancellation');138return new WithStatelessProviderTelemetry(cancelled, telemetryBuilder.build(Result.error(cancelled)));139}140141if (behavior.kind === 'yieldEditThenWaitThenYieldEditsThenNoSuggestions') {142yield new WithStatelessProviderTelemetry({ edit: behavior.firstEdit, isFromCursorJump: false, targetDocument: activeDocId, window: streamedEditWindow, originalWindow: streamedOriginalWindow }, telemetryBuilder.build(Result.ok(undefined)));143await Promise.race([behavior.continueSignal.p, cancellationRequested.p]);144if (!call.wasCancelled) {145for (const edit of behavior.remainingEdits) {146yield new WithStatelessProviderTelemetry({ edit, isFromCursorJump: false, targetDocument: activeDocId, window: streamedEditWindow, originalWindow: streamedOriginalWindow }, telemetryBuilder.build(Result.ok(undefined)));147}148}149const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, streamedEditWindow);150return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions)));151}152153yield new WithStatelessProviderTelemetry({ edit: behavior.edit, isFromCursorJump: false, targetDocument: activeDocId, window: streamedEditWindow, originalWindow: streamedOriginalWindow }, telemetryBuilder.build(Result.ok(undefined)));154155if (behavior.kind === 'yieldEditThenWait') {156await Promise.race([behavior.continueSignal.p, cancellationRequested.p]);157}158159const noSuggestions = new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, streamedEditWindow);160return new WithStatelessProviderTelemetry(noSuggestions, telemetryBuilder.build(Result.error(noSuggestions)));161} finally {162cancellationDisposable.dispose();163if (!completed.isSettled) {164completed.complete();165}166}167}168}169170function createInlineContext(): NESInlineCompletionContext {171return {172triggerKind: 1,173selectedCompletionInfo: undefined,174requestUuid: generateUuid(),175requestIssuedDateTime: Date.now(),176earliestShownDateTime: Date.now(),177enforceCacheDelay: false,178};179}180181async function flushMicrotasks(ticks = 20): Promise<void> {182for (let i = 0; i < ticks; i++) {183await Promise.resolve();184}185}186187function lineReplacement(lineNumberOneBased: number, newLine: string): LineReplacement {188return new LineReplacement(new LineRange(lineNumberOneBased, lineNumberOneBased + 1), [newLine]);189}190191describe('NextEditProvider speculative requests', () => {192let disposables: DisposableStore;193let configService: InMemoryConfigurationService;194let snippyService: ISnippyService;195let gitExtensionService: IGitExtensionService;196let logService: ILogService;197let expService: IExperimentationService;198let workspaceService: IWorkspaceService;199let requestLogger: IRequestLogger;200201beforeEach(() => {202disposables = new DisposableStore();203workspaceService = disposables.add(new TestWorkspaceService());204configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());205snippyService = new NullSnippyService();206gitExtensionService = new NullGitExtensionService();207logService = new LogServiceImpl([]);208expService = new NullExperimentationService();209requestLogger = new NullRequestLogger();210});211212afterEach(() => {213disposables.dispose();214});215216function createProviderAndWorkspace(statelessProvider: IStatelessNextEditProvider): { nextEditProvider: NextEditProvider; workspace: MutableObservableWorkspace } {217const workspace = new MutableObservableWorkspace();218const git = new ObservableGit(gitExtensionService);219const nextEditProvider = new NextEditProvider(220workspace,221statelessProvider,222new NesHistoryContextProvider(workspace, git),223new NesXtabHistoryTracker(workspace, undefined, configService, expService),224undefined,225configService,226snippyService,227logService,228expService,229requestLogger,230);231return { nextEditProvider, workspace };232}233234async function getNextEdit(nextEditProvider: NextEditProvider, docId: DocumentId) {235const context = createInlineContext();236const logContext = new InlineEditRequestLogContext(docId.toString(), 1, context);237const telemetryBuilder = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, undefined);238try {239return await nextEditProvider.getNextEdit(docId, context, logContext, CancellationToken.None, telemetryBuilder.nesBuilder);240} finally {241telemetryBuilder.dispose();242}243}244245async function getNextEditWithTelemetry(nextEditProvider: NextEditProvider, docId: DocumentId): Promise<{ suggestion: Awaited<ReturnType<typeof getNextEdit>>; telemetry: ILlmNESTelemetry }> {246const context = createInlineContext();247const logContext = new InlineEditRequestLogContext(docId.toString(), 1, context);248const telemetryBuilder = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, undefined);249try {250const suggestion = await nextEditProvider.getNextEdit(docId, context, logContext, CancellationToken.None, telemetryBuilder.nesBuilder);251const telemetry = telemetryBuilder.nesBuilder.build(false);252return { suggestion, telemetry };253} finally {254telemetryBuilder.dispose();255}256}257258it('does not trigger speculative request when feature is off', async () => {259await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.Off);260261const statelessProvider = new TestStatelessNextEditProvider();262statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });263const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);264265const doc = workspace.addDocument({266id: DocumentId.create(URI.file('/test/spec-off.ts').toString()),267initialValue: 'const value = 1;\nconsole.log(value);',268});269doc.setSelection([new OffsetRange(0, 0)], undefined);270271const suggestion = await getNextEdit(nextEditProvider, doc.id);272assert(suggestion.result?.edit);273274nextEditProvider.handleShown(suggestion);275await flushMicrotasks();276277expect(statelessProvider.calls.length).toBe(1);278});279280it('triggers speculative request when feature is on', async () => {281await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);282283const statelessProvider = new TestStatelessNextEditProvider();284statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });285statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });286const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);287288const doc = workspace.addDocument({289id: DocumentId.create(URI.file('/test/spec-on.ts').toString()),290initialValue: 'const value = 1;\nconsole.log(value);',291});292doc.setSelection([new OffsetRange(0, 0)], undefined);293294const suggestion = await getNextEdit(nextEditProvider, doc.id);295assert(suggestion.result?.edit);296297nextEditProvider.handleShown(suggestion);298await statelessProvider.waitForCall(2);299300expect(statelessProvider.calls.length).toBe(2);301nextEditProvider.handleRejection(doc.id, suggestion);302await statelessProvider.calls[1].completed.p;303});304305it('reuses speculative request after acceptance without creating a third request', async () => {306await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);307308const statelessProvider = new TestStatelessNextEditProvider();309statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });310statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });311const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);312313const doc = workspace.addDocument({314id: DocumentId.create(URI.file('/test/spec-reuse.ts').toString()),315initialValue: 'const value = 1;\nconsole.log(value);',316});317doc.setSelection([new OffsetRange(0, 0)], undefined);318319const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);320assert(firstSuggestion.result?.edit);321nextEditProvider.handleShown(firstSuggestion);322await statelessProvider.waitForCall(2);323await statelessProvider.calls[1].completed.p;324325nextEditProvider.handleAcceptance(doc.id, firstSuggestion);326doc.applyEdit(firstSuggestion.result.edit.toEdit());327328const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);329assert(secondSuggestion.result?.edit);330331expect(statelessProvider.calls.length).toBe(2);332expect(secondSuggestion.result.edit.newText).toBe('console.log(value + 1);');333});334335it('cancels speculative request on rejection', async () => {336await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);337338const statelessProvider = new TestStatelessNextEditProvider();339statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });340statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });341const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);342343const doc = workspace.addDocument({344id: DocumentId.create(URI.file('/test/spec-reject.ts').toString()),345initialValue: 'const value = 1;\nconsole.log(value);',346});347doc.setSelection([new OffsetRange(0, 0)], undefined);348349const suggestion = await getNextEdit(nextEditProvider, doc.id);350assert(suggestion.result?.edit);351nextEditProvider.handleShown(suggestion);352await statelessProvider.waitForCall(2);353354nextEditProvider.handleRejection(doc.id, suggestion);355await statelessProvider.calls[1].cancellationRequested.p;356357expect(statelessProvider.calls[1].wasCancelled).toBe(true);358});359360it('cancels speculative request on ignored when suggestion was shown and not superseded', async () => {361await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);362363const statelessProvider = new TestStatelessNextEditProvider();364statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });365statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });366const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);367368const doc = workspace.addDocument({369id: DocumentId.create(URI.file('/test/spec-ignored.ts').toString()),370initialValue: 'const value = 1;\nconsole.log(value);',371});372doc.setSelection([new OffsetRange(0, 0)], undefined);373374const suggestion = await getNextEdit(nextEditProvider, doc.id);375assert(suggestion.result?.edit);376nextEditProvider.handleShown(suggestion);377await statelessProvider.waitForCall(2);378379nextEditProvider.handleIgnored(doc.id, suggestion, undefined);380await statelessProvider.calls[1].cancellationRequested.p;381382expect(statelessProvider.calls[1].wasCancelled).toBe(true);383});384385it('does not cancel speculative request on unrelated open-document changes', async () => {386await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);387388const statelessProvider = new TestStatelessNextEditProvider();389statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });390statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });391const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);392393const activeDoc = workspace.addDocument({394id: DocumentId.create(URI.file('/test/spec-active.ts').toString()),395initialValue: 'const value = 1;\nconsole.log(value);',396});397activeDoc.setSelection([new OffsetRange(0, 0)], undefined);398399const unrelatedDoc = workspace.addDocument({400id: DocumentId.create(URI.file('/test/spec-other.ts').toString()),401initialValue: 'export const other = 1;',402});403unrelatedDoc.setSelection([new OffsetRange(0, 0)], undefined);404405const suggestion = await getNextEdit(nextEditProvider, activeDoc.id);406assert(suggestion.result?.edit);407nextEditProvider.handleShown(suggestion);408await statelessProvider.waitForCall(2);409410unrelatedDoc.applyEdit(StringEdit.insert(0, '// unrelated change\n'));411await flushMicrotasks();412413expect(statelessProvider.calls[1].wasCancelled).toBe(false);414415nextEditProvider.handleRejection(activeDoc.id, suggestion);416await statelessProvider.calls[1].completed.p;417});418419it.skip('cancels speculative request when active document edit moves off the type-through trajectory', async () => {420await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);421422const statelessProvider = new TestStatelessNextEditProvider();423statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });424statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });425const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);426427const doc = workspace.addDocument({428id: DocumentId.create(URI.file('/test/spec-diverge.ts').toString()),429initialValue: 'const value = 1;\nconsole.log(value);',430});431doc.setSelection([new OffsetRange(0, 0)], undefined);432433const suggestion = await getNextEdit(nextEditProvider, doc.id);434assert(suggestion.result?.edit);435nextEditProvider.handleShown(suggestion);436await statelessProvider.waitForCall(2);437438// Inserting at the start of the document breaks the trajectory's prefix439// (the doc no longer starts with `pre[0..editStart]`). The speculative440// can no longer be reached via type-through-then-accept — cancel.441doc.applyEdit(StringEdit.insert(0, '/* diverged */\n'));442await statelessProvider.calls[1].cancellationRequested.p;443444expect(statelessProvider.calls[1].wasCancelled).toBe(true);445});446447it.skip('keeps speculative alive while user types characters of the suggestion (type-through)', async () => {448await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);449450const statelessProvider = new TestStatelessNextEditProvider();451// Suggestion inserts `'barbaz'` between `'foo'` and `'();'`.452// Resulting precise edit: replace [3, 3) with 'barbaz' (a pure insertion).453statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'foobarbaz();') });454statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });455const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);456457const doc = workspace.addDocument({458id: DocumentId.create(URI.file('/test/spec-typing.ts').toString()),459initialValue: 'foo();\nconsole.log();',460});461doc.setSelection([new OffsetRange(0, 0)], undefined);462463const suggestion = await getNextEdit(nextEditProvider, doc.id);464assert(suggestion.result?.edit);465nextEditProvider.handleShown(suggestion);466await statelessProvider.waitForCall(2);467468// User types characters of the suggestion at the edit position — each469// keystroke keeps the document on a type-through trajectory toward470// `postEditContent`, so the speculative must NOT be cancelled.471doc.applyEdit(StringEdit.insert(3, 'b'));472await flushMicrotasks();473expect(statelessProvider.calls[1].wasCancelled).toBe(false);474475doc.applyEdit(StringEdit.insert(4, 'a'));476await flushMicrotasks();477expect(statelessProvider.calls[1].wasCancelled).toBe(false);478479doc.applyEdit(StringEdit.insert(5, 'r'));480await flushMicrotasks();481expect(statelessProvider.calls[1].wasCancelled).toBe(false);482483// Now the user types a character that doesn't match the suggestion's484// next character (`'b'` would be expected; they typed `'X'`). The485// trajectory is broken — cancel.486doc.applyEdit(StringEdit.insert(6, 'X'));487await statelessProvider.calls[1].cancellationRequested.p;488489expect(statelessProvider.calls[1].wasCancelled).toBe(true);490});491492it('cancels mismatched speculative request when starting a request for another document', async () => {493await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);494495const statelessProvider = new TestStatelessNextEditProvider();496statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });497statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });498statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'export const second = 2;') });499const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);500501const doc1 = workspace.addDocument({502id: DocumentId.create(URI.file('/test/spec-cross-doc-1.ts').toString()),503initialValue: 'const value = 1;\nconsole.log(value);',504});505doc1.setSelection([new OffsetRange(0, 0)], undefined);506507const doc2 = workspace.addDocument({508id: DocumentId.create(URI.file('/test/spec-cross-doc-2.ts').toString()),509initialValue: 'export const second = 1;\nconsole.log(second);',510});511doc2.setSelection([new OffsetRange(0, 0)], undefined);512513const suggestion = await getNextEdit(nextEditProvider, doc1.id);514assert(suggestion.result?.edit);515nextEditProvider.handleShown(suggestion);516await statelessProvider.waitForCall(2);517518const secondDocSuggestion = await getNextEdit(nextEditProvider, doc2.id);519assert(secondDocSuggestion.result?.edit);520await statelessProvider.calls[1].cancellationRequested.p;521522expect(statelessProvider.calls[1].wasCancelled).toBe(true);523expect(statelessProvider.calls.length).toBe(3);524});525526describe('telemetry', () => {527it('fresh request has normal headerRequestId and no reusedRequest', async () => {528const statelessProvider = new TestStatelessNextEditProvider();529statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });530const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);531532const doc = workspace.addDocument({533id: DocumentId.create(URI.file('/test/telemetry-fresh.ts').toString()),534initialValue: 'const value = 1;\nconsole.log(value);',535});536doc.setSelection([new OffsetRange(0, 0)], undefined);537538const { suggestion, telemetry } = await getNextEditWithTelemetry(nextEditProvider, doc.id);539assert(suggestion.result?.edit);540541expect(telemetry.headerRequestId).toBeDefined();542expect(telemetry.headerRequestId!.startsWith('sp-')).toBe(false);543expect(telemetry.isFromCache).toBe(false);544expect(telemetry.reusedRequest).toBeUndefined();545});546547it('reused speculative request has sp- headerRequestId and reusedRequest=speculative', async () => {548await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);549550const statelessProvider = new TestStatelessNextEditProvider();551statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });552// The speculative request yields an edit but stays in-flight until we signal it,553// so the second getNextEdit joins the pending speculative request rather than hitting cache.554const specContinue = new DeferredPromise<void>();555statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(2, 'console.log(value + 1);'), continueSignal: specContinue });556const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);557558const doc = workspace.addDocument({559id: DocumentId.create(URI.file('/test/telemetry-spec-reuse.ts').toString()),560initialValue: 'const value = 1;\nconsole.log(value);',561});562doc.setSelection([new OffsetRange(0, 0)], undefined);563564// First request: fresh565const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);566assert(firstSuggestion.result?.edit);567nextEditProvider.handleShown(firstSuggestion);568await statelessProvider.waitForCall(2);569// Speculative request is now in-flight (yielded edit but waiting on continueSignal)570571// Accept and apply the edit572nextEditProvider.handleAcceptance(doc.id, firstSuggestion);573doc.applyEdit(firstSuggestion.result.edit.toEdit());574575// Second request: should join the still-in-flight speculative request576const { suggestion: secondSuggestion, telemetry } = await getNextEditWithTelemetry(nextEditProvider, doc.id);577assert(secondSuggestion.result?.edit);578579expect(telemetry.headerRequestId).toBeDefined();580expect(telemetry.headerRequestId!.startsWith('sp-')).toBe(true);581expect(telemetry.isFromCache).toBe(false);582expect(telemetry.reusedRequest).toBe(ReusedRequestKind.Speculative);583584// Clean up: let the speculative request finish585specContinue.complete();586await statelessProvider.calls[1].completed.p;587});588589it('skips cache delay for edits from speculative requests even when enforceCacheDelay is true', async () => {590const CACHE_DELAY_MS = 5_000;591await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);592await configService.setConfig(ConfigKey.TeamInternal.InlineEditsCacheDelay, CACHE_DELAY_MS);593await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestDelay, 0);594595const statelessProvider = new TestStatelessNextEditProvider();596statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });597const specContinue = new DeferredPromise<void>();598statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(2, 'console.log(value + 1);'), continueSignal: specContinue });599const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);600601const doc = workspace.addDocument({602id: DocumentId.create(URI.file('/test/spec-skip-delay.ts').toString()),603initialValue: 'const value = 1;\nconsole.log(value);',604});605doc.setSelection([new OffsetRange(0, 0)], undefined);606607// First request (fresh, no cache delay since enforceCacheDelay=false)608const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);609assert(firstSuggestion.result?.edit);610nextEditProvider.handleShown(firstSuggestion);611await statelessProvider.waitForCall(2);612613// Accept and apply the suggestion — doc now matches speculative request's postEditContent614nextEditProvider.handleAcceptance(doc.id, firstSuggestion);615doc.applyEdit(firstSuggestion.result.edit.toEdit());616617// Second request with enforceCacheDelay=true — should still return fast because the result618// comes from a speculative request, which uses speculativeRequestDelay (0) instead of cacheDelay (5000)619const context: NESInlineCompletionContext = {620triggerKind: 1,621selectedCompletionInfo: undefined,622requestUuid: generateUuid(),623requestIssuedDateTime: Date.now(),624earliestShownDateTime: Date.now(),625enforceCacheDelay: true,626};627const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context);628const telemetryBuilder = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, undefined);629const start = Date.now();630try {631const secondSuggestion = await nextEditProvider.getNextEdit(doc.id, context, logContext, CancellationToken.None, telemetryBuilder.nesBuilder);632const elapsed = Date.now() - start;633assert(secondSuggestion.result?.edit);634expect(elapsed).toBeLessThan(100);635} finally {636telemetryBuilder.dispose();637specContinue.complete();638await statelessProvider.calls[1].completed.p;639}640});641642it('cached speculative result has sp- headerRequestId and isFromCache=true', async () => {643await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);644645const statelessProvider = new TestStatelessNextEditProvider();646statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });647statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });648const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);649650const doc = workspace.addDocument({651id: DocumentId.create(URI.file('/test/telemetry-spec-cache.ts').toString()),652initialValue: 'const value = 1;\nconsole.log(value);',653});654doc.setSelection([new OffsetRange(0, 0)], undefined);655656// First request: fresh657const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);658assert(firstSuggestion.result?.edit);659nextEditProvider.handleShown(firstSuggestion);660await statelessProvider.waitForCall(2);661await statelessProvider.calls[1].completed.p;662663// Accept and apply (speculative result is now cached)664nextEditProvider.handleAcceptance(doc.id, firstSuggestion);665doc.applyEdit(firstSuggestion.result.edit.toEdit());666667// Clear the speculative pending request by requesting once (consumes it from pending)668const consumeResult = await getNextEdit(nextEditProvider, doc.id);669assert(consumeResult.result?.edit);670671// Now the result is in cache. Request again at same document state.672const { suggestion: cachedSuggestion, telemetry } = await getNextEditWithTelemetry(nextEditProvider, doc.id);673assert(cachedSuggestion.result?.edit);674675expect(telemetry.headerRequestId).toBeDefined();676expect(telemetry.headerRequestId!.startsWith('sp-')).toBe(true);677expect(telemetry.isFromCache).toBe(true);678expect(telemetry.reusedRequest).toBeUndefined();679});680});681682describe('isSpeculative and isSubsequentEdit flags', () => {683it('normal request result has isSpeculative = false and isSubsequentEdit = false', async () => {684const statelessProvider = new TestStatelessNextEditProvider();685statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });686const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);687688const doc = workspace.addDocument({689id: DocumentId.create(URI.file('/test/flags-normal.ts').toString()),690initialValue: 'const value = 1;\nconsole.log(value);',691});692doc.setSelection([new OffsetRange(0, 0)], undefined);693694const suggestion = await getNextEdit(nextEditProvider, doc.id);695assert(suggestion.result?.edit);696697expect(suggestion.source.isSpeculative).toBe(false);698expect(suggestion.result.isSubsequentEdit).toBe(false);699});700701it('reused speculative result has isSpeculative = true on source', async () => {702await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);703704const statelessProvider = new TestStatelessNextEditProvider();705statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });706statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });707const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);708709const doc = workspace.addDocument({710id: DocumentId.create(URI.file('/test/flags-speculative.ts').toString()),711initialValue: 'const value = 1;\nconsole.log(value);',712});713doc.setSelection([new OffsetRange(0, 0)], undefined);714715const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);716assert(firstSuggestion.result?.edit);717expect(firstSuggestion.source.isSpeculative).toBe(false);718719nextEditProvider.handleShown(firstSuggestion);720await statelessProvider.waitForCall(2);721await statelessProvider.calls[1].completed.p;722723nextEditProvider.handleAcceptance(doc.id, firstSuggestion);724doc.applyEdit(firstSuggestion.result.edit.toEdit());725726const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);727assert(secondSuggestion.result?.edit);728729expect(secondSuggestion.source.isSpeculative).toBe(true);730});731});732733describe('SpeculativeRequestsAutoExpandEditWindowLines', () => {734it('Off: speculative request has expandedEditWindowNLines = undefined', async () => {735await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);736await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsAutoExpandEditWindowLines.Off);737738const statelessProvider = new TestStatelessNextEditProvider();739statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });740statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });741const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);742743const doc = workspace.addDocument({744id: DocumentId.create(URI.file('/test/expand-off.ts').toString()),745initialValue: 'const value = 1;\nconsole.log(value);',746});747doc.setSelection([new OffsetRange(0, 0)], undefined);748749const suggestion = await getNextEdit(nextEditProvider, doc.id);750assert(suggestion.result?.edit);751752nextEditProvider.handleShown(suggestion);753await statelessProvider.waitForCall(2);754755expect(statelessProvider.calls[1].request.expandedEditWindowNLines).toBeUndefined();756757nextEditProvider.handleRejection(doc.id, suggestion);758await statelessProvider.calls[1].completed.p;759});760761it('Always: speculative request has expandedEditWindowNLines from base config', async () => {762await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);763await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsAutoExpandEditWindowLines.Always);764await configService.setConfig(ConfigKey.TeamInternal.InlineEditsAutoExpandEditWindowLines, 20);765766const statelessProvider = new TestStatelessNextEditProvider();767statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });768statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });769const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);770771const doc = workspace.addDocument({772id: DocumentId.create(URI.file('/test/expand-always.ts').toString()),773initialValue: 'const value = 1;\nconsole.log(value);',774});775doc.setSelection([new OffsetRange(0, 0)], undefined);776777const suggestion = await getNextEdit(nextEditProvider, doc.id);778assert(suggestion.result?.edit);779780nextEditProvider.handleShown(suggestion);781await statelessProvider.waitForCall(2);782783expect(statelessProvider.calls[1].request.expandedEditWindowNLines).toBe(20);784785nextEditProvider.handleRejection(doc.id, suggestion);786await statelessProvider.calls[1].completed.p;787});788789it('Smart: expandedEditWindowNLines is undefined for first non-speculative edit', async () => {790await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);791await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsAutoExpandEditWindowLines.Smart);792await configService.setConfig(ConfigKey.TeamInternal.InlineEditsAutoExpandEditWindowLines, 20);793794const statelessProvider = new TestStatelessNextEditProvider();795statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });796statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });797const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);798799const doc = workspace.addDocument({800id: DocumentId.create(URI.file('/test/expand-smart-first.ts').toString()),801initialValue: 'const value = 1;\nconsole.log(value);',802});803doc.setSelection([new OffsetRange(0, 0)], undefined);804805// First suggestion is from a normal (non-speculative) request806const suggestion = await getNextEdit(nextEditProvider, doc.id);807assert(suggestion.result?.edit);808809nextEditProvider.handleShown(suggestion);810await statelessProvider.waitForCall(2);811812// The speculative request triggered from a non-speculative first edit813// should NOT expand the edit window in Smart mode814expect(statelessProvider.calls[1].request.expandedEditWindowNLines).toBeUndefined();815816nextEditProvider.handleRejection(doc.id, suggestion);817await statelessProvider.calls[1].completed.p;818});819820it('Smart: expandedEditWindowNLines uses base config when triggered by speculative chain', async () => {821await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);822await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsAutoExpandEditWindowLines.Smart);823await configService.setConfig(ConfigKey.TeamInternal.InlineEditsAutoExpandEditWindowLines, 20);824825const statelessProvider = new TestStatelessNextEditProvider();826// First normal request827statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });828// Speculative request after first edit829statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });830// Speculative request after second (speculative-sourced) edit831statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });832const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);833834const doc = workspace.addDocument({835id: DocumentId.create(URI.file('/test/expand-smart-chain.ts').toString()),836initialValue: 'const value = 1;\nconsole.log(value);',837});838doc.setSelection([new OffsetRange(0, 0)], undefined);839840// Step 1: Get first edit (normal, non-speculative)841const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);842assert(firstSuggestion.result?.edit);843844// Step 2: Show → triggers speculative request (call 2)845nextEditProvider.handleShown(firstSuggestion);846await statelessProvider.waitForCall(2);847await statelessProvider.calls[1].completed.p;848849// Step 3: Accept and apply → doc matches speculative post-edit state850nextEditProvider.handleAcceptance(doc.id, firstSuggestion);851doc.applyEdit(firstSuggestion.result.edit.toEdit());852853// Step 4: Get second edit → reuses speculative result (source.isSpeculative = true)854const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);855assert(secondSuggestion.result?.edit);856assert(secondSuggestion.source.isSpeculative);857858// Step 5: Show second suggestion → triggers another speculative request (call 3)859nextEditProvider.handleShown(secondSuggestion);860await statelessProvider.waitForCall(3);861862// The 3rd call is a speculative request triggered by a speculative-sourced edit,863// so in Smart mode, isModelOnRightTrack = true and edit window should be expanded864expect(statelessProvider.calls[2].request.expandedEditWindowNLines).toBe(20);865866nextEditProvider.handleRejection(doc.id, secondSuggestion);867await statelessProvider.calls[2].completed.p;868});869});870871describe('scheduled speculative requests for multi-edit streams', () => {872it('does not trigger speculative when shown edit is not the last in a multi-edit stream', async () => {873await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);874875const statelessProvider = new TestStatelessNextEditProvider();876const continueSignal = new DeferredPromise<void>();877statelessProvider.enqueueBehavior({878kind: 'yieldEditThenWaitThenYieldEditsThenNoSuggestions',879firstEdit: lineReplacement(1, 'const value = 2;'),880continueSignal,881remainingEdits: [lineReplacement(2, 'console.log(value + 1);')],882});883const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);884885const doc = workspace.addDocument({886id: DocumentId.create(URI.file('/test/spec-multi-not-last.ts').toString()),887initialValue: 'const value = 1;\nconsole.log(value);',888});889doc.setSelection([new OffsetRange(0, 0)], undefined);890891// Get first edit (E0) — stream is paused on continueSignal892const suggestion = await getNextEdit(nextEditProvider, doc.id);893assert(suggestion.result?.edit);894895// Show E0 — stream still running → speculative is scheduled (not fired)896nextEditProvider.handleShown(suggestion);897898// Resume stream — E1 arrives → clears the scheduled speculative899continueSignal.complete();900await statelessProvider.calls[0].completed.p;901await flushMicrotasks();902903// Only the original request was made — no speculative request904expect(statelessProvider.calls.length).toBe(1);905});906907it('triggers speculative after stream completes when shown edit is the last one', async () => {908await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);909910const statelessProvider = new TestStatelessNextEditProvider();911const continueSignal = new DeferredPromise<void>();912statelessProvider.enqueueBehavior({913kind: 'yieldEditThenWait',914edit: lineReplacement(1, 'const value = 2;'),915continueSignal,916});917statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });918const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);919920const doc = workspace.addDocument({921id: DocumentId.create(URI.file('/test/spec-last-edit.ts').toString()),922initialValue: 'const value = 1;\nconsole.log(value);',923});924doc.setSelection([new OffsetRange(0, 0)], undefined);925926// Get first edit (E0) — stream paused on continueSignal927const suggestion = await getNextEdit(nextEditProvider, doc.id);928assert(suggestion.result?.edit);929930// Show E0 — stream still running → speculative is scheduled931nextEditProvider.handleShown(suggestion);932933// Resume stream — no more edits → stream ends → scheduled speculative fires934continueSignal.complete();935// The speculative fires from handleStreamEnd (background IIFE), so we need936// microtasks to propagate through the async chain before the call arrives.937await flushMicrotasks();938939expect(statelessProvider.calls.length).toBe(2);940expect(statelessProvider.calls[1].request.isSpeculative).toBe(true);941942nextEditProvider.handleRejection(doc.id, suggestion);943await statelessProvider.calls[1].completed.p;944});945946it('clears scheduled speculative on rejection before stream completes', async () => {947await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);948949const statelessProvider = new TestStatelessNextEditProvider();950const continueSignal = new DeferredPromise<void>();951statelessProvider.enqueueBehavior({952kind: 'yieldEditThenWait',953edit: lineReplacement(1, 'const value = 2;'),954continueSignal,955});956const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);957958const doc = workspace.addDocument({959id: DocumentId.create(URI.file('/test/spec-reject-before-end.ts').toString()),960initialValue: 'const value = 1;\nconsole.log(value);',961});962doc.setSelection([new OffsetRange(0, 0)], undefined);963964// Get E0, stream paused965const suggestion = await getNextEdit(nextEditProvider, doc.id);966assert(suggestion.result?.edit);967968// Show → schedules speculative969nextEditProvider.handleShown(suggestion);970971// Reject before stream completes → clears schedule972nextEditProvider.handleRejection(doc.id, suggestion);973974// Let stream finish975continueSignal.complete();976await statelessProvider.calls[0].completed.p;977await flushMicrotasks();978979// No speculative request was created980expect(statelessProvider.calls.length).toBe(1);981});982983it('clears scheduled speculative on handleIgnored (shown, not superseded) before stream completes', async () => {984await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);985986const statelessProvider = new TestStatelessNextEditProvider();987const continueSignal = new DeferredPromise<void>();988statelessProvider.enqueueBehavior({989kind: 'yieldEditThenWait',990edit: lineReplacement(1, 'const value = 2;'),991continueSignal,992});993const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);994995const doc = workspace.addDocument({996id: DocumentId.create(URI.file('/test/spec-ignored-before-end.ts').toString()),997initialValue: 'const value = 1;\nconsole.log(value);',998});999doc.setSelection([new OffsetRange(0, 0)], undefined);10001001const suggestion = await getNextEdit(nextEditProvider, doc.id);1002assert(suggestion.result?.edit);10031004// Show → schedules speculative1005nextEditProvider.handleShown(suggestion);10061007// Ignored (shown, not superseded) before stream completes → clears schedule1008nextEditProvider.handleIgnored(doc.id, suggestion, undefined);10091010// Let stream finish1011continueSignal.complete();1012await statelessProvider.calls[0].completed.p;1013await flushMicrotasks();10141015// No speculative request was created1016expect(statelessProvider.calls.length).toBe(1);1017});10181019it('fires speculative immediately when stream already completed before handleShown', async () => {1020await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);10211022const statelessProvider = new TestStatelessNextEditProvider();1023statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });1024statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });1025const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);10261027const doc = workspace.addDocument({1028id: DocumentId.create(URI.file('/test/spec-stream-done.ts').toString()),1029initialValue: 'const value = 1;\nconsole.log(value);',1030});1031doc.setSelection([new OffsetRange(0, 0)], undefined);10321033const suggestion = await getNextEdit(nextEditProvider, doc.id);1034assert(suggestion.result?.edit);10351036// Ensure the background IIFE has completed (stream is done, pending request cleared)1037await flushMicrotasks();10381039// Now handleShown sees no pending request → fires immediately (not scheduled)1040nextEditProvider.handleShown(suggestion);1041await statelessProvider.waitForCall(2);10421043expect(statelessProvider.calls.length).toBe(2);1044expect(statelessProvider.calls[1].request.isSpeculative).toBe(true);10451046nextEditProvider.handleRejection(doc.id, suggestion);1047await statelessProvider.calls[1].completed.p;1048});10491050it('clears scheduled speculative when a new getNextEdit supersedes the originating stream', async () => {1051await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);10521053const statelessProvider = new TestStatelessNextEditProvider();1054// Stream A: yields E0, then waits (simulating a paused multi-edit stream)1055const streamAContinue = new DeferredPromise<void>();1056statelessProvider.enqueueBehavior({1057kind: 'yieldEditThenWait',1058edit: lineReplacement(1, 'const value = 2;'),1059continueSignal: streamAContinue,1060});1061// Stream B: the new request that supersedes stream A1062statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 3;') });1063const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);10641065const doc = workspace.addDocument({1066id: DocumentId.create(URI.file('/test/spec-stale-schedule.ts').toString()),1067initialValue: 'const value = 1;\nconsole.log(value);',1068});1069doc.setSelection([new OffsetRange(0, 0)], undefined);10701071// 1. Get E0 from stream A — stream is paused on streamAContinue1072const suggestionA = await getNextEdit(nextEditProvider, doc.id);1073assert(suggestionA.result?.edit);10741075// 2. handleShown(E0) → speculative is scheduled (not fired, stream A still running)1076nextEditProvider.handleShown(suggestionA);10771078// 3. A new getNextEdit supersedes stream A — should clear the scheduled speculative1079const suggestionB = await getNextEdit(nextEditProvider, doc.id);1080assert(suggestionB.result?.edit);10811082// 4. Let stream A's background IIFE finish (after cancellation).1083// Without the fix, handleStreamEnd would see the stale scheduled speculative1084// and fire _triggerSpeculativeRequest for stream A's E0.1085streamAContinue.complete();1086await statelessProvider.calls[0].completed.p;1087await flushMicrotasks();10881089// Only 2 calls: stream A and stream B. No stale speculative request fired.1090expect(statelessProvider.calls.length).toBe(2);1091});10921093it('second handleShown replaces a previously scheduled speculative', async () => {1094await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);10951096const statelessProvider = new TestStatelessNextEditProvider();1097const continueSignal = new DeferredPromise<void>();1098statelessProvider.enqueueBehavior({1099kind: 'yieldEditThenWait',1100edit: lineReplacement(1, 'const value = 2;'),1101continueSignal,1102});1103statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });1104const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);11051106const doc = workspace.addDocument({1107id: DocumentId.create(URI.file('/test/spec-replace-schedule.ts').toString()),1108initialValue: 'const value = 1;\nconsole.log(value);',1109});1110doc.setSelection([new OffsetRange(0, 0)], undefined);11111112const suggestion = await getNextEdit(nextEditProvider, doc.id);1113assert(suggestion.result?.edit);11141115// First handleShown → schedules speculative (stream still running)1116nextEditProvider.handleShown(suggestion);11171118// Second handleShown for the same suggestion → clears the previous schedule1119// and sets a new one for the same headerRequestId1120nextEditProvider.handleShown(suggestion);11211122// Resume stream → stream ends → the (second) scheduled speculative fires1123continueSignal.complete();1124await flushMicrotasks();11251126// Exactly one speculative request was created (not two)1127expect(statelessProvider.calls.length).toBe(2);1128expect(statelessProvider.calls[1].request.isSpeculative).toBe(true);11291130nextEditProvider.handleRejection(doc.id, suggestion);1131await statelessProvider.calls[1].completed.p;1132});1133});11341135describe('edit window cursor check for request reuse', () => {1136it('does not reuse in-flight request when cursor moves outside edit window', async () => {1137const statelessProvider = new TestStatelessNextEditProvider();1138// Edit window covers offsets 0–20 of the document1139statelessProvider.editWindow = new RequestEditWindow(new OffsetRange(0, 20));1140const continueSignal1 = new DeferredPromise<void>();1141const continueSignal2 = new DeferredPromise<void>();1142statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 2;'), continueSignal: continueSignal1 });1143statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 3;'), continueSignal: continueSignal2 });1144const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);11451146const doc = workspace.addDocument({1147id: DocumentId.create(URI.file('/test/ew-outside.ts').toString()),1148initialValue: 'const value = 1;\nconsole.log(value);\nconst other = 3;\n',1149});1150doc.setSelection([new OffsetRange(0, 0)], undefined);11511152// First request — yields first edit, stream still running in background1153const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);1154assert(firstSuggestion.result?.edit);1155expect(statelessProvider.calls.length).toBe(1);11561157// Move cursor far outside the edit window (offset 40)1158doc.setSelection([new OffsetRange(40, 40)], undefined);11591160// Second request — should NOT reuse the in-flight request because cursor is outside edit window1161// The first request's stream is still running, but cursor is outside its edit window, so a new request is made1162const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);1163assert(secondSuggestion.result?.edit);11641165// Two separate provider calls were made1166expect(statelessProvider.calls.length).toBe(2);11671168// Clean up1169continueSignal1.complete();1170continueSignal2.complete();1171await statelessProvider.calls[0].completed.p;1172await statelessProvider.calls[1].completed.p;1173});11741175it('reuses in-flight request when cursor stays within edit window', async () => {1176const statelessProvider = new TestStatelessNextEditProvider();1177// Edit window covers offsets 0–50 (whole document)1178statelessProvider.editWindow = new RequestEditWindow(new OffsetRange(0, 50));1179const continueSignal = new DeferredPromise<void>();1180statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 2;'), continueSignal });1181const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);11821183const doc = workspace.addDocument({1184id: DocumentId.create(URI.file('/test/ew-inside.ts').toString()),1185initialValue: 'const value = 1;\nconsole.log(value);\n',1186});1187doc.setSelection([new OffsetRange(0, 0)], undefined);11881189// First request — yields first edit, stream still running1190const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);1191assert(firstSuggestion.result?.edit);1192expect(statelessProvider.calls.length).toBe(1);11931194// Move cursor but still within the edit window (offset 10)1195doc.setSelection([new OffsetRange(10, 10)], undefined);11961197// Second request — should reuse the in-flight request1198const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);1199assert(secondSuggestion.result?.edit);12001201// Only one provider call was made (reused)1202expect(statelessProvider.calls.length).toBe(1);12031204// Clean up1205continueSignal.complete();1206await statelessProvider.calls[0].completed.p;1207});12081209it('reuses in-flight request when editWindow is undefined (graceful fallback)', async () => {1210const statelessProvider = new TestStatelessNextEditProvider();1211// No editWindow set — should allow reuse1212const continueSignal = new DeferredPromise<void>();1213statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 2;'), continueSignal });1214const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);12151216const doc = workspace.addDocument({1217id: DocumentId.create(URI.file('/test/ew-undefined.ts').toString()),1218initialValue: 'const value = 1;\nconsole.log(value);\nconst other = 3;\n',1219});1220doc.setSelection([new OffsetRange(0, 0)], undefined);12211222// First request — yields first edit, stream still running1223const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);1224assert(firstSuggestion.result?.edit);1225expect(statelessProvider.calls.length).toBe(1);12261227// Move cursor far away — but editWindow is undefined so reuse is allowed1228doc.setSelection([new OffsetRange(40, 40)], undefined);12291230// Second request — should reuse (no edit window to check)1231const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);1232assert(secondSuggestion.result?.edit);12331234expect(statelessProvider.calls.length).toBe(1);12351236// Clean up1237continueSignal.complete();1238await statelessProvider.calls[0].completed.p;1239});12401241it('does not reuse speculative request when cursor moves outside edit window', async () => {1242await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);12431244const statelessProvider = new TestStatelessNextEditProvider();1245// Edit window covers offsets 0–201246statelessProvider.editWindow = new RequestEditWindow(new OffsetRange(0, 20));1247statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });1248statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });1249// Third behavior for the new request that will be needed since speculative won't be reused1250statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });1251const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);12521253const doc = workspace.addDocument({1254id: DocumentId.create(URI.file('/test/ew-spec-outside.ts').toString()),1255initialValue: 'const value = 1;\nconsole.log(value);\nconst other = 3;\n',1256});1257doc.setSelection([new OffsetRange(0, 0)], undefined);12581259const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);1260assert(firstSuggestion.result?.edit);1261nextEditProvider.handleShown(firstSuggestion);1262await statelessProvider.waitForCall(2);1263await statelessProvider.calls[1].completed.p;12641265// Accept and apply the edit1266nextEditProvider.handleAcceptance(doc.id, firstSuggestion);1267doc.applyEdit(firstSuggestion.result.edit.toEdit());12681269// Move cursor outside the speculative request's edit window1270doc.setSelection([new OffsetRange(40, 40)], undefined);12711272// This should NOT reuse the speculative request (cursor is outside)1273await getNextEdit(nextEditProvider, doc.id);12741275// Three calls: original, speculative, and a new one (speculative was not reused)1276expect(statelessProvider.calls.length).toBe(3);1277});12781279it('reuses in-flight request when cursor is within originalWindow of cursor jump edit window', async () => {1280const statelessProvider = new TestStatelessNextEditProvider();1281// Cursor jump: new window is at 30–50, original window is at 0–201282statelessProvider.editWindow = new RequestEditWindowWithCursorJump(new OffsetRange(30, 50), new OffsetRange(0, 20));1283const continueSignal = new DeferredPromise<void>();1284statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(1, 'const value = 2;'), continueSignal });1285const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);12861287const doc = workspace.addDocument({1288id: DocumentId.create(URI.file('/test/ew-cursorjump.ts').toString()),1289initialValue: 'const value = 1;\nconsole.log(value);\nconst other = 3;\nconst extra = 4;\n',1290});1291doc.setSelection([new OffsetRange(0, 0)], undefined);12921293// First request — yields first edit, stream still running1294const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);1295assert(firstSuggestion.result?.edit);1296expect(statelessProvider.calls.length).toBe(1);12971298// Move cursor to offset 10 — inside originalWindow (0–20) but outside jump target (30–50)1299doc.setSelection([new OffsetRange(10, 10)], undefined);13001301// Second request — should reuse because cursor is in originalWindow1302const secondSuggestion = await getNextEdit(nextEditProvider, doc.id);1303assert(secondSuggestion.result?.edit);13041305expect(statelessProvider.calls.length).toBe(1);13061307// Clean up1308continueSignal.complete();1309await statelessProvider.calls[0].completed.p;1310});1311});13121313describe('cached speculative result delay', () => {1314it('uses speculativeRequestDelay (not cacheDelay) when speculative result is served from cache', async () => {1315const CACHE_DELAY_MS = 5_000;1316await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);1317await configService.setConfig(ConfigKey.TeamInternal.InlineEditsCacheDelay, CACHE_DELAY_MS);1318await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestDelay, 0);13191320const statelessProvider = new TestStatelessNextEditProvider();1321statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });1322statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });1323const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);13241325const doc = workspace.addDocument({1326id: DocumentId.create(URI.file('/test/spec-cache-delay.ts').toString()),1327initialValue: 'const value = 1;\nconsole.log(value);',1328});1329doc.setSelection([new OffsetRange(0, 0)], undefined);13301331// First request (fresh)1332const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);1333assert(firstSuggestion.result?.edit);13341335// Show → triggers speculative request; wait for it to complete and cache1336nextEditProvider.handleShown(firstSuggestion);1337await statelessProvider.waitForCall(2);1338await statelessProvider.calls[1].completed.p;13391340// Accept and apply — doc now matches speculative request's postEditContent1341nextEditProvider.handleAcceptance(doc.id, firstSuggestion);1342doc.applyEdit(firstSuggestion.result.edit.toEdit());13431344// Next getNextEdit hits the cache path (speculative result already cached).1345// With enforceCacheDelay=true, it should use speculativeRequestDelay (0ms),1346// NOT the normal cacheDelay (5000ms).1347const context: NESInlineCompletionContext = {1348triggerKind: 1,1349selectedCompletionInfo: undefined,1350requestUuid: generateUuid(),1351requestIssuedDateTime: Date.now(),1352earliestShownDateTime: Date.now(),1353enforceCacheDelay: true,1354};1355const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context);1356const telemetryBuilder = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, undefined);1357const start = Date.now();1358try {1359const cachedSuggestion = await nextEditProvider.getNextEdit(doc.id, context, logContext, CancellationToken.None, telemetryBuilder.nesBuilder);1360const elapsed = Date.now() - start;1361assert(cachedSuggestion.result?.edit);13621363// The result comes from a speculative request's cache, so it should1364// use the speculative delay (0ms) rather than the cache delay (5000ms)1365expect(elapsed).toBeLessThan(100);1366} finally {1367telemetryBuilder.dispose();1368}1369});1370});13711372describe('lifecycle cancellation', () => {1373it('cancels in-flight speculative when clearCache() is called', async () => {1374await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);13751376const statelessProvider = new TestStatelessNextEditProvider();1377statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });1378statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });1379const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);13801381const doc = workspace.addDocument({1382id: DocumentId.create(URI.file('/test/spec-clear-cache.ts').toString()),1383initialValue: 'const value = 1;\nconsole.log(value);',1384});1385doc.setSelection([new OffsetRange(0, 0)], undefined);13861387const suggestion = await getNextEdit(nextEditProvider, doc.id);1388assert(suggestion.result?.edit);1389nextEditProvider.handleShown(suggestion);1390await statelessProvider.waitForCall(2);13911392nextEditProvider.clearCache();1393await statelessProvider.calls[1].cancellationRequested.p;13941395expect(statelessProvider.calls[1].wasCancelled).toBe(true);1396});13971398it('cancels in-flight speculative when its target document is closed', async () => {1399await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);14001401const statelessProvider = new TestStatelessNextEditProvider();1402statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });1403statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });1404const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);14051406const doc = workspace.addDocument({1407id: DocumentId.create(URI.file('/test/spec-doc-close.ts').toString()),1408initialValue: 'const value = 1;\nconsole.log(value);',1409});1410doc.setSelection([new OffsetRange(0, 0)], undefined);14111412const suggestion = await getNextEdit(nextEditProvider, doc.id);1413assert(suggestion.result?.edit);1414nextEditProvider.handleShown(suggestion);1415await statelessProvider.waitForCall(2);14161417// Closing the document removes it from openDocuments — the speculative's1418// cached result would never be hit again, so cancel it.1419doc.dispose();1420await statelessProvider.calls[1].cancellationRequested.p;14211422expect(statelessProvider.calls[1].wasCancelled).toBe(true);1423});14241425it('cancels in-flight speculative when the provider is disposed', async () => {1426await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);14271428const statelessProvider = new TestStatelessNextEditProvider();1429statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });1430statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });1431const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);14321433const doc = workspace.addDocument({1434id: DocumentId.create(URI.file('/test/spec-provider-dispose.ts').toString()),1435initialValue: 'const value = 1;\nconsole.log(value);',1436});1437doc.setSelection([new OffsetRange(0, 0)], undefined);14381439const suggestion = await getNextEdit(nextEditProvider, doc.id);1440assert(suggestion.result?.edit);1441nextEditProvider.handleShown(suggestion);1442await statelessProvider.waitForCall(2);14431444nextEditProvider.dispose();1445await statelessProvider.calls[1].cancellationRequested.p;14461447expect(statelessProvider.calls[1].wasCancelled).toBe(true);1448});1449});1450});145114521453