Path: blob/main/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts
13399 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 { basename } from 'path';6import type * as vscode from 'vscode';7import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';8import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';9import { Edits, RootedEdit } from '../../../platform/inlineEdits/common/dataTypes/edit';10import { RootedLineEdit } from '../../../platform/inlineEdits/common/dataTypes/rootedLineEdit';11import { SpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsCursorPlacement, SpeculativeRequestsEnablement } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';12import { InlineEditRequestLogContext, type MarkdownLoggable } from '../../../platform/inlineEdits/common/inlineEditLogContext';13import { IObservableDocument, ObservableWorkspace } from '../../../platform/inlineEdits/common/observableWorkspace';14import { IStatelessNextEditProvider, IStatelessNextEditTelemetry, NoNextEditReason, StatelessNextEditDocument, StatelessNextEditRequest, StatelessNextEditResult } from '../../../platform/inlineEdits/common/statelessNextEditProvider';15import { autorunWithChanges } from '../../../platform/inlineEdits/common/utils/observable';16import { DocumentHistory, HistoryContext, IHistoryContextProvider } from '../../../platform/inlineEdits/common/workspaceEditTracker/historyContextProvider';17import { IXtabHistoryEditEntry, IXtabHistoryEntry, NesXtabHistoryTracker } from '../../../platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker';18import { ILogger, ILogService, LogTarget } from '../../../platform/log/common/logService';19import { CapturingToken } from '../../../platform/requestLogger/common/capturingToken';20import { IRequestLogger, LoggedRequestKind } from '../../../platform/requestLogger/common/requestLogger';21import { ISnippyService } from '../../../platform/snippy/common/snippyService';22import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';23import { ErrorUtils } from '../../../util/common/errors';24import { Result } from '../../../util/common/result';25import { assert, assertNever } from '../../../util/vs/base/common/assert';26import { DeferredPromise, timeout, TimeoutTimer } from '../../../util/vs/base/common/async';27import { CachedFunction } from '../../../util/vs/base/common/cache';28import { CancellationToken } from '../../../util/vs/base/common/cancellation';29import { BugIndicatingError } from '../../../util/vs/base/common/errors';30import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../util/vs/base/common/lifecycle';31import { mapObservableArrayCached, runOnChange } from '../../../util/vs/base/common/observable';32import { StopWatch } from '../../../util/vs/base/common/stopwatch';33import { assertType } from '../../../util/vs/base/common/types';34import { generateUuid } from '../../../util/vs/base/common/uuid';35import { LineEdit, LineReplacement } from '../../../util/vs/editor/common/core/edits/lineEdit';36import { StringEdit, StringReplacement } from '../../../util/vs/editor/common/core/edits/stringEdit';37import { Position } from '../../../util/vs/editor/common/core/position';38import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';39import { StringText } from '../../../util/vs/editor/common/core/text/abstractText';40import { checkEditConsistency } from '../common/editRebase';41import { NesChangeHint } from '../common/nesTriggerHint';42import { RejectionCollector } from '../common/rejectionCollector';43import { DebugRecorder } from './debugRecorder';44import { INesConfigs } from './nesConfigs';45import { CachedEdit, CachedOrRebasedEdit, NextEditCache } from './nextEditCache';46import { LlmNESTelemetryBuilder, ReusedRequestKind } from './nextEditProviderTelemetry';47import { INextEditResult, NextEditResult } from './nextEditResult';48import { SpeculativeCancelReason, SpeculativeRequestManager } from './speculativeRequestManager';4950/**51* Computes a reduced window range that encompasses both the original window (shrunk by one line52* on each end) and the full line where the cursor is located.53*54* This ensures the cache invalidation window always includes the cursor's line while trimming55* the edges of the original window.56*/57function computeReducedWindow(58window: OffsetRange,59activeDocSelection: OffsetRange | undefined,60documentBeforeEdits: StringText61): OffsetRange {62if (!activeDocSelection) {63return window;64}65const cursorOffset = activeDocSelection.endExclusive;66const t = documentBeforeEdits.getTransformer();67const cursorPosition = t.getPosition(cursorOffset);68const lineOffset = t.getOffset(cursorPosition.with(undefined, 1));69const lineEndOffset = t.getOffset(cursorPosition.with(undefined, t.getLineLength(cursorPosition.lineNumber) + 1));70const reducedOffset = t.getOffset(t.getPosition(window.start).delta(1));71const reducedEndPosition = t.getPosition(window.endExclusive).delta(-2);72const reducedEndOffset = t.getOffset(reducedEndPosition.column > 1 ? reducedEndPosition.with(undefined, t.getLineLength(reducedEndPosition.lineNumber) + 1) : reducedEndPosition);73return new OffsetRange(74Math.min(reducedOffset, lineOffset),75Math.max(reducedEndOffset, lineEndOffset)76);77}7879function convertLineEditToEdit(nextLineEdit: LineEdit, document: StringText): StringEdit {80const rootedLineEdit = new RootedLineEdit(document, nextLineEdit);81const suggestedEdit = rootedLineEdit.toEdit();82// LineReplacement.toSingleTextEdit always joins newLines with '\n'.83// If the document uses '\r\n' line endings, we need to match that in84// the replacement text so that applying the edit produces consistent85// line endings and the resulting content matches what VS Code reports.86if (document.value.includes('\r\n')) {87return new StringEdit(suggestedEdit.replacements.map(88r => new StringReplacement(r.replaceRange, r.newText.replace(/\n/g, '\r\n'))89));90}91return suggestedEdit;92}9394function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xtabEditHistory: readonly IXtabHistoryEntry[]): CachedFunction<DocumentId, {95baseDocState: StringText;96docContents: StringText;97editsSoFar: StringEdit;98nextEdits: StringReplacement[];99docId: DocumentId;100}> {101const statePerDoc = new CachedFunction((id: DocumentId) => {102const doc = projectedDocuments.find(d => d.nextEditDoc.id === id);103if (!doc) {104for (let i = xtabEditHistory.length - 1; i >= 0; i--) {105const entry = xtabEditHistory[i];106if (entry.docId === id && entry.kind === 'edit') {107const baseDocState = entry.edit.getEditedState();108return {109baseDocState,110docContents: baseDocState,111editsSoFar: StringEdit.empty,112nextEdits: [] as StringReplacement[],113docId: id,114};115}116}117throw new BugIndicatingError();118}119return {120baseDocState: doc.documentAfterEdits,121docContents: doc.documentAfterEdits,122editsSoFar: StringEdit.empty,123nextEdits: [] as StringReplacement[],124docId: id,125};126});127128129return statePerDoc;130}131132export interface NESInlineCompletionContext extends vscode.InlineCompletionContext {133enforceCacheDelay: boolean;134changeHint?: NesChangeHint;135}136137export enum NesOutcome {138Accepted = 'accepted',139Rejected = 'rejected',140Ignored = 'ignored',141}142143export interface INextEditProvider<T extends INextEditResult, TTelemetry, TData = void> extends IDisposable {144readonly ID: string;145getNextEdit(docId: DocumentId, context: NESInlineCompletionContext, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken, telemetryBuilder: TTelemetry, data?: TData): Promise<T>;146handleShown(suggestion: T): void;147handleAcceptance(docId: DocumentId, suggestion: T): void;148handleRejection(docId: DocumentId, suggestion: T): void;149handleIgnored(docId: DocumentId, suggestion: T, supersededBy: INextEditResult | undefined): void;150lastRejectionTime: number;151lastTriggerTime: number;152lastOutcome: NesOutcome | undefined;153}154155interface ProcessedDoc {156recentEdit: RootedEdit<StringEdit>;157nextEditDoc: StatelessNextEditDocument;158documentAfterEdits: StringText;159}160161export class NextEditProvider extends Disposable implements INextEditProvider<NextEditResult, LlmNESTelemetryBuilder> {162163public readonly ID = this._statelessNextEditProvider.ID;164165private readonly _rejectionCollector = this._register(new RejectionCollector(this._workspace, this._logService));166private readonly _nextEditCache: NextEditCache;167168private _pendingStatelessNextEditRequest: StatelessNextEditRequest<CachedOrRebasedEdit> | null = null;169170private readonly _specManager: SpeculativeRequestManager;171172private _lastShownTime = 0;173/** The requestId of the last shown suggestion. We store only the requestId (not the object) to avoid preventing garbage collection. */174private _lastShownSuggestionId: number | undefined = undefined;175176private _lastRejectionTime = 0;177public get lastRejectionTime() {178return this._lastRejectionTime;179}180181private _lastTriggerTime = 0;182public get lastTriggerTime() {183return this._lastTriggerTime;184}185186private _lastOutcome: NesOutcome | undefined;187public get lastOutcome() {188return this._lastOutcome;189}190191private _lastNextEditResult: NextEditResult | undefined;192private _shouldExpandEditWindow = false;193194private _logger: ILogger;195196constructor(197private readonly _workspace: ObservableWorkspace,198private readonly _statelessNextEditProvider: IStatelessNextEditProvider,199private readonly _historyContextProvider: IHistoryContextProvider,200private readonly _xtabHistoryTracker: NesXtabHistoryTracker,201private readonly _debugRecorder: DebugRecorder | undefined,202@IConfigurationService private readonly _configService: IConfigurationService,203@ISnippyService private readonly _snippyService: ISnippyService,204@ILogService private readonly _logService: ILogService,205@IExperimentationService private readonly _expService: IExperimentationService,206@IRequestLogger private readonly _requestLogger: IRequestLogger,207) {208super();209210this._logger = this._logService.createSubLogger(['NES', 'NextEditProvider']);211this._nextEditCache = new NextEditCache(this._workspace, this._logService, this._configService, this._expService);212this._specManager = this._register(new SpeculativeRequestManager(this._logger.createSubLogger('SpeculativeRequestManager')));213214mapObservableArrayCached(this, this._workspace.openDocuments, (doc, store) => {215store.add(runOnChange(doc.value, (value) => {216this._cancelPendingRequestDueToDocChange(doc.id, value);217// FIXME: don't invoke before fixing false positive cancellations218// this._specManager.onActiveDocumentChanged(doc.id, value.value);219}));220// When the per-doc store is disposed, the document was removed from221// openDocuments. Cancel any speculative targeting it — its cached result222// would never be hit again.223store.add(toDisposable(() => this._specManager.onDocumentClosed(doc.id)));224}).recomputeInitiallyAndOnChange(this._store);225}226227/**228* Cancels the in-flight stateless next-edit request when the document it229* was issued for has diverged from the request's expected post-edit state.230*231* Invoked from the per-document `runOnChange` autorun in the constructor232* whenever an open document's value changes. The pending request was built233* against a specific snapshot (`documentAfterEdits`); if the user has since234* typed something that makes the current value differ from that snapshot,235* the result would no longer be applicable and is cancelled eagerly.236*237* Skipped when:238* - the `InlineEditsAsyncCompletions` experiment is enabled (that path239* tolerates divergence and rebases later), or240* - there is no pending request, or241* - the changed document is not the one the pending request targets.242*243* Note: this only handles the regular pending stateless request. Speculative244* requests have their own divergence handling via245* `SpeculativeRequestManager.onActiveDocumentChanged` (trajectory check).246*/247private _cancelPendingRequestDueToDocChange(docId: DocumentId, docValue: StringText) {248const isAsyncCompletions = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAsyncCompletions, this._expService);249250if (isAsyncCompletions || this._pendingStatelessNextEditRequest === null) {251return;252}253254const activeDoc = this._pendingStatelessNextEditRequest.getActiveDocument();255if (activeDoc.id === docId && activeDoc.documentAfterEdits.value !== docValue.value) {256this._pendingStatelessNextEditRequest.cancellationTokenSource.cancel();257}258}259260public async getNextEdit(261docId: DocumentId,262context: NESInlineCompletionContext,263logContext: InlineEditRequestLogContext,264cancellationToken: CancellationToken,265telemetryBuilder: LlmNESTelemetryBuilder266): Promise<NextEditResult> {267const now = Date.now();268269this._lastTriggerTime = now;270271const sw = new StopWatch();272273const logger = this._logger.createSubLogger(context.requestUuid.substring(4, 8))274.withExtraTarget(LogTarget.fromCallback((_level, msg) => {275logContext.trace(`[${Math.floor(sw.elapsed()).toString().padStart(4, ' ')}ms] ${msg}`);276}));277278const shouldExpandEditWindow = this._shouldExpandEditWindow;279280logContext.setStatelessNextEditProviderId(this._statelessNextEditProvider.ID);281282let result: NextEditResult;283try {284result = await this._getNextEditCanThrow(docId, context, now, shouldExpandEditWindow, logger, logContext, cancellationToken, telemetryBuilder);285} catch (error) {286logContext.setError(error);287telemetryBuilder.setNextEditProviderError(ErrorUtils.toString(error));288throw error;289} finally {290telemetryBuilder.markEndTime();291}292293this._lastNextEditResult = result;294295return result;296}297298private async _getNextEditCanThrow(299docId: DocumentId,300context: NESInlineCompletionContext,301triggerTime: number,302shouldExpandEditWindow: boolean,303parentLogger: ILogger,304logContext: InlineEditRequestLogContext,305cancellationToken: CancellationToken,306telemetryBuilder: LlmNESTelemetryBuilder307): Promise<NextEditResult> {308309const logger = parentLogger.createSubLogger('_getNextEdit');310logger.trace(`invoked with trigger id = ${context.changeHint === undefined ? 'undefined' : `uuid = ${context.changeHint.data.uuid}, reason = ${context.changeHint.data.reason}`}`);311312const doc = this._workspace.getDocument(docId);313if (!doc) {314logger.trace(`Document "${docId.baseName}" not found`);315throw new BugIndicatingError(`Document "${docId.baseName}" not found`);316}317318const documentAtInvocationTime = doc.value.get();319const selections = doc.selection.get();320321const nesConfigs = this.determineNesConfigs(telemetryBuilder, logContext);322323const cachedEdit = this._nextEditCache.lookupNextEdit(docId, documentAtInvocationTime, selections);324if (cachedEdit?.rejected) {325logger.trace('cached edit was previously rejected');326telemetryBuilder.setStatus('previouslyRejectedCache');327telemetryBuilder.setWasPreviouslyRejected();328logContext.markAsPreviouslyRejected();329const rejectedEdit = cachedEdit.rebasedEdit ?? cachedEdit.edit;330if (rejectedEdit) {331this._rejectionCollector.reject(docId, rejectedEdit);332}333const nextEditResult = new NextEditResult(logContext.requestId, cachedEdit.source, undefined);334return nextEditResult;335}336337let edit: { actualEdit: StringReplacement; isFromCursorJump: boolean } | undefined;338let currentDocument: StringText | undefined;339let error: NoNextEditReason | undefined;340let req: NextEditFetchRequest;341let targetDocumentId = docId;342343let isRebasedCachedEdit = false;344let isSubsequentCachedEdit = false;345let isFromSpeculativeRequest = false;346let cacheEntry: CachedEdit | undefined;347348if (cachedEdit) {349logger.trace('using cached edit');350const actualEdit = cachedEdit.rebasedEdit || cachedEdit.edit;351if (actualEdit) {352edit = { actualEdit, isFromCursorJump: cachedEdit.isFromCursorJump };353}354isRebasedCachedEdit = !!cachedEdit.rebasedEdit;355isSubsequentCachedEdit = cachedEdit.subsequentN !== undefined && cachedEdit.subsequentN > 0;356isFromSpeculativeRequest = cachedEdit.source.isSpeculative;357req = cachedEdit.source;358logContext.setIsCachedResult(cachedEdit.source.log);359currentDocument = documentAtInvocationTime;360telemetryBuilder.setHeaderRequestId(req.headerRequestId);361telemetryBuilder.setIsFromCache();362telemetryBuilder.setSubsequentEditOrder(cachedEdit.rebasedEditIndex ?? cachedEdit.subsequentN);363// back-date the recording bookmark of the cached edit to the bookmark of the original request.364logContext.recordingBookmark = req.log.recordingBookmark;365cacheEntry = cachedEdit.baseCacheEntry ?? cachedEdit;366367} else {368logger.trace(`fetching next edit with shouldExpandEditWindow=${shouldExpandEditWindow}`);369const providerRequestStartDateTime = (this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsDebounceUseCoreRequestTime, this._expService)370? (context.requestIssuedDateTime ?? undefined)371: undefined);372req = new NextEditFetchRequest(context.requestUuid, logContext, providerRequestStartDateTime, false);373telemetryBuilder.setHeaderRequestId(req.headerRequestId);374375const startVersion = doc.value.get();376logger.trace('awaiting firstEdit promise');377const result = await this.fetchNextEdit(req, doc, nesConfigs, shouldExpandEditWindow, logger, telemetryBuilder, cancellationToken);378logger.trace('resolved firstEdit promise');379const latency = `First edit latency: ${Date.now() - this._lastTriggerTime} ms`;380logContext.addLog(latency);381logger.trace(latency);382383if (result.isError()) {384logger.trace(`failed to fetch next edit ${result.err.toString()}`);385telemetryBuilder.setStatus(`noEdit:${result.err.kind}`);386error = result.err;387} else {388targetDocumentId = result.val.docId ?? targetDocumentId;389const targetDoc = targetDocumentId ? this._workspace.getDocument(targetDocumentId)! : doc;390currentDocument = targetDoc.value.get();391const docDidChange = targetDocumentId === doc.id && startVersion.value !== currentDocument.value;392393if (docDidChange) {394logger.trace('document changed while fetching next edit');395telemetryBuilder.setStatus('docChanged');396logContext.setIsSkipped();397} else {398const suggestedNextEdit = result.val.rebasedEdit || result.val.edit;399if (!suggestedNextEdit) {400logger.trace('empty edits');401telemetryBuilder.setStatus('emptyEdits');402} else {403logger.trace('fetch succeeded');404logContext.setResponseResults([suggestedNextEdit]); // TODO: other streamed edits?405edit = { actualEdit: suggestedNextEdit, isFromCursorJump: result.val.isFromCursorJump };406isFromSpeculativeRequest = result.val.isFromSpeculativeRequest ?? false;407cacheEntry = result.val.baseCacheEntry ?? result.val;408}409}410}411}412413if (error instanceof NoNextEditReason.FetchFailure || error instanceof NoNextEditReason.Unexpected) {414logger.trace(`has throwing error: ${error.error}`);415throw error.error;416} else if (error instanceof NoNextEditReason.NoSuggestions) {417if (error.nextCursorPosition === undefined) {418logContext.markAsNoSuggestions();419} else {420telemetryBuilder.setStatus('emptyEditsButHasNextCursorPosition');421return new NextEditResult(logContext.requestId, req, { jumpToPosition: error.nextCursorPosition, targetDocumentId: error.nextCursorDocumentId, documentBeforeEdits: documentAtInvocationTime, isFromCursorJump: false, isSubsequentEdit: false });422}423} else if (error instanceof NoNextEditReason.GotCancelled) {424logContext.setIsSkipped();425}426427const emptyResult = new NextEditResult(logContext.requestId, req, undefined);428429if (!edit) {430logger.trace('had no edit');431// telemetry builder status must've been set earlier432return emptyResult;433}434435if (cancellationToken.isCancellationRequested) {436logger.trace('cancelled');437telemetryBuilder.setStatus(`noEdit:gotCancelled`);438return emptyResult;439}440441if (this._rejectionCollector.isRejected(targetDocumentId, edit.actualEdit) || currentDocument && this._nextEditCache.isRejectedNextEdit(targetDocumentId, currentDocument, edit.actualEdit)) {442logger.trace('edit was previously rejected');443telemetryBuilder.setStatus('previouslyRejected');444telemetryBuilder.setWasPreviouslyRejected();445logContext.markAsPreviouslyRejected();446return emptyResult;447}448449logContext.setResult(RootedLineEdit.fromEdit(new RootedEdit(documentAtInvocationTime, new StringEdit([edit.actualEdit]))));450451assert(currentDocument !== undefined, 'should be defined if edit is defined');452453telemetryBuilder.setStatus('notAccepted'); // Acceptance pending.454455const nextEditResult = new NextEditResult(logContext.requestId, req, { edit: edit.actualEdit, isFromCursorJump: edit.isFromCursorJump, documentBeforeEdits: currentDocument, targetDocumentId, isSubsequentEdit: isSubsequentCachedEdit, cacheEntry });456457telemetryBuilder.setHasNextEdit(true);458459const delay = this.computeMinimumResponseDelay({ triggerTime, isRebasedCachedEdit, isSubsequentCachedEdit, isFromSpeculativeRequest, enforceCacheDelay: context.enforceCacheDelay }, logger);460if (delay > 0) {461await timeout(delay);462if (cancellationToken.isCancellationRequested) {463logger.trace('cancelled');464telemetryBuilder.setStatus(`noEdit:gotCancelled`);465return emptyResult;466}467}468469logger.trace('returning next edit result');470return nextEditResult;471}472473private determineNesConfigs(telemetryBuilder: LlmNESTelemetryBuilder, logContext: InlineEditRequestLogContext): INesConfigs {474const nesConfigs: INesConfigs = {475isAsyncCompletions: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAsyncCompletions, this._expService),476isEagerBackupRequest: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsEagerBackupRequest, this._expService),477};478479telemetryBuilder.setNESConfigs({ ...nesConfigs });480logContext.addCodeblockToLog(JSON.stringify(nesConfigs, null, '\t'));481482return nesConfigs;483}484485private _processDoc(doc: DocumentHistory): ProcessedDoc {486const documentLinesBeforeEdit = doc.lastEdit.base.getLines();487488const recentEdits = doc.lastEdits;489490const recentEdit = RootedLineEdit.fromEdit(new RootedEdit(doc.lastEdit.base, doc.lastEdits.compose())).removeCommonSuffixPrefixLines().edit;491492const documentBeforeEdits = doc.lastEdit.base;493494const lastSelectionInAfterEdits = doc.lastSelection;495496const workspaceRoot = this._workspace.getWorkspaceRoot(doc.docId);497498const nextEditDoc = new StatelessNextEditDocument(499doc.docId,500workspaceRoot,501doc.languageId,502documentLinesBeforeEdit,503recentEdit,504documentBeforeEdits,505recentEdits,506lastSelectionInAfterEdits,507);508509return {510recentEdit: doc.lastEdit,511nextEditDoc,512documentAfterEdits: nextEditDoc.documentAfterEdits,513};514}515516private async fetchNextEdit(req: NextEditFetchRequest, doc: IObservableDocument, nesConfigs: INesConfigs, shouldExpandEditWindow: boolean, parentLogger: ILogger, telemetryBuilder: LlmNESTelemetryBuilder, cancellationToken: CancellationToken): Promise<Result<CachedOrRebasedEdit, NoNextEditReason>> {517const curDocId = doc.id;518const logger = parentLogger.createSubLogger('fetchNextEdit');519const historyContext = this._historyContextProvider.getHistoryContext(curDocId);520521if (!historyContext) {522return Result.error(new NoNextEditReason.Unexpected(new Error('DocumentMissingInHistoryContext')));523}524525const documentAtInvocationTime = doc.value.get();526const selectionAtInvocationTime = doc.selection.get();527528const logContext = req.log;529530logContext.setRecentEdit(historyContext);531532const cursorAtInvocationTime = selectionAtInvocationTime.at(0);533const cursorInRequestEditWindow = (request: StatelessNextEditRequest) =>534!request.requestEditWindow || !cursorAtInvocationTime || request.requestEditWindow.containsCursor(cursorAtInvocationTime);535536// Check if we can reuse the regular pending request537const pendingRequestStillCurrent = documentAtInvocationTime.value === this._pendingStatelessNextEditRequest?.documentBeforeEdits.value;538const cursorWithinPendingEditWindow = !this._pendingStatelessNextEditRequest || cursorInRequestEditWindow(this._pendingStatelessNextEditRequest);539const existingNextEditRequest = (pendingRequestStillCurrent || nesConfigs.isAsyncCompletions) && cursorWithinPendingEditWindow540&& !this._pendingStatelessNextEditRequest?.cancellationTokenSource.token.isCancellationRequested541&& this._pendingStatelessNextEditRequest || undefined;542543// Check if we can reuse the speculative pending request (from when a suggestion was shown)544const specPending = this._specManager.pending;545const speculativeRequestMatches = specPending?.docId === curDocId546&& specPending?.postEditContent === documentAtInvocationTime.value547&& !specPending.request.cancellationTokenSource.token.isCancellationRequested548&& cursorInRequestEditWindow(specPending.request);549const speculativeRequest = speculativeRequestMatches ? specPending?.request : undefined;550551// Prefer speculative request if it matches (it was specifically created for this post-edit state)552const requestToReuse = speculativeRequest ?? existingNextEditRequest;553554if (requestToReuse) {555// Nice! No need to make another request, we can reuse the result from a pending request.556if (speculativeRequest) {557logger.trace(`reusing speculative pending request (opportunityId=${speculativeRequest.opportunityId}, headerRequestId=${speculativeRequest.headerRequestId})`);558// Detach the speculative — caller is consuming it now.559this._specManager.consumePending();560} else {561logger.trace(`reusing in-flight pending request (opportunityId=${requestToReuse.opportunityId}, headerRequestId=${requestToReuse.headerRequestId})`);562}563564const requestStillCurrent = speculativeRequest565? speculativeRequestMatches // For speculative, we already checked it matches566: pendingRequestStillCurrent;567568const reusedRequestKind = speculativeRequest ? ReusedRequestKind.Speculative : ReusedRequestKind.Async;569570if (requestStillCurrent) {571const nextEditResult = await this._joinNextEditRequest(requestToReuse, reusedRequestKind, telemetryBuilder, logContext, cancellationToken);572telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);573if (speculativeRequest) {574const firstEdit = await requestToReuse.firstEdit.p;575return firstEdit.map(val => ({ ...val, isFromSpeculativeRequest: true }));576}577return nextEditResult.nextEdit.isError() ? nextEditResult.nextEdit : requestToReuse.firstEdit.p;578} else if (nesConfigs.isEagerBackupRequest) {579// The pending request is stale (document diverged). Start a backup request580// in parallel so that if rebase fails, we already have a head start.581logger.trace('starting eager backup request in parallel with rebase attempt');582583// _executeNewNextEditRequest cancels the current _pendingStatelessNextEditRequest,584// but we're still trying to join+rebase requestToReuse. Temporarily clear the585// pending field so the stale request isn't cancelled prematurely.586this._pendingStatelessNextEditRequest = null;587const backupPromise = this._executeNewNextEditRequest(req, doc, historyContext, nesConfigs, shouldExpandEditWindow, logger, telemetryBuilder, cancellationToken);588const cancelBackupRequest = () => {589void backupPromise590.then(r => r.nextEditRequest.cancellationTokenSource.cancel())591.catch(() => undefined);592};593594// Simultaneously attempt to join + rebase the stale request595const nextEditResult = await this._joinNextEditRequest(requestToReuse, reusedRequestKind, telemetryBuilder, logContext, cancellationToken);596const cacheResult = await requestToReuse.firstEdit.p;597if (cacheResult.isOk() && cacheResult.val.edit) {598const rebaseResult = this._nextEditCache.tryRebaseCacheEntry(cacheResult.val, documentAtInvocationTime, selectionAtInvocationTime);599if (rebaseResult.edit) {600logger.trace('rebase succeeded, cancelling eager backup request');601cancelBackupRequest();602telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);603return Result.ok(rebaseResult.edit);604}605this._logRebaseFailure(rebaseResult.failureInfo, logContext);606}607608if (cancellationToken.isCancellationRequested) {609logger.trace('cancelled after rebase failed (eager backup path)');610cancelBackupRequest();611telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);612return Result.error(new NoNextEditReason.GotCancelled('afterFailedRebase'));613}614615// Rebase failed — use the backup request that's already been running in parallel616logger.trace('rebase failed, using eager backup request');617const backupRes = await backupPromise;618telemetryBuilder.setStatelessNextEditTelemetry(backupRes.nextEditResult.telemetry);619return backupRes.nextEditResult.nextEdit.isError() ? backupRes.nextEditResult.nextEdit : backupRes.nextEditRequest.firstEdit.p;620} else {621const nextEditResult = await this._joinNextEditRequest(requestToReuse, reusedRequestKind, telemetryBuilder, logContext, cancellationToken);622623// Needs rebasing.624const cacheResult = await requestToReuse.firstEdit.p;625if (cacheResult.isOk() && cacheResult.val.edit) {626const rebaseResult = this._nextEditCache.tryRebaseCacheEntry(cacheResult.val, documentAtInvocationTime, selectionAtInvocationTime);627if (rebaseResult.edit) {628telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);629return Result.ok(rebaseResult.edit);630}631this._logRebaseFailure(rebaseResult.failureInfo, logContext);632}633634if (cancellationToken.isCancellationRequested) {635logger.trace('document changed after rebase failed');636telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);637return Result.error(new NoNextEditReason.GotCancelled('afterFailedRebase'));638}639640// Rebase failed (or result had error). Check if there is a new pending request. Otherwise continue with a new request below.641const pendingRequestStillCurrent2 = documentAtInvocationTime.value === this._pendingStatelessNextEditRequest?.documentBeforeEdits.value;642const existingNextEditRequest2 = pendingRequestStillCurrent2 && !this._pendingStatelessNextEditRequest?.cancellationTokenSource.token.isCancellationRequested643&& this._pendingStatelessNextEditRequest || undefined;644if (existingNextEditRequest2) {645logger.trace('reusing 2nd existing next edit request after rebase failed');646const nextEditResult2 = await this._joinNextEditRequest(existingNextEditRequest2, ReusedRequestKind.Async, telemetryBuilder, logContext, cancellationToken);647telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult2.telemetry);648return nextEditResult2.nextEdit.isError() ? nextEditResult2.nextEdit : existingNextEditRequest2.firstEdit.p;649}650651logger.trace('creating new next edit request after rebase failed');652}653}654655const res = await this._executeNewNextEditRequest(req, doc, historyContext, nesConfigs, shouldExpandEditWindow, logger, telemetryBuilder, cancellationToken);656const nextEditRequest = res.nextEditRequest;657const nextEditResult = res.nextEditResult;658telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);659return nextEditResult.nextEdit.isError() ? nextEditResult.nextEdit : nextEditRequest.firstEdit.p;660}661662private async _joinNextEditRequest(nextEditRequest: StatelessNextEditRequest, reusedRequestKind: ReusedRequestKind, telemetryBuilder: LlmNESTelemetryBuilder, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) {663telemetryBuilder.setHeaderRequestId(nextEditRequest.headerRequestId);664telemetryBuilder.setReusedRequest(reusedRequestKind);665666telemetryBuilder.setRequest(nextEditRequest);667logContext.setRequestInput(nextEditRequest);668logContext.setIsReusedInFlightResult(nextEditRequest.logContext);669670const disp = this._hookupCancellation(nextEditRequest, cancellationToken);671try {672return await nextEditRequest.result;673} finally {674disp.dispose();675}676}677678private _logRebaseFailure(failureInfo: MarkdownLoggable | undefined, logContext: InlineEditRequestLogContext): void {679if (failureInfo) {680logContext.setRebaseFailure(failureInfo);681}682}683684private async _executeNewNextEditRequest(685req: NextEditFetchRequest,686doc: IObservableDocument,687historyContext: HistoryContext,688nesConfigs: INesConfigs,689shouldExpandEditWindow: boolean,690parentLogger: ILogger,691telemetryBuilder: LlmNESTelemetryBuilder,692cancellationToken: CancellationToken693): Promise<{694nextEditRequest: StatelessNextEditRequest<CachedOrRebasedEdit>;695nextEditResult: StatelessNextEditResult;696}> {697const curDocId = doc.id;698const logger = parentLogger.createSubLogger('_executeNewNextEditRequest');699700const recording = this._debugRecorder?.getRecentLog();701702const logContext = req.log;703704const activeDocAndIdx = assertDefined(historyContext.getDocumentAndIdx(curDocId));705const activeDocSelection = doc.selection.get()[0] as OffsetRange | undefined;706707const projectedDocuments = historyContext.documents.map(doc => this._processDoc(doc));708709const xtabEditHistory = this._xtabHistoryTracker.getHistory();710711const firstEdit = new DeferredPromise<Result<CachedOrRebasedEdit, NoNextEditReason>>();712713const nLinesEditWindow = (shouldExpandEditWindow714? this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAutoExpandEditWindowLines, this._expService)715: undefined);716717const nextEditRequest = new StatelessNextEditRequest(718req.headerRequestId,719req.opportunityId,720doc.value.get(),721projectedDocuments.map(d => d.nextEditDoc),722activeDocAndIdx.idx,723xtabEditHistory,724firstEdit,725nLinesEditWindow,726false, // isSpeculative727logContext,728req.log.recordingBookmark,729recording,730req.providerRequestStartDateTime,731);732let nextEditResult: StatelessNextEditResult | undefined;733734if (this._pendingStatelessNextEditRequest) {735this._pendingStatelessNextEditRequest.cancellationTokenSource.cancel();736this._pendingStatelessNextEditRequest = null;737// Clear any scheduled (but not yet triggered) speculative request tied to the738// old stream — it would otherwise fire stale when the old stream's background739// loop calls handleStreamEnd after the stream has already been superseded.740this._specManager.clearScheduled();741}742743// Cancel speculative request if it doesn't match the document/state744// of this new request — it was built for a different document or post-edit state.745this._specManager.cancelIfMismatch(curDocId, nextEditRequest.documentBeforeEdits.value, SpeculativeCancelReason.Superseded);746747this._pendingStatelessNextEditRequest = nextEditRequest;748749const removeFromPending = () => {750if (this._pendingStatelessNextEditRequest === nextEditRequest) {751this._pendingStatelessNextEditRequest = null;752}753};754755telemetryBuilder.setRequest(nextEditRequest);756telemetryBuilder.setStatus('requested');757logContext.setRequestInput(nextEditRequest);758759// A note on cancellation:760//761// We don't cancel when the cancellation token is signalled, because we have our own762// separate cancellation logic which ends up cancelling based on documents changing.763//764// But we do cancel requests which didn't start yet if no-one really needs their result765//766const disp = this._hookupCancellation(nextEditRequest, cancellationToken, nesConfigs.isAsyncCompletions ? autorunWithChanges(this, {767value: doc.value,768}, data => {769data.value.changes.forEach(edit => {770if (nextEditRequest.intermediateUserEdit && !edit.isEmpty()) {771nextEditRequest.intermediateUserEdit = nextEditRequest.intermediateUserEdit.compose(edit);772if (!checkEditConsistency(nextEditRequest.documentBeforeEdits.value, nextEditRequest.intermediateUserEdit, data.value.value.value, logger)) {773nextEditRequest.intermediateUserEdit = undefined;774}775}776});777}) : undefined);778779780const statePerDoc = createDocStateLookupMap(projectedDocuments, xtabEditHistory);781782const editStream = this._statelessNextEditProvider.provideNextEdit(nextEditRequest, logger, logContext, nextEditRequest.cancellationTokenSource.token);783784let ithEdit = -1;785786const processEdit = (streamedEdit: { readonly edit: LineReplacement; readonly isFromCursorJump: boolean; readonly window?: OffsetRange; readonly originalWindow?: OffsetRange; readonly targetDocument?: DocumentId }, telemetry: IStatelessNextEditTelemetry): CachedOrRebasedEdit | undefined => {787++ithEdit;788const myLogger = logger.createSubLogger('processEdit');789myLogger.trace(`processing edit #${ithEdit} (starts at 0)`);790791// reset shouldExpandEditWindow to false when we get any edit792myLogger.trace('resetting shouldExpandEditWindow to false due to receiving an edit');793this._shouldExpandEditWindow = false;794795const targetDocState = statePerDoc.get(streamedEdit.targetDocument ?? curDocId);796797const singleLineEdit = streamedEdit.edit;798const lineEdit = new LineEdit([singleLineEdit]);799const edit = convertLineEditToEdit(lineEdit, targetDocState.baseDocState);800const rebasedEdit = edit.tryRebase(targetDocState.editsSoFar);801802if (rebasedEdit === undefined) {803myLogger.trace(`edit ${ithEdit} is undefined after rebasing`);804if (!firstEdit.isSettled) {805firstEdit.complete(Result.error(new NoNextEditReason.Uncategorized(new Error('Rebased edit is undefined'))));806}807return undefined;808}809810targetDocState.editsSoFar = targetDocState.editsSoFar.compose(rebasedEdit);811812let cachedEdit: CachedOrRebasedEdit | undefined;813if (rebasedEdit.replacements.length === 0) {814myLogger.trace(`WARNING: ${ithEdit} has no edits`);815} else if (rebasedEdit.replacements.length > 1) {816myLogger.trace(`WARNING: ${ithEdit} has ${rebasedEdit.replacements.length} edits, but expected only 1`);817} else {818// populate the cache819const nextEditReplacement = rebasedEdit.replacements[0];820targetDocState.nextEdits.push(nextEditReplacement);821cachedEdit = this._nextEditCache.setKthNextEdit(822targetDocState.docId,823targetDocState.docContents,824ithEdit === 0 ? streamedEdit.window : undefined,825nextEditReplacement,826ithEdit,827ithEdit === 0 ? targetDocState.nextEdits : undefined,828ithEdit === 0 ? nextEditRequest.intermediateUserEdit : undefined,829req,830{ isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === curDocId ? activeDocSelection?.start : undefined }831);832myLogger.trace(`populated cache for ${ithEdit}`);833}834835if (!firstEdit.isSettled) {836myLogger.trace('resolving firstEdit promise');837logContext.setResult(new RootedLineEdit(targetDocState.docContents, lineEdit)); // this's correct without rebasing because this's the first edit838firstEdit.complete(cachedEdit ? Result.ok(cachedEdit) : Result.error(new NoNextEditReason.Unexpected(new Error('No cached edit'))));839}840841targetDocState.docContents = rebasedEdit.applyOnText(targetDocState.docContents);842843return cachedEdit;844};845846const handleStreamEnd = (completionReason: NoNextEditReason, lastTelemetry: IStatelessNextEditTelemetry) => {847const myLogger = logger.createSubLogger('streamEnd');848849// if there was a request made, and it ended without any edits, reset shouldExpandEditWindow850const hadNoEdits = ithEdit === -1;851if (hadNoEdits && completionReason instanceof NoNextEditReason.NoSuggestions) {852myLogger.trace('resetting shouldExpandEditWindow to false due to NoSuggestions');853this._shouldExpandEditWindow = false;854}855856if (statePerDoc.get(curDocId).nextEdits.length) {857myLogger.trace(`${statePerDoc.get(curDocId).nextEdits.length} edits returned`);858} else {859myLogger.trace(`no edit, reason: ${completionReason.kind}`);860if (completionReason instanceof NoNextEditReason.NoSuggestions) {861const { documentBeforeEdits, window } = completionReason;862const reducedWindow = window ? computeReducedWindow(window, activeDocSelection, documentBeforeEdits) : undefined;863this._nextEditCache.setNoNextEdit(curDocId, documentBeforeEdits, reducedWindow, req);864}865}866867if (!firstEdit.isSettled) {868firstEdit.complete(Result.error(completionReason));869}870871const resultForTelemetry: Result<void, NoNextEditReason> = statePerDoc.get(curDocId).nextEdits.length > 0872? Result.ok(undefined)873: Result.error(completionReason);874const result = new StatelessNextEditResult(resultForTelemetry, lastTelemetry);875nextEditRequest.setResult(result);876877disp.dispose();878removeFromPending();879880// Fire any scheduled speculative request — the last shown edit881// was indeed the last edit from this stream.882const scheduled = this._specManager.consumeScheduled(nextEditRequest.headerRequestId);883if (scheduled) {884void this._triggerSpeculativeRequest(scheduled.suggestion);885}886887return result;888};889890try {891let res = await editStream.next();892893if (res.done) {894// Stream ended immediately without any edits895const completionReason = res.value.v;896nextEditResult = handleStreamEnd(completionReason, res.value.telemetryBuilder);897} else {898// Process first edit synchronously899const firstStreamedEdit = res.value.v;900const firstTelemetry = res.value.telemetryBuilder;901processEdit(firstStreamedEdit, firstTelemetry);902903// Continue streaming remaining edits in the background (unawaited)904(async () => {905try {906res = await editStream.next();907while (!res.done) {908const streamedEdit = res.value.v;909processEdit(streamedEdit, res.value.telemetryBuilder);910911// A new edit arrived from the stream — the previously-shown912// edit was not the last one. Clear the scheduled speculative.913this._specManager.consumeScheduled(nextEditRequest.headerRequestId);914915res = await editStream.next();916}917918// Stream completed919const completionReason = res.value.v;920handleStreamEnd(completionReason, res.value.telemetryBuilder);921} catch (err) {922logger.trace(`Error while streaming further edits: ${ErrorUtils.toString(err)}`);923const errorReason = new NoNextEditReason.Unexpected(ErrorUtils.fromUnknown(err));924handleStreamEnd(errorReason, firstTelemetry);925}926})();927928nextEditResult = new StatelessNextEditResult(Result.ok(undefined), firstTelemetry);929}930931} catch (err) {932nextEditRequest.setResultError(err);933throw err;934}935936return { nextEditRequest, nextEditResult };937}938939private _hookupCancellation(nextEditRequest: StatelessNextEditRequest, cancellationToken: CancellationToken, attachedDisposable?: IDisposable): IDisposable {940const disposables = new DisposableStore();941942let dependantRemoved = false;943const removeDependant = () => {944if (!dependantRemoved) {945dependantRemoved = true;946nextEditRequest.liveDependentants--;947}948};949950const cancellationTimer = disposables.add(new TimeoutTimer());951952disposables.add(cancellationToken.onCancellationRequested(() => {953removeDependant();954if (nextEditRequest.liveDependentants > 0) {955// there are others depending on this request956return;957}958if (!nextEditRequest.fetchIssued) {959// fetch not issued => cancel!960nextEditRequest.cancellationTokenSource.cancel();961attachedDisposable?.dispose();962return;963}964cancellationTimer.setIfNotSet(() => {965if (nextEditRequest.liveDependentants > 0) {966// there are others depending on this request967return;968}969nextEditRequest.cancellationTokenSource.cancel();970attachedDisposable?.dispose();971}, 1000); // This needs to be longer than the pause between two requests from Core otherwise we cancel running requests too early.972}));973974disposables.add(toDisposable(() => {975removeDependant();976if (nextEditRequest.liveDependentants === 0) {977attachedDisposable?.dispose();978}979}));980981nextEditRequest.liveDependentants++;982983return disposables;984}985986private computeMinimumResponseDelay({ triggerTime, isRebasedCachedEdit, isSubsequentCachedEdit, isFromSpeculativeRequest, enforceCacheDelay }: { triggerTime: number; isRebasedCachedEdit: boolean; isSubsequentCachedEdit: boolean; isFromSpeculativeRequest: boolean; enforceCacheDelay: boolean }, logger: ILogger): number {987988if (!enforceCacheDelay) {989logger.trace('[minimumDelay] no minimum delay enforced due to enforceCacheDelay being false');990return 0;991}992993const cacheDelay = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsCacheDelay, this._expService);994const rebasedCacheDelay = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsRebasedCacheDelay, this._expService);995const subsequentCacheDelay = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSubsequentCacheDelay, this._expService);996const speculativeRequestDelay = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestDelay, this._expService);997998let minimumResponseDelay = cacheDelay;999if (isRebasedCachedEdit && rebasedCacheDelay !== undefined) {1000minimumResponseDelay = rebasedCacheDelay;1001} else if (isSubsequentCachedEdit && subsequentCacheDelay !== undefined) {1002minimumResponseDelay = subsequentCacheDelay;1003} else if (isFromSpeculativeRequest && speculativeRequestDelay !== undefined) {1004minimumResponseDelay = speculativeRequestDelay;1005}10061007const nextEditProviderCallLatency = Date.now() - triggerTime;10081009// if the provider call took longer than the minimum delay, we don't need to delay further1010const delay = Math.max(0, minimumResponseDelay - nextEditProviderCallLatency);10111012logger.trace(`[minimumDelay] expected delay: ${minimumResponseDelay}ms, effective delay: ${delay}. isRebasedCachedEdit: ${isRebasedCachedEdit} (rebasedCacheDelay: ${rebasedCacheDelay}), isSubsequentCachedEdit: ${isSubsequentCachedEdit} (subsequentCacheDelay: ${subsequentCacheDelay}), isFromSpeculativeRequest: ${isFromSpeculativeRequest} (speculativeRequestDelay: ${speculativeRequestDelay})`);10131014return delay;1015}10161017public handleShown(suggestion: NextEditResult) {1018this._lastShownTime = Date.now();1019this._lastShownSuggestionId = suggestion.requestId;1020this._lastOutcome = undefined; // clear so that outcome is "pending" until resolved1021this._specManager.clearScheduled(); // clear any previously scheduled speculative10221023// Trigger speculative request for the post-edit document state1024const speculativeRequestsEnablement = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, this._expService);1025if (speculativeRequestsEnablement === SpeculativeRequestsEnablement.On) {1026// If the originating stream is still running, defer the speculative request1027// until the stream completes. If more edits come from this stream, the1028// schedule is cleared (the shown edit wasn't the last one). The speculative1029// request only fires when the stream ends with the shown edit as the last one.1030const originatingRequest = this._pendingStatelessNextEditRequest;1031if (originatingRequest && originatingRequest.headerRequestId === suggestion.source.headerRequestId) {1032this._specManager.schedule({1033suggestion,1034headerRequestId: originatingRequest.headerRequestId,1035});1036} else {1037void this._triggerSpeculativeRequest(suggestion);1038}1039}1040}10411042private async _triggerSpeculativeRequest(suggestion: NextEditResult): Promise<void> {1043const result = suggestion.result;1044if (!result?.edit) {1045return;1046}10471048const docId = result.targetDocumentId;1049if (!docId) {1050return;1051}10521053const logContext = new InlineEditRequestLogContext(docId.uri, 0, undefined);10541055const sw = new StopWatch();1056const logger = this._logger.createSubLogger('_triggerSpeculativeRequest')1057.withExtraTarget(LogTarget.fromCallback((_level, msg) => {1058logContext.trace(`[${Math.floor(sw.elapsed()).toString().padStart(4, ' ')}ms] ${msg}`);1059}));10601061// Compute the post-edit document content1062const postEditContent = result.edit.replace(result.documentBeforeEdits.value);1063const preciseEdit = result.edit.removeCommonSuffixPrefix(result.documentBeforeEdits.value);1064const postEditCursorOffset = preciseEdit.replaceRange.start + preciseEdit.newText.length;1065const postEditCursorOffsetRange = new OffsetRange(postEditCursorOffset, postEditCursorOffset);1066const selections = [postEditCursorOffsetRange];1067const rootedEdit = new RootedEdit(result.documentBeforeEdits, new StringEdit([result.edit]));10681069const postEditContentST = new StringText(postEditContent);1070let cachedEdit = this._nextEditCache.lookupNextEdit(docId, postEditContentST, selections);1071let shiftedSelection = postEditCursorOffsetRange;1072if (cachedEdit) {1073// first cachedEdit should be without edits because of noSuggestions caching1074if (cachedEdit.edit) {1075logger.trace('already have cached edit for post-edit state');1076return;1077} else if (cachedEdit.editWindow) {1078logger.trace('have cached no-suggestions entry for post-edit state, but it has an edit window. Checking if shifting selection based on cursor placement config can yield a cached edit');1079const cursorPlacement = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestsCursorPlacement, this._expService);1080if (cursorPlacement === SpeculativeRequestsCursorPlacement.AfterEditWindow) {1081logger.trace('cursor placement config is AfterEditWindow, shifting selection to after edit window');1082shiftedSelection = NextEditProvider.shiftSelectionAfterEditWindow(postEditContentST, cachedEdit.editWindow);1083cachedEdit = this._nextEditCache.lookupNextEdit(docId, postEditContentST, [shiftedSelection]);1084if (cachedEdit?.edit) {1085logger.trace('already have cached edit for post-edit state (after shifting selection)');1086return;1087} else {1088logger.trace('no cached edit even after shifting selection');1089}1090} else {1091logger.trace(`cursor placement config is ${cursorPlacement}, not shifting selection`);1092}1093} else {1094logger.trace('already have cached no-suggestions entry for post-edit state');1095return;1096}1097}10981099// Check if we already have a pending request for the post-edit state1100if (this._pendingStatelessNextEditRequest?.documentBeforeEdits.value === postEditContent) {1101logger.trace('already have pending request for post-edit state');1102return;1103}11041105// Check if we already have a speculative request for this post-edit state1106const existingSpec = this._specManager.pending;1107if (existingSpec?.docId === docId && existingSpec?.postEditContent === postEditContent) {1108logger.trace('already have speculative request for post-edit state');1109return;1110}11111112// Get the document to trigger speculative fetch1113// Note: targetDocumentId is defined when the suggestion targets a different document1114// Otherwise, use the file path from the log context1115const doc = this._workspace.getDocument(docId);1116if (!doc) {1117logger.trace('document not found for speculative request');1118return;1119}11201121// Note: any previous speculative request will be cancelled (as `Replaced`)1122// by `_specManager.setPending` once the new request is actually installed —1123// see the `setPending` call at the end of this method. We deliberately do1124// not cancel earlier so the prior speculative stays available for reuse1125// while the new one is being constructed.11261127const historyContext = this._historyContextProvider.getHistoryContext(docId);1128if (!historyContext) {1129logger.trace('no history context for speculative request');1130return;1131}11321133const req = new NextEditFetchRequest(`sp-${suggestion.source.opportunityId}`, logContext, undefined, true, `sp-${generateUuid()}`);11341135logger.trace(`triggering speculative request for post-edit state (opportunityId=${req.opportunityId}, headerRequestId=${req.headerRequestId})`);11361137try {1138const speculativeRequest = await this._createSpeculativeRequest(1139req,1140doc,1141shiftedSelection,1142historyContext,1143postEditContent,1144rootedEdit,1145result.edit,1146{1147triggeredBySpeculativeRequest: suggestion.source.isSpeculative,1148isSubsequentEdit: suggestion.result?.isSubsequentEdit ?? false,1149},1150logger1151);11521153if (speculativeRequest) {1154// Capture trajectory data: while the user is typing in `docId`, the1155// document is on a "type-through" trajectory iff:1156// doc = preEdit[0..editStart] + newText[0..k] + preEdit[editEnd..]1157// for some 0 <= k <= newText.length. Storing the prefix/suffix/newText1158// (already-CRLF-normalized via `result.edit.newText` whose newlines1159// match the original document) lets us check this in O(|cur|) on doc changes.1160const preEditValue = result.documentBeforeEdits.value;1161const trajectoryPrefix = preEditValue.slice(0, preciseEdit.replaceRange.start);1162const trajectorySuffix = preEditValue.slice(preciseEdit.replaceRange.endExclusive);1163const trajectoryNewText = preciseEdit.newText;1164this._specManager.setPending({1165request: speculativeRequest,1166docId,1167postEditContent,1168trajectoryPrefix,1169trajectorySuffix,1170trajectoryNewText,1171});1172}1173} catch (e) {1174logger.trace(`speculative request failed: ${ErrorUtils.toString(e)}`);1175}1176}11771178/**1179* Creates and starts a speculative request for the post-edit document state.1180* The request will populate the cache so that when the user accepts the suggestion,1181* the next NES request can reuse or find the result in cache.1182*/1183private async _createSpeculativeRequest(1184req: NextEditFetchRequest,1185doc: IObservableDocument,1186shiftedSelection: OffsetRange,1187historyContext: HistoryContext,1188postEditContent: string,1189rootedEdit: RootedEdit,1190appliedEdit: StringReplacement,1191{ triggeredBySpeculativeRequest, isSubsequentEdit }: { triggeredBySpeculativeRequest: boolean; isSubsequentEdit: boolean },1192parentLogger: ILogger1193): Promise<StatelessNextEditRequest<CachedOrRebasedEdit> | undefined> {1194const curDocId = doc.id;11951196const recording = this._debugRecorder?.getRecentLog();1197const logContext = req.log;1198logContext.setStatelessNextEditProviderId(this._statelessNextEditProvider.ID);11991200const logger = parentLogger.createSubLogger('_createSpeculativeRequest');12011202const activeDocAndIdx = historyContext.getDocumentAndIdx(curDocId);1203if (!activeDocAndIdx) {1204logger.trace('active doc not found in history context');1205return undefined;1206}12071208// Create the post-edit document content1209const postEditText = new StringText(postEditContent);12101211// Process documents, but for the active document, use the post-edit state1212const projectedDocuments: ProcessedDoc[] = historyContext.documents.map(docHist => {1213if (docHist.docId !== curDocId) {1214return this._processDoc(docHist);1215} else {1216// For the active document, create a version representing post-edit state1217// The "recent edit" from the model's perspective is the NES edit we just applied1218const workspaceRoot = this._workspace.getWorkspaceRoot(curDocId);1219const postEditEdit = new StringEdit([appliedEdit]);1220const postEditLineEdit = RootedLineEdit.fromEdit(new RootedEdit(doc.value.get(), postEditEdit)).removeCommonSuffixPrefixLines().edit;12211222const nextEditDoc = new StatelessNextEditDocument(1223curDocId,1224workspaceRoot,1225docHist.languageId,1226doc.value.get().getLines(), // lines before the NES edit1227postEditLineEdit, // the NES edit as LineEdit1228doc.value.get(), // document before NES edit1229Edits.single(postEditEdit), // the NES edit as Edits1230shiftedSelection,1231);12321233return {1234recentEdit: new RootedEdit(doc.value.get(), postEditEdit),1235nextEditDoc,1236documentAfterEdits: postEditText,1237};1238}1239});12401241const xtabEditHistory = this._xtabHistoryTracker.getHistory();1242const suggestedEdit: IXtabHistoryEditEntry = { kind: 'edit', docId: curDocId, edit: rootedEdit };1243xtabEditHistory.push(suggestedEdit);12441245const firstEdit = new DeferredPromise<Result<CachedOrRebasedEdit, NoNextEditReason>>();12461247// FIXME@ulugbekna: implement advanced expansion1248const autoExpandEditWindowLinesSetting = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestsAutoExpandEditWindowLines, this._expService);1249let nLinesEditWindow: number | undefined;1250switch (autoExpandEditWindowLinesSetting) {1251case SpeculativeRequestsAutoExpandEditWindowLines.Off:1252nLinesEditWindow = undefined;1253break;1254case SpeculativeRequestsAutoExpandEditWindowLines.Always:1255nLinesEditWindow = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAutoExpandEditWindowLines, this._expService);1256break;1257case SpeculativeRequestsAutoExpandEditWindowLines.Smart: {1258const isModelOnRightTrack = triggeredBySpeculativeRequest || isSubsequentEdit;1259nLinesEditWindow = (isModelOnRightTrack1260? this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAutoExpandEditWindowLines, this._expService)1261: undefined);1262break;1263}1264default:1265assertNever(autoExpandEditWindowLinesSetting);1266}12671268const nextEditRequest = new StatelessNextEditRequest(1269req.headerRequestId,1270req.opportunityId,1271postEditText, // documentBeforeEdits is the post-edit state1272projectedDocuments.map(d => d.nextEditDoc),1273activeDocAndIdx.idx,1274xtabEditHistory,1275firstEdit,1276nLinesEditWindow,1277true, // isSpeculative1278logContext,1279undefined, // recordingBookmark1280recording,1281undefined, // providerRequestStartDateTime1282);12831284logContext.setRequestInput(nextEditRequest);12851286logger.trace('starting speculative provider call');12871288// Start the provider call - this runs in the background and populates the cache1289const label = `NES | spec | ${basename(doc.id.toUri().fsPath)} (v${doc.version.get()})`;12901291const capturingToken = new CapturingToken(label, undefined);12921293void this._requestLogger.captureInvocation(capturingToken, async () => {1294this._addLiveLogContextEntry(logContext, label);1295try {1296await this._runSpeculativeProviderCall(nextEditRequest, projectedDocuments, curDocId, req, shiftedSelection.start, logger);1297} catch (e) {1298logContext.setError(e);1299} finally {1300logContext.markCompleted();1301}1302});13031304return nextEditRequest;1305}13061307/**1308* Runs the provider call for a speculative request and caches results.1309*/1310private async _runSpeculativeProviderCall(1311nextEditRequest: StatelessNextEditRequest<CachedOrRebasedEdit>,1312projectedDocuments: readonly ProcessedDoc[],1313curDocId: DocumentId,1314req: NextEditFetchRequest,1315cursorOffset: number,1316parentLogger: ILogger1317): Promise<void> {1318const logger = parentLogger.createSubLogger('_runSpeculativeProviderCall');13191320const xtabEditHistory = nextEditRequest.xtabEditHistory;13211322const statePerDoc = createDocStateLookupMap(projectedDocuments, xtabEditHistory);13231324const logContext = req.log;1325const editStream = this._statelessNextEditProvider.provideNextEdit(1326nextEditRequest,1327logger,1328logContext,1329nextEditRequest.cancellationTokenSource.token1330);13311332let ithEdit = -1;13331334try {1335let res = await editStream.next();13361337if (res.done) {1338nextEditRequest.firstEdit.complete(Result.error(res.value.v));1339nextEditRequest.setResult(new StatelessNextEditResult(1340Result.error(res.value.v),1341res.value.telemetryBuilder1342));1343logContext.markAsNoSuggestions();1344logger.trace('speculative request completed with no edits');1345} else {13461347(async () => {1348while (!res.done) {1349++ithEdit;1350const streamedEdit = res.value.v;13511352const targetDocState = statePerDoc.get(streamedEdit.targetDocument ?? curDocId);13531354const singleLineEdit = streamedEdit.edit;1355const lineEdit = new LineEdit([singleLineEdit]);1356const edit = convertLineEditToEdit(lineEdit, targetDocState.baseDocState);1357const rebasedEdit = edit.tryRebase(targetDocState.editsSoFar);13581359if (rebasedEdit === undefined) {1360logger.trace(`speculative edit ${ithEdit} rebasing failed`);1361res = await editStream.next();1362continue;1363}13641365targetDocState.editsSoFar = targetDocState.editsSoFar.compose(rebasedEdit);13661367if (rebasedEdit.replacements.length === 1) {1368const nextEditReplacement = rebasedEdit.replacements[0];1369targetDocState.nextEdits.push(nextEditReplacement);13701371// Populate the cache with the speculative result1372const cachedEdit = this._nextEditCache.setKthNextEdit(1373targetDocState.docId,1374targetDocState.docContents,1375ithEdit === 0 ? streamedEdit.window : undefined,1376nextEditReplacement,1377ithEdit,1378ithEdit === 0 ? targetDocState.nextEdits : undefined,1379undefined, // no userEditSince for speculative1380req,1381{ isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === curDocId ? cursorOffset : undefined }1382);13831384if (!nextEditRequest.firstEdit.isSettled && cachedEdit) {1385nextEditRequest.firstEdit.complete(Result.ok(cachedEdit));1386nextEditRequest.setResult(1387new StatelessNextEditResult(1388Result.ok(undefined),1389res.value.telemetryBuilder1390)1391);1392logContext.setResponseResults([nextEditReplacement]);1393}13941395logger.trace(`cached speculative edit ${ithEdit}`);1396}13971398targetDocState.docContents = rebasedEdit.applyOnText(targetDocState.docContents);13991400res = await editStream.next();1401}1402})().finally(() => {1403if (!nextEditRequest.firstEdit.isSettled) {1404nextEditRequest.firstEdit.complete(Result.error(new NoNextEditReason.Uncategorized(new Error('Speculative request ended without edits'))));1405nextEditRequest.setResult(1406new StatelessNextEditResult(1407Result.error(new NoNextEditReason.Uncategorized(new Error('Speculative request ended without edits'))),1408res.value.telemetryBuilder1409)1410);1411logContext.markAsNoSuggestions();1412}1413});1414}14151416logger.trace(`speculative request completed with ${ithEdit + 1} edits`);1417} catch (e) {1418logger.trace(`speculative provider call error: ${ErrorUtils.toString(e)}`);1419}1420}14211422private static shiftSelectionAfterEditWindow(postEditContentST: StringText, editWindowOffsetRange: OffsetRange): OffsetRange {1423const trans = postEditContentST.getTransformer();1424const endOfEditWindow = trans.getPosition(editWindowOffsetRange.endExclusive - 1);1425const shiftedCursorLineNumber = (endOfEditWindow.lineNumber + 1 < postEditContentST.lineRange.endLineNumberExclusive1426? endOfEditWindow.lineNumber + 11427: endOfEditWindow.lineNumber);1428const shiftedSelectionCursorOffset = trans.getOffset(new Position(shiftedCursorLineNumber, 1));1429const shiftedSelection = new OffsetRange(shiftedSelectionCursorOffset, shiftedSelectionCursorOffset);1430return shiftedSelection;1431}14321433public handleAcceptance(docId: DocumentId, suggestion: NextEditResult) {1434this.runSnippy(docId, suggestion);1435this._statelessNextEditProvider.handleAcceptance?.();1436this._lastOutcome = NesOutcome.Accepted;14371438const logger = this._logger.createSubLogger(suggestion.source.opportunityId.substring(4, 8)).createSubLogger('handleAcceptance');1439if (suggestion === this._lastNextEditResult) {1440logger.trace('setting shouldExpandEditWindow to true due to acceptance of last suggestion');1441this._shouldExpandEditWindow = true;1442} else {1443logger.trace('NOT setting shouldExpandEditWindow to true because suggestion is not the last suggestion');1444}1445}14461447public handleRejection(docId: DocumentId, suggestion: NextEditResult) {1448assertType(suggestion.result, '@ulugbekna: undefined edit cannot be rejected?');14491450// The user rejected the suggestion, so the speculative request (which1451// predicted the post-accept state) will never be reused. Cancel it to1452// avoid wasting a server slot.1453this._specManager.cancelAll(SpeculativeCancelReason.Rejected);14541455const shownDuration = Date.now() - this._lastShownTime;1456if (shownDuration > 1000 && suggestion.result.edit) {1457// we can argue that the user had the time to review this1458// so it wasn't an accidental rejection1459this._rejectionCollector.reject(docId, suggestion.result.edit);1460this._nextEditCache.rejectedNextEdit(suggestion.source.headerRequestId);1461}14621463this._lastRejectionTime = Date.now();1464this._lastOutcome = NesOutcome.Rejected;14651466this._statelessNextEditProvider.handleRejection?.();1467}14681469public handleIgnored(docId: DocumentId, suggestion: NextEditResult, supersededBy: INextEditResult | undefined): void {1470this._lastOutcome = NesOutcome.Ignored;14711472// Check if this was the last shown suggestion1473const wasShown = this._lastShownSuggestionId === suggestion.requestId;1474const wasSuperseded = supersededBy !== undefined;1475if (wasShown && !wasSuperseded) {1476// The shown suggestion was dismissed (not superseded by a new one),1477// so the speculative request for its post-accept state is useless.1478this._specManager.cancelAll(SpeculativeCancelReason.IgnoredDismissed);1479this._statelessNextEditProvider.handleIgnored?.();1480}1481// Note: the superseded case is intentionally NOT handled here. The trajectory1482// check on `_specManager.onActiveDocumentChanged` already cancels the1483// speculative iff the user's edit moved off the type-through trajectory; if1484// the new (superseding) suggestion is just a continuation of the old one1485// (e.g. typed `i` while `ibonacci` was shown → now `bonacci` is shown), the1486// speculative's `postEditContent` is still the right bet and we keep it.1487}14881489private async runSnippy(docId: DocumentId, suggestion: NextEditResult) {1490if (suggestion.result === undefined || suggestion.result.edit === undefined) {1491return;1492}1493this._snippyService.handlePostInsertion(docId.toUri(), suggestion.result.documentBeforeEdits, suggestion.result.edit);1494}14951496private _addLiveLogContextEntry(logContext: InlineEditRequestLogContext, debugNameOverride?: string): void {1497this._requestLogger.addEntry({1498type: LoggedRequestKind.MarkdownContentRequest,1499debugName: debugNameOverride ?? logContext.getDebugName(),1500icon: () => logContext.getIcon(),1501startTimeMs: logContext.time,1502markdownContent: () => logContext.toLogDocument(),1503onDidChange: logContext.onDidChange,1504isVisible: () => logContext.includeInLogTree,1505});1506}15071508public clearCache() {1509this._nextEditCache.clear();1510this._rejectionCollector.clear();1511// Any in-flight speculative would land its result into a cache that's1512// meant to be empty (and may be based on a now-stale model/auth/prompt).1513this._specManager.cancelAll(SpeculativeCancelReason.CacheCleared);1514}1515}15161517function assertDefined<T>(value: T | undefined): T {1518if (!value) {1519throw new BugIndicatingError('expected value to be defined, but it was not');1520}1521return value;1522}15231524export class NextEditFetchRequest {1525constructor(1526public readonly opportunityId: string,1527public readonly log: InlineEditRequestLogContext,1528public readonly providerRequestStartDateTime: number | undefined,1529public readonly isSpeculative: boolean,1530public readonly headerRequestId = generateUuid(),1531) {1532}1533}153415351536