Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { basename } from 'path';
7
import type * as vscode from 'vscode';
8
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
9
import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';
10
import { Edits, RootedEdit } from '../../../platform/inlineEdits/common/dataTypes/edit';
11
import { RootedLineEdit } from '../../../platform/inlineEdits/common/dataTypes/rootedLineEdit';
12
import { SpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsCursorPlacement, SpeculativeRequestsEnablement } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';
13
import { InlineEditRequestLogContext, type MarkdownLoggable } from '../../../platform/inlineEdits/common/inlineEditLogContext';
14
import { IObservableDocument, ObservableWorkspace } from '../../../platform/inlineEdits/common/observableWorkspace';
15
import { IStatelessNextEditProvider, IStatelessNextEditTelemetry, NoNextEditReason, StatelessNextEditDocument, StatelessNextEditRequest, StatelessNextEditResult } from '../../../platform/inlineEdits/common/statelessNextEditProvider';
16
import { autorunWithChanges } from '../../../platform/inlineEdits/common/utils/observable';
17
import { DocumentHistory, HistoryContext, IHistoryContextProvider } from '../../../platform/inlineEdits/common/workspaceEditTracker/historyContextProvider';
18
import { IXtabHistoryEditEntry, IXtabHistoryEntry, NesXtabHistoryTracker } from '../../../platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker';
19
import { ILogger, ILogService, LogTarget } from '../../../platform/log/common/logService';
20
import { CapturingToken } from '../../../platform/requestLogger/common/capturingToken';
21
import { IRequestLogger, LoggedRequestKind } from '../../../platform/requestLogger/common/requestLogger';
22
import { ISnippyService } from '../../../platform/snippy/common/snippyService';
23
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
24
import { ErrorUtils } from '../../../util/common/errors';
25
import { Result } from '../../../util/common/result';
26
import { assert, assertNever } from '../../../util/vs/base/common/assert';
27
import { DeferredPromise, timeout, TimeoutTimer } from '../../../util/vs/base/common/async';
28
import { CachedFunction } from '../../../util/vs/base/common/cache';
29
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
30
import { BugIndicatingError } from '../../../util/vs/base/common/errors';
31
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../util/vs/base/common/lifecycle';
32
import { mapObservableArrayCached, runOnChange } from '../../../util/vs/base/common/observable';
33
import { StopWatch } from '../../../util/vs/base/common/stopwatch';
34
import { assertType } from '../../../util/vs/base/common/types';
35
import { generateUuid } from '../../../util/vs/base/common/uuid';
36
import { LineEdit, LineReplacement } from '../../../util/vs/editor/common/core/edits/lineEdit';
37
import { StringEdit, StringReplacement } from '../../../util/vs/editor/common/core/edits/stringEdit';
38
import { Position } from '../../../util/vs/editor/common/core/position';
39
import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';
40
import { StringText } from '../../../util/vs/editor/common/core/text/abstractText';
41
import { checkEditConsistency } from '../common/editRebase';
42
import { NesChangeHint } from '../common/nesTriggerHint';
43
import { RejectionCollector } from '../common/rejectionCollector';
44
import { DebugRecorder } from './debugRecorder';
45
import { INesConfigs } from './nesConfigs';
46
import { CachedEdit, CachedOrRebasedEdit, NextEditCache } from './nextEditCache';
47
import { LlmNESTelemetryBuilder, ReusedRequestKind } from './nextEditProviderTelemetry';
48
import { INextEditResult, NextEditResult } from './nextEditResult';
49
import { SpeculativeCancelReason, SpeculativeRequestManager } from './speculativeRequestManager';
50
51
/**
52
* Computes a reduced window range that encompasses both the original window (shrunk by one line
53
* on each end) and the full line where the cursor is located.
54
*
55
* This ensures the cache invalidation window always includes the cursor's line while trimming
56
* the edges of the original window.
57
*/
58
function computeReducedWindow(
59
window: OffsetRange,
60
activeDocSelection: OffsetRange | undefined,
61
documentBeforeEdits: StringText
62
): OffsetRange {
63
if (!activeDocSelection) {
64
return window;
65
}
66
const cursorOffset = activeDocSelection.endExclusive;
67
const t = documentBeforeEdits.getTransformer();
68
const cursorPosition = t.getPosition(cursorOffset);
69
const lineOffset = t.getOffset(cursorPosition.with(undefined, 1));
70
const lineEndOffset = t.getOffset(cursorPosition.with(undefined, t.getLineLength(cursorPosition.lineNumber) + 1));
71
const reducedOffset = t.getOffset(t.getPosition(window.start).delta(1));
72
const reducedEndPosition = t.getPosition(window.endExclusive).delta(-2);
73
const reducedEndOffset = t.getOffset(reducedEndPosition.column > 1 ? reducedEndPosition.with(undefined, t.getLineLength(reducedEndPosition.lineNumber) + 1) : reducedEndPosition);
74
return new OffsetRange(
75
Math.min(reducedOffset, lineOffset),
76
Math.max(reducedEndOffset, lineEndOffset)
77
);
78
}
79
80
function convertLineEditToEdit(nextLineEdit: LineEdit, document: StringText): StringEdit {
81
const rootedLineEdit = new RootedLineEdit(document, nextLineEdit);
82
const suggestedEdit = rootedLineEdit.toEdit();
83
// LineReplacement.toSingleTextEdit always joins newLines with '\n'.
84
// If the document uses '\r\n' line endings, we need to match that in
85
// the replacement text so that applying the edit produces consistent
86
// line endings and the resulting content matches what VS Code reports.
87
if (document.value.includes('\r\n')) {
88
return new StringEdit(suggestedEdit.replacements.map(
89
r => new StringReplacement(r.replaceRange, r.newText.replace(/\n/g, '\r\n'))
90
));
91
}
92
return suggestedEdit;
93
}
94
95
function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xtabEditHistory: readonly IXtabHistoryEntry[]): CachedFunction<DocumentId, {
96
baseDocState: StringText;
97
docContents: StringText;
98
editsSoFar: StringEdit;
99
nextEdits: StringReplacement[];
100
docId: DocumentId;
101
}> {
102
const statePerDoc = new CachedFunction((id: DocumentId) => {
103
const doc = projectedDocuments.find(d => d.nextEditDoc.id === id);
104
if (!doc) {
105
for (let i = xtabEditHistory.length - 1; i >= 0; i--) {
106
const entry = xtabEditHistory[i];
107
if (entry.docId === id && entry.kind === 'edit') {
108
const baseDocState = entry.edit.getEditedState();
109
return {
110
baseDocState,
111
docContents: baseDocState,
112
editsSoFar: StringEdit.empty,
113
nextEdits: [] as StringReplacement[],
114
docId: id,
115
};
116
}
117
}
118
throw new BugIndicatingError();
119
}
120
return {
121
baseDocState: doc.documentAfterEdits,
122
docContents: doc.documentAfterEdits,
123
editsSoFar: StringEdit.empty,
124
nextEdits: [] as StringReplacement[],
125
docId: id,
126
};
127
});
128
129
130
return statePerDoc;
131
}
132
133
export interface NESInlineCompletionContext extends vscode.InlineCompletionContext {
134
enforceCacheDelay: boolean;
135
changeHint?: NesChangeHint;
136
}
137
138
export enum NesOutcome {
139
Accepted = 'accepted',
140
Rejected = 'rejected',
141
Ignored = 'ignored',
142
}
143
144
export interface INextEditProvider<T extends INextEditResult, TTelemetry, TData = void> extends IDisposable {
145
readonly ID: string;
146
getNextEdit(docId: DocumentId, context: NESInlineCompletionContext, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken, telemetryBuilder: TTelemetry, data?: TData): Promise<T>;
147
handleShown(suggestion: T): void;
148
handleAcceptance(docId: DocumentId, suggestion: T): void;
149
handleRejection(docId: DocumentId, suggestion: T): void;
150
handleIgnored(docId: DocumentId, suggestion: T, supersededBy: INextEditResult | undefined): void;
151
lastRejectionTime: number;
152
lastTriggerTime: number;
153
lastOutcome: NesOutcome | undefined;
154
}
155
156
interface ProcessedDoc {
157
recentEdit: RootedEdit<StringEdit>;
158
nextEditDoc: StatelessNextEditDocument;
159
documentAfterEdits: StringText;
160
}
161
162
export class NextEditProvider extends Disposable implements INextEditProvider<NextEditResult, LlmNESTelemetryBuilder> {
163
164
public readonly ID = this._statelessNextEditProvider.ID;
165
166
private readonly _rejectionCollector = this._register(new RejectionCollector(this._workspace, this._logService));
167
private readonly _nextEditCache: NextEditCache;
168
169
private _pendingStatelessNextEditRequest: StatelessNextEditRequest<CachedOrRebasedEdit> | null = null;
170
171
private readonly _specManager: SpeculativeRequestManager;
172
173
private _lastShownTime = 0;
174
/** The requestId of the last shown suggestion. We store only the requestId (not the object) to avoid preventing garbage collection. */
175
private _lastShownSuggestionId: number | undefined = undefined;
176
177
private _lastRejectionTime = 0;
178
public get lastRejectionTime() {
179
return this._lastRejectionTime;
180
}
181
182
private _lastTriggerTime = 0;
183
public get lastTriggerTime() {
184
return this._lastTriggerTime;
185
}
186
187
private _lastOutcome: NesOutcome | undefined;
188
public get lastOutcome() {
189
return this._lastOutcome;
190
}
191
192
private _lastNextEditResult: NextEditResult | undefined;
193
private _shouldExpandEditWindow = false;
194
195
private _logger: ILogger;
196
197
constructor(
198
private readonly _workspace: ObservableWorkspace,
199
private readonly _statelessNextEditProvider: IStatelessNextEditProvider,
200
private readonly _historyContextProvider: IHistoryContextProvider,
201
private readonly _xtabHistoryTracker: NesXtabHistoryTracker,
202
private readonly _debugRecorder: DebugRecorder | undefined,
203
@IConfigurationService private readonly _configService: IConfigurationService,
204
@ISnippyService private readonly _snippyService: ISnippyService,
205
@ILogService private readonly _logService: ILogService,
206
@IExperimentationService private readonly _expService: IExperimentationService,
207
@IRequestLogger private readonly _requestLogger: IRequestLogger,
208
) {
209
super();
210
211
this._logger = this._logService.createSubLogger(['NES', 'NextEditProvider']);
212
this._nextEditCache = new NextEditCache(this._workspace, this._logService, this._configService, this._expService);
213
this._specManager = this._register(new SpeculativeRequestManager(this._logger.createSubLogger('SpeculativeRequestManager')));
214
215
mapObservableArrayCached(this, this._workspace.openDocuments, (doc, store) => {
216
store.add(runOnChange(doc.value, (value) => {
217
this._cancelPendingRequestDueToDocChange(doc.id, value);
218
// FIXME: don't invoke before fixing false positive cancellations
219
// this._specManager.onActiveDocumentChanged(doc.id, value.value);
220
}));
221
// When the per-doc store is disposed, the document was removed from
222
// openDocuments. Cancel any speculative targeting it — its cached result
223
// would never be hit again.
224
store.add(toDisposable(() => this._specManager.onDocumentClosed(doc.id)));
225
}).recomputeInitiallyAndOnChange(this._store);
226
}
227
228
/**
229
* Cancels the in-flight stateless next-edit request when the document it
230
* was issued for has diverged from the request's expected post-edit state.
231
*
232
* Invoked from the per-document `runOnChange` autorun in the constructor
233
* whenever an open document's value changes. The pending request was built
234
* against a specific snapshot (`documentAfterEdits`); if the user has since
235
* typed something that makes the current value differ from that snapshot,
236
* the result would no longer be applicable and is cancelled eagerly.
237
*
238
* Skipped when:
239
* - the `InlineEditsAsyncCompletions` experiment is enabled (that path
240
* tolerates divergence and rebases later), or
241
* - there is no pending request, or
242
* - the changed document is not the one the pending request targets.
243
*
244
* Note: this only handles the regular pending stateless request. Speculative
245
* requests have their own divergence handling via
246
* `SpeculativeRequestManager.onActiveDocumentChanged` (trajectory check).
247
*/
248
private _cancelPendingRequestDueToDocChange(docId: DocumentId, docValue: StringText) {
249
const isAsyncCompletions = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAsyncCompletions, this._expService);
250
251
if (isAsyncCompletions || this._pendingStatelessNextEditRequest === null) {
252
return;
253
}
254
255
const activeDoc = this._pendingStatelessNextEditRequest.getActiveDocument();
256
if (activeDoc.id === docId && activeDoc.documentAfterEdits.value !== docValue.value) {
257
this._pendingStatelessNextEditRequest.cancellationTokenSource.cancel();
258
}
259
}
260
261
public async getNextEdit(
262
docId: DocumentId,
263
context: NESInlineCompletionContext,
264
logContext: InlineEditRequestLogContext,
265
cancellationToken: CancellationToken,
266
telemetryBuilder: LlmNESTelemetryBuilder
267
): Promise<NextEditResult> {
268
const now = Date.now();
269
270
this._lastTriggerTime = now;
271
272
const sw = new StopWatch();
273
274
const logger = this._logger.createSubLogger(context.requestUuid.substring(4, 8))
275
.withExtraTarget(LogTarget.fromCallback((_level, msg) => {
276
logContext.trace(`[${Math.floor(sw.elapsed()).toString().padStart(4, ' ')}ms] ${msg}`);
277
}));
278
279
const shouldExpandEditWindow = this._shouldExpandEditWindow;
280
281
logContext.setStatelessNextEditProviderId(this._statelessNextEditProvider.ID);
282
283
let result: NextEditResult;
284
try {
285
result = await this._getNextEditCanThrow(docId, context, now, shouldExpandEditWindow, logger, logContext, cancellationToken, telemetryBuilder);
286
} catch (error) {
287
logContext.setError(error);
288
telemetryBuilder.setNextEditProviderError(ErrorUtils.toString(error));
289
throw error;
290
} finally {
291
telemetryBuilder.markEndTime();
292
}
293
294
this._lastNextEditResult = result;
295
296
return result;
297
}
298
299
private async _getNextEditCanThrow(
300
docId: DocumentId,
301
context: NESInlineCompletionContext,
302
triggerTime: number,
303
shouldExpandEditWindow: boolean,
304
parentLogger: ILogger,
305
logContext: InlineEditRequestLogContext,
306
cancellationToken: CancellationToken,
307
telemetryBuilder: LlmNESTelemetryBuilder
308
): Promise<NextEditResult> {
309
310
const logger = parentLogger.createSubLogger('_getNextEdit');
311
logger.trace(`invoked with trigger id = ${context.changeHint === undefined ? 'undefined' : `uuid = ${context.changeHint.data.uuid}, reason = ${context.changeHint.data.reason}`}`);
312
313
const doc = this._workspace.getDocument(docId);
314
if (!doc) {
315
logger.trace(`Document "${docId.baseName}" not found`);
316
throw new BugIndicatingError(`Document "${docId.baseName}" not found`);
317
}
318
319
const documentAtInvocationTime = doc.value.get();
320
const selections = doc.selection.get();
321
322
const nesConfigs = this.determineNesConfigs(telemetryBuilder, logContext);
323
324
const cachedEdit = this._nextEditCache.lookupNextEdit(docId, documentAtInvocationTime, selections);
325
if (cachedEdit?.rejected) {
326
logger.trace('cached edit was previously rejected');
327
telemetryBuilder.setStatus('previouslyRejectedCache');
328
telemetryBuilder.setWasPreviouslyRejected();
329
logContext.markAsPreviouslyRejected();
330
const rejectedEdit = cachedEdit.rebasedEdit ?? cachedEdit.edit;
331
if (rejectedEdit) {
332
this._rejectionCollector.reject(docId, rejectedEdit);
333
}
334
const nextEditResult = new NextEditResult(logContext.requestId, cachedEdit.source, undefined);
335
return nextEditResult;
336
}
337
338
let edit: { actualEdit: StringReplacement; isFromCursorJump: boolean } | undefined;
339
let currentDocument: StringText | undefined;
340
let error: NoNextEditReason | undefined;
341
let req: NextEditFetchRequest;
342
let targetDocumentId = docId;
343
344
let isRebasedCachedEdit = false;
345
let isSubsequentCachedEdit = false;
346
let isFromSpeculativeRequest = false;
347
let cacheEntry: CachedEdit | undefined;
348
349
if (cachedEdit) {
350
logger.trace('using cached edit');
351
const actualEdit = cachedEdit.rebasedEdit || cachedEdit.edit;
352
if (actualEdit) {
353
edit = { actualEdit, isFromCursorJump: cachedEdit.isFromCursorJump };
354
}
355
isRebasedCachedEdit = !!cachedEdit.rebasedEdit;
356
isSubsequentCachedEdit = cachedEdit.subsequentN !== undefined && cachedEdit.subsequentN > 0;
357
isFromSpeculativeRequest = cachedEdit.source.isSpeculative;
358
req = cachedEdit.source;
359
logContext.setIsCachedResult(cachedEdit.source.log);
360
currentDocument = documentAtInvocationTime;
361
telemetryBuilder.setHeaderRequestId(req.headerRequestId);
362
telemetryBuilder.setIsFromCache();
363
telemetryBuilder.setSubsequentEditOrder(cachedEdit.rebasedEditIndex ?? cachedEdit.subsequentN);
364
// back-date the recording bookmark of the cached edit to the bookmark of the original request.
365
logContext.recordingBookmark = req.log.recordingBookmark;
366
cacheEntry = cachedEdit.baseCacheEntry ?? cachedEdit;
367
368
} else {
369
logger.trace(`fetching next edit with shouldExpandEditWindow=${shouldExpandEditWindow}`);
370
const providerRequestStartDateTime = (this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsDebounceUseCoreRequestTime, this._expService)
371
? (context.requestIssuedDateTime ?? undefined)
372
: undefined);
373
req = new NextEditFetchRequest(context.requestUuid, logContext, providerRequestStartDateTime, false);
374
telemetryBuilder.setHeaderRequestId(req.headerRequestId);
375
376
const startVersion = doc.value.get();
377
logger.trace('awaiting firstEdit promise');
378
const result = await this.fetchNextEdit(req, doc, nesConfigs, shouldExpandEditWindow, logger, telemetryBuilder, cancellationToken);
379
logger.trace('resolved firstEdit promise');
380
const latency = `First edit latency: ${Date.now() - this._lastTriggerTime} ms`;
381
logContext.addLog(latency);
382
logger.trace(latency);
383
384
if (result.isError()) {
385
logger.trace(`failed to fetch next edit ${result.err.toString()}`);
386
telemetryBuilder.setStatus(`noEdit:${result.err.kind}`);
387
error = result.err;
388
} else {
389
targetDocumentId = result.val.docId ?? targetDocumentId;
390
const targetDoc = targetDocumentId ? this._workspace.getDocument(targetDocumentId)! : doc;
391
currentDocument = targetDoc.value.get();
392
const docDidChange = targetDocumentId === doc.id && startVersion.value !== currentDocument.value;
393
394
if (docDidChange) {
395
logger.trace('document changed while fetching next edit');
396
telemetryBuilder.setStatus('docChanged');
397
logContext.setIsSkipped();
398
} else {
399
const suggestedNextEdit = result.val.rebasedEdit || result.val.edit;
400
if (!suggestedNextEdit) {
401
logger.trace('empty edits');
402
telemetryBuilder.setStatus('emptyEdits');
403
} else {
404
logger.trace('fetch succeeded');
405
logContext.setResponseResults([suggestedNextEdit]); // TODO: other streamed edits?
406
edit = { actualEdit: suggestedNextEdit, isFromCursorJump: result.val.isFromCursorJump };
407
isFromSpeculativeRequest = result.val.isFromSpeculativeRequest ?? false;
408
cacheEntry = result.val.baseCacheEntry ?? result.val;
409
}
410
}
411
}
412
}
413
414
if (error instanceof NoNextEditReason.FetchFailure || error instanceof NoNextEditReason.Unexpected) {
415
logger.trace(`has throwing error: ${error.error}`);
416
throw error.error;
417
} else if (error instanceof NoNextEditReason.NoSuggestions) {
418
if (error.nextCursorPosition === undefined) {
419
logContext.markAsNoSuggestions();
420
} else {
421
telemetryBuilder.setStatus('emptyEditsButHasNextCursorPosition');
422
return new NextEditResult(logContext.requestId, req, { jumpToPosition: error.nextCursorPosition, targetDocumentId: error.nextCursorDocumentId, documentBeforeEdits: documentAtInvocationTime, isFromCursorJump: false, isSubsequentEdit: false });
423
}
424
} else if (error instanceof NoNextEditReason.GotCancelled) {
425
logContext.setIsSkipped();
426
}
427
428
const emptyResult = new NextEditResult(logContext.requestId, req, undefined);
429
430
if (!edit) {
431
logger.trace('had no edit');
432
// telemetry builder status must've been set earlier
433
return emptyResult;
434
}
435
436
if (cancellationToken.isCancellationRequested) {
437
logger.trace('cancelled');
438
telemetryBuilder.setStatus(`noEdit:gotCancelled`);
439
return emptyResult;
440
}
441
442
if (this._rejectionCollector.isRejected(targetDocumentId, edit.actualEdit) || currentDocument && this._nextEditCache.isRejectedNextEdit(targetDocumentId, currentDocument, edit.actualEdit)) {
443
logger.trace('edit was previously rejected');
444
telemetryBuilder.setStatus('previouslyRejected');
445
telemetryBuilder.setWasPreviouslyRejected();
446
logContext.markAsPreviouslyRejected();
447
return emptyResult;
448
}
449
450
logContext.setResult(RootedLineEdit.fromEdit(new RootedEdit(documentAtInvocationTime, new StringEdit([edit.actualEdit]))));
451
452
assert(currentDocument !== undefined, 'should be defined if edit is defined');
453
454
telemetryBuilder.setStatus('notAccepted'); // Acceptance pending.
455
456
const nextEditResult = new NextEditResult(logContext.requestId, req, { edit: edit.actualEdit, isFromCursorJump: edit.isFromCursorJump, documentBeforeEdits: currentDocument, targetDocumentId, isSubsequentEdit: isSubsequentCachedEdit, cacheEntry });
457
458
telemetryBuilder.setHasNextEdit(true);
459
460
const delay = this.computeMinimumResponseDelay({ triggerTime, isRebasedCachedEdit, isSubsequentCachedEdit, isFromSpeculativeRequest, enforceCacheDelay: context.enforceCacheDelay }, logger);
461
if (delay > 0) {
462
await timeout(delay);
463
if (cancellationToken.isCancellationRequested) {
464
logger.trace('cancelled');
465
telemetryBuilder.setStatus(`noEdit:gotCancelled`);
466
return emptyResult;
467
}
468
}
469
470
logger.trace('returning next edit result');
471
return nextEditResult;
472
}
473
474
private determineNesConfigs(telemetryBuilder: LlmNESTelemetryBuilder, logContext: InlineEditRequestLogContext): INesConfigs {
475
const nesConfigs: INesConfigs = {
476
isAsyncCompletions: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAsyncCompletions, this._expService),
477
isEagerBackupRequest: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsEagerBackupRequest, this._expService),
478
};
479
480
telemetryBuilder.setNESConfigs({ ...nesConfigs });
481
logContext.addCodeblockToLog(JSON.stringify(nesConfigs, null, '\t'));
482
483
return nesConfigs;
484
}
485
486
private _processDoc(doc: DocumentHistory): ProcessedDoc {
487
const documentLinesBeforeEdit = doc.lastEdit.base.getLines();
488
489
const recentEdits = doc.lastEdits;
490
491
const recentEdit = RootedLineEdit.fromEdit(new RootedEdit(doc.lastEdit.base, doc.lastEdits.compose())).removeCommonSuffixPrefixLines().edit;
492
493
const documentBeforeEdits = doc.lastEdit.base;
494
495
const lastSelectionInAfterEdits = doc.lastSelection;
496
497
const workspaceRoot = this._workspace.getWorkspaceRoot(doc.docId);
498
499
const nextEditDoc = new StatelessNextEditDocument(
500
doc.docId,
501
workspaceRoot,
502
doc.languageId,
503
documentLinesBeforeEdit,
504
recentEdit,
505
documentBeforeEdits,
506
recentEdits,
507
lastSelectionInAfterEdits,
508
);
509
510
return {
511
recentEdit: doc.lastEdit,
512
nextEditDoc,
513
documentAfterEdits: nextEditDoc.documentAfterEdits,
514
};
515
}
516
517
private async fetchNextEdit(req: NextEditFetchRequest, doc: IObservableDocument, nesConfigs: INesConfigs, shouldExpandEditWindow: boolean, parentLogger: ILogger, telemetryBuilder: LlmNESTelemetryBuilder, cancellationToken: CancellationToken): Promise<Result<CachedOrRebasedEdit, NoNextEditReason>> {
518
const curDocId = doc.id;
519
const logger = parentLogger.createSubLogger('fetchNextEdit');
520
const historyContext = this._historyContextProvider.getHistoryContext(curDocId);
521
522
if (!historyContext) {
523
return Result.error(new NoNextEditReason.Unexpected(new Error('DocumentMissingInHistoryContext')));
524
}
525
526
const documentAtInvocationTime = doc.value.get();
527
const selectionAtInvocationTime = doc.selection.get();
528
529
const logContext = req.log;
530
531
logContext.setRecentEdit(historyContext);
532
533
const cursorAtInvocationTime = selectionAtInvocationTime.at(0);
534
const cursorInRequestEditWindow = (request: StatelessNextEditRequest) =>
535
!request.requestEditWindow || !cursorAtInvocationTime || request.requestEditWindow.containsCursor(cursorAtInvocationTime);
536
537
// Check if we can reuse the regular pending request
538
const pendingRequestStillCurrent = documentAtInvocationTime.value === this._pendingStatelessNextEditRequest?.documentBeforeEdits.value;
539
const cursorWithinPendingEditWindow = !this._pendingStatelessNextEditRequest || cursorInRequestEditWindow(this._pendingStatelessNextEditRequest);
540
const existingNextEditRequest = (pendingRequestStillCurrent || nesConfigs.isAsyncCompletions) && cursorWithinPendingEditWindow
541
&& !this._pendingStatelessNextEditRequest?.cancellationTokenSource.token.isCancellationRequested
542
&& this._pendingStatelessNextEditRequest || undefined;
543
544
// Check if we can reuse the speculative pending request (from when a suggestion was shown)
545
const specPending = this._specManager.pending;
546
const speculativeRequestMatches = specPending?.docId === curDocId
547
&& specPending?.postEditContent === documentAtInvocationTime.value
548
&& !specPending.request.cancellationTokenSource.token.isCancellationRequested
549
&& cursorInRequestEditWindow(specPending.request);
550
const speculativeRequest = speculativeRequestMatches ? specPending?.request : undefined;
551
552
// Prefer speculative request if it matches (it was specifically created for this post-edit state)
553
const requestToReuse = speculativeRequest ?? existingNextEditRequest;
554
555
if (requestToReuse) {
556
// Nice! No need to make another request, we can reuse the result from a pending request.
557
if (speculativeRequest) {
558
logger.trace(`reusing speculative pending request (opportunityId=${speculativeRequest.opportunityId}, headerRequestId=${speculativeRequest.headerRequestId})`);
559
// Detach the speculative — caller is consuming it now.
560
this._specManager.consumePending();
561
} else {
562
logger.trace(`reusing in-flight pending request (opportunityId=${requestToReuse.opportunityId}, headerRequestId=${requestToReuse.headerRequestId})`);
563
}
564
565
const requestStillCurrent = speculativeRequest
566
? speculativeRequestMatches // For speculative, we already checked it matches
567
: pendingRequestStillCurrent;
568
569
const reusedRequestKind = speculativeRequest ? ReusedRequestKind.Speculative : ReusedRequestKind.Async;
570
571
if (requestStillCurrent) {
572
const nextEditResult = await this._joinNextEditRequest(requestToReuse, reusedRequestKind, telemetryBuilder, logContext, cancellationToken);
573
telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);
574
if (speculativeRequest) {
575
const firstEdit = await requestToReuse.firstEdit.p;
576
return firstEdit.map(val => ({ ...val, isFromSpeculativeRequest: true }));
577
}
578
return nextEditResult.nextEdit.isError() ? nextEditResult.nextEdit : requestToReuse.firstEdit.p;
579
} else if (nesConfigs.isEagerBackupRequest) {
580
// The pending request is stale (document diverged). Start a backup request
581
// in parallel so that if rebase fails, we already have a head start.
582
logger.trace('starting eager backup request in parallel with rebase attempt');
583
584
// _executeNewNextEditRequest cancels the current _pendingStatelessNextEditRequest,
585
// but we're still trying to join+rebase requestToReuse. Temporarily clear the
586
// pending field so the stale request isn't cancelled prematurely.
587
this._pendingStatelessNextEditRequest = null;
588
const backupPromise = this._executeNewNextEditRequest(req, doc, historyContext, nesConfigs, shouldExpandEditWindow, logger, telemetryBuilder, cancellationToken);
589
const cancelBackupRequest = () => {
590
void backupPromise
591
.then(r => r.nextEditRequest.cancellationTokenSource.cancel())
592
.catch(() => undefined);
593
};
594
595
// Simultaneously attempt to join + rebase the stale request
596
const nextEditResult = await this._joinNextEditRequest(requestToReuse, reusedRequestKind, telemetryBuilder, logContext, cancellationToken);
597
const cacheResult = await requestToReuse.firstEdit.p;
598
if (cacheResult.isOk() && cacheResult.val.edit) {
599
const rebaseResult = this._nextEditCache.tryRebaseCacheEntry(cacheResult.val, documentAtInvocationTime, selectionAtInvocationTime);
600
if (rebaseResult.edit) {
601
logger.trace('rebase succeeded, cancelling eager backup request');
602
cancelBackupRequest();
603
telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);
604
return Result.ok(rebaseResult.edit);
605
}
606
this._logRebaseFailure(rebaseResult.failureInfo, logContext);
607
}
608
609
if (cancellationToken.isCancellationRequested) {
610
logger.trace('cancelled after rebase failed (eager backup path)');
611
cancelBackupRequest();
612
telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);
613
return Result.error(new NoNextEditReason.GotCancelled('afterFailedRebase'));
614
}
615
616
// Rebase failed — use the backup request that's already been running in parallel
617
logger.trace('rebase failed, using eager backup request');
618
const backupRes = await backupPromise;
619
telemetryBuilder.setStatelessNextEditTelemetry(backupRes.nextEditResult.telemetry);
620
return backupRes.nextEditResult.nextEdit.isError() ? backupRes.nextEditResult.nextEdit : backupRes.nextEditRequest.firstEdit.p;
621
} else {
622
const nextEditResult = await this._joinNextEditRequest(requestToReuse, reusedRequestKind, telemetryBuilder, logContext, cancellationToken);
623
624
// Needs rebasing.
625
const cacheResult = await requestToReuse.firstEdit.p;
626
if (cacheResult.isOk() && cacheResult.val.edit) {
627
const rebaseResult = this._nextEditCache.tryRebaseCacheEntry(cacheResult.val, documentAtInvocationTime, selectionAtInvocationTime);
628
if (rebaseResult.edit) {
629
telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);
630
return Result.ok(rebaseResult.edit);
631
}
632
this._logRebaseFailure(rebaseResult.failureInfo, logContext);
633
}
634
635
if (cancellationToken.isCancellationRequested) {
636
logger.trace('document changed after rebase failed');
637
telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);
638
return Result.error(new NoNextEditReason.GotCancelled('afterFailedRebase'));
639
}
640
641
// Rebase failed (or result had error). Check if there is a new pending request. Otherwise continue with a new request below.
642
const pendingRequestStillCurrent2 = documentAtInvocationTime.value === this._pendingStatelessNextEditRequest?.documentBeforeEdits.value;
643
const existingNextEditRequest2 = pendingRequestStillCurrent2 && !this._pendingStatelessNextEditRequest?.cancellationTokenSource.token.isCancellationRequested
644
&& this._pendingStatelessNextEditRequest || undefined;
645
if (existingNextEditRequest2) {
646
logger.trace('reusing 2nd existing next edit request after rebase failed');
647
const nextEditResult2 = await this._joinNextEditRequest(existingNextEditRequest2, ReusedRequestKind.Async, telemetryBuilder, logContext, cancellationToken);
648
telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult2.telemetry);
649
return nextEditResult2.nextEdit.isError() ? nextEditResult2.nextEdit : existingNextEditRequest2.firstEdit.p;
650
}
651
652
logger.trace('creating new next edit request after rebase failed');
653
}
654
}
655
656
const res = await this._executeNewNextEditRequest(req, doc, historyContext, nesConfigs, shouldExpandEditWindow, logger, telemetryBuilder, cancellationToken);
657
const nextEditRequest = res.nextEditRequest;
658
const nextEditResult = res.nextEditResult;
659
telemetryBuilder.setStatelessNextEditTelemetry(nextEditResult.telemetry);
660
return nextEditResult.nextEdit.isError() ? nextEditResult.nextEdit : nextEditRequest.firstEdit.p;
661
}
662
663
private async _joinNextEditRequest(nextEditRequest: StatelessNextEditRequest, reusedRequestKind: ReusedRequestKind, telemetryBuilder: LlmNESTelemetryBuilder, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) {
664
telemetryBuilder.setHeaderRequestId(nextEditRequest.headerRequestId);
665
telemetryBuilder.setReusedRequest(reusedRequestKind);
666
667
telemetryBuilder.setRequest(nextEditRequest);
668
logContext.setRequestInput(nextEditRequest);
669
logContext.setIsReusedInFlightResult(nextEditRequest.logContext);
670
671
const disp = this._hookupCancellation(nextEditRequest, cancellationToken);
672
try {
673
return await nextEditRequest.result;
674
} finally {
675
disp.dispose();
676
}
677
}
678
679
private _logRebaseFailure(failureInfo: MarkdownLoggable | undefined, logContext: InlineEditRequestLogContext): void {
680
if (failureInfo) {
681
logContext.setRebaseFailure(failureInfo);
682
}
683
}
684
685
private async _executeNewNextEditRequest(
686
req: NextEditFetchRequest,
687
doc: IObservableDocument,
688
historyContext: HistoryContext,
689
nesConfigs: INesConfigs,
690
shouldExpandEditWindow: boolean,
691
parentLogger: ILogger,
692
telemetryBuilder: LlmNESTelemetryBuilder,
693
cancellationToken: CancellationToken
694
): Promise<{
695
nextEditRequest: StatelessNextEditRequest<CachedOrRebasedEdit>;
696
nextEditResult: StatelessNextEditResult;
697
}> {
698
const curDocId = doc.id;
699
const logger = parentLogger.createSubLogger('_executeNewNextEditRequest');
700
701
const recording = this._debugRecorder?.getRecentLog();
702
703
const logContext = req.log;
704
705
const activeDocAndIdx = assertDefined(historyContext.getDocumentAndIdx(curDocId));
706
const activeDocSelection = doc.selection.get()[0] as OffsetRange | undefined;
707
708
const projectedDocuments = historyContext.documents.map(doc => this._processDoc(doc));
709
710
const xtabEditHistory = this._xtabHistoryTracker.getHistory();
711
712
const firstEdit = new DeferredPromise<Result<CachedOrRebasedEdit, NoNextEditReason>>();
713
714
const nLinesEditWindow = (shouldExpandEditWindow
715
? this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAutoExpandEditWindowLines, this._expService)
716
: undefined);
717
718
const nextEditRequest = new StatelessNextEditRequest(
719
req.headerRequestId,
720
req.opportunityId,
721
doc.value.get(),
722
projectedDocuments.map(d => d.nextEditDoc),
723
activeDocAndIdx.idx,
724
xtabEditHistory,
725
firstEdit,
726
nLinesEditWindow,
727
false, // isSpeculative
728
logContext,
729
req.log.recordingBookmark,
730
recording,
731
req.providerRequestStartDateTime,
732
);
733
let nextEditResult: StatelessNextEditResult | undefined;
734
735
if (this._pendingStatelessNextEditRequest) {
736
this._pendingStatelessNextEditRequest.cancellationTokenSource.cancel();
737
this._pendingStatelessNextEditRequest = null;
738
// Clear any scheduled (but not yet triggered) speculative request tied to the
739
// old stream — it would otherwise fire stale when the old stream's background
740
// loop calls handleStreamEnd after the stream has already been superseded.
741
this._specManager.clearScheduled();
742
}
743
744
// Cancel speculative request if it doesn't match the document/state
745
// of this new request — it was built for a different document or post-edit state.
746
this._specManager.cancelIfMismatch(curDocId, nextEditRequest.documentBeforeEdits.value, SpeculativeCancelReason.Superseded);
747
748
this._pendingStatelessNextEditRequest = nextEditRequest;
749
750
const removeFromPending = () => {
751
if (this._pendingStatelessNextEditRequest === nextEditRequest) {
752
this._pendingStatelessNextEditRequest = null;
753
}
754
};
755
756
telemetryBuilder.setRequest(nextEditRequest);
757
telemetryBuilder.setStatus('requested');
758
logContext.setRequestInput(nextEditRequest);
759
760
// A note on cancellation:
761
//
762
// We don't cancel when the cancellation token is signalled, because we have our own
763
// separate cancellation logic which ends up cancelling based on documents changing.
764
//
765
// But we do cancel requests which didn't start yet if no-one really needs their result
766
//
767
const disp = this._hookupCancellation(nextEditRequest, cancellationToken, nesConfigs.isAsyncCompletions ? autorunWithChanges(this, {
768
value: doc.value,
769
}, data => {
770
data.value.changes.forEach(edit => {
771
if (nextEditRequest.intermediateUserEdit && !edit.isEmpty()) {
772
nextEditRequest.intermediateUserEdit = nextEditRequest.intermediateUserEdit.compose(edit);
773
if (!checkEditConsistency(nextEditRequest.documentBeforeEdits.value, nextEditRequest.intermediateUserEdit, data.value.value.value, logger)) {
774
nextEditRequest.intermediateUserEdit = undefined;
775
}
776
}
777
});
778
}) : undefined);
779
780
781
const statePerDoc = createDocStateLookupMap(projectedDocuments, xtabEditHistory);
782
783
const editStream = this._statelessNextEditProvider.provideNextEdit(nextEditRequest, logger, logContext, nextEditRequest.cancellationTokenSource.token);
784
785
let ithEdit = -1;
786
787
const processEdit = (streamedEdit: { readonly edit: LineReplacement; readonly isFromCursorJump: boolean; readonly window?: OffsetRange; readonly originalWindow?: OffsetRange; readonly targetDocument?: DocumentId }, telemetry: IStatelessNextEditTelemetry): CachedOrRebasedEdit | undefined => {
788
++ithEdit;
789
const myLogger = logger.createSubLogger('processEdit');
790
myLogger.trace(`processing edit #${ithEdit} (starts at 0)`);
791
792
// reset shouldExpandEditWindow to false when we get any edit
793
myLogger.trace('resetting shouldExpandEditWindow to false due to receiving an edit');
794
this._shouldExpandEditWindow = false;
795
796
const targetDocState = statePerDoc.get(streamedEdit.targetDocument ?? curDocId);
797
798
const singleLineEdit = streamedEdit.edit;
799
const lineEdit = new LineEdit([singleLineEdit]);
800
const edit = convertLineEditToEdit(lineEdit, targetDocState.baseDocState);
801
const rebasedEdit = edit.tryRebase(targetDocState.editsSoFar);
802
803
if (rebasedEdit === undefined) {
804
myLogger.trace(`edit ${ithEdit} is undefined after rebasing`);
805
if (!firstEdit.isSettled) {
806
firstEdit.complete(Result.error(new NoNextEditReason.Uncategorized(new Error('Rebased edit is undefined'))));
807
}
808
return undefined;
809
}
810
811
targetDocState.editsSoFar = targetDocState.editsSoFar.compose(rebasedEdit);
812
813
let cachedEdit: CachedOrRebasedEdit | undefined;
814
if (rebasedEdit.replacements.length === 0) {
815
myLogger.trace(`WARNING: ${ithEdit} has no edits`);
816
} else if (rebasedEdit.replacements.length > 1) {
817
myLogger.trace(`WARNING: ${ithEdit} has ${rebasedEdit.replacements.length} edits, but expected only 1`);
818
} else {
819
// populate the cache
820
const nextEditReplacement = rebasedEdit.replacements[0];
821
targetDocState.nextEdits.push(nextEditReplacement);
822
cachedEdit = this._nextEditCache.setKthNextEdit(
823
targetDocState.docId,
824
targetDocState.docContents,
825
ithEdit === 0 ? streamedEdit.window : undefined,
826
nextEditReplacement,
827
ithEdit,
828
ithEdit === 0 ? targetDocState.nextEdits : undefined,
829
ithEdit === 0 ? nextEditRequest.intermediateUserEdit : undefined,
830
req,
831
{ isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === curDocId ? activeDocSelection?.start : undefined }
832
);
833
myLogger.trace(`populated cache for ${ithEdit}`);
834
}
835
836
if (!firstEdit.isSettled) {
837
myLogger.trace('resolving firstEdit promise');
838
logContext.setResult(new RootedLineEdit(targetDocState.docContents, lineEdit)); // this's correct without rebasing because this's the first edit
839
firstEdit.complete(cachedEdit ? Result.ok(cachedEdit) : Result.error(new NoNextEditReason.Unexpected(new Error('No cached edit'))));
840
}
841
842
targetDocState.docContents = rebasedEdit.applyOnText(targetDocState.docContents);
843
844
return cachedEdit;
845
};
846
847
const handleStreamEnd = (completionReason: NoNextEditReason, lastTelemetry: IStatelessNextEditTelemetry) => {
848
const myLogger = logger.createSubLogger('streamEnd');
849
850
// if there was a request made, and it ended without any edits, reset shouldExpandEditWindow
851
const hadNoEdits = ithEdit === -1;
852
if (hadNoEdits && completionReason instanceof NoNextEditReason.NoSuggestions) {
853
myLogger.trace('resetting shouldExpandEditWindow to false due to NoSuggestions');
854
this._shouldExpandEditWindow = false;
855
}
856
857
if (statePerDoc.get(curDocId).nextEdits.length) {
858
myLogger.trace(`${statePerDoc.get(curDocId).nextEdits.length} edits returned`);
859
} else {
860
myLogger.trace(`no edit, reason: ${completionReason.kind}`);
861
if (completionReason instanceof NoNextEditReason.NoSuggestions) {
862
const { documentBeforeEdits, window } = completionReason;
863
const reducedWindow = window ? computeReducedWindow(window, activeDocSelection, documentBeforeEdits) : undefined;
864
this._nextEditCache.setNoNextEdit(curDocId, documentBeforeEdits, reducedWindow, req);
865
}
866
}
867
868
if (!firstEdit.isSettled) {
869
firstEdit.complete(Result.error(completionReason));
870
}
871
872
const resultForTelemetry: Result<void, NoNextEditReason> = statePerDoc.get(curDocId).nextEdits.length > 0
873
? Result.ok(undefined)
874
: Result.error(completionReason);
875
const result = new StatelessNextEditResult(resultForTelemetry, lastTelemetry);
876
nextEditRequest.setResult(result);
877
878
disp.dispose();
879
removeFromPending();
880
881
// Fire any scheduled speculative request — the last shown edit
882
// was indeed the last edit from this stream.
883
const scheduled = this._specManager.consumeScheduled(nextEditRequest.headerRequestId);
884
if (scheduled) {
885
void this._triggerSpeculativeRequest(scheduled.suggestion);
886
}
887
888
return result;
889
};
890
891
try {
892
let res = await editStream.next();
893
894
if (res.done) {
895
// Stream ended immediately without any edits
896
const completionReason = res.value.v;
897
nextEditResult = handleStreamEnd(completionReason, res.value.telemetryBuilder);
898
} else {
899
// Process first edit synchronously
900
const firstStreamedEdit = res.value.v;
901
const firstTelemetry = res.value.telemetryBuilder;
902
processEdit(firstStreamedEdit, firstTelemetry);
903
904
// Continue streaming remaining edits in the background (unawaited)
905
(async () => {
906
try {
907
res = await editStream.next();
908
while (!res.done) {
909
const streamedEdit = res.value.v;
910
processEdit(streamedEdit, res.value.telemetryBuilder);
911
912
// A new edit arrived from the stream — the previously-shown
913
// edit was not the last one. Clear the scheduled speculative.
914
this._specManager.consumeScheduled(nextEditRequest.headerRequestId);
915
916
res = await editStream.next();
917
}
918
919
// Stream completed
920
const completionReason = res.value.v;
921
handleStreamEnd(completionReason, res.value.telemetryBuilder);
922
} catch (err) {
923
logger.trace(`Error while streaming further edits: ${ErrorUtils.toString(err)}`);
924
const errorReason = new NoNextEditReason.Unexpected(ErrorUtils.fromUnknown(err));
925
handleStreamEnd(errorReason, firstTelemetry);
926
}
927
})();
928
929
nextEditResult = new StatelessNextEditResult(Result.ok(undefined), firstTelemetry);
930
}
931
932
} catch (err) {
933
nextEditRequest.setResultError(err);
934
throw err;
935
}
936
937
return { nextEditRequest, nextEditResult };
938
}
939
940
private _hookupCancellation(nextEditRequest: StatelessNextEditRequest, cancellationToken: CancellationToken, attachedDisposable?: IDisposable): IDisposable {
941
const disposables = new DisposableStore();
942
943
let dependantRemoved = false;
944
const removeDependant = () => {
945
if (!dependantRemoved) {
946
dependantRemoved = true;
947
nextEditRequest.liveDependentants--;
948
}
949
};
950
951
const cancellationTimer = disposables.add(new TimeoutTimer());
952
953
disposables.add(cancellationToken.onCancellationRequested(() => {
954
removeDependant();
955
if (nextEditRequest.liveDependentants > 0) {
956
// there are others depending on this request
957
return;
958
}
959
if (!nextEditRequest.fetchIssued) {
960
// fetch not issued => cancel!
961
nextEditRequest.cancellationTokenSource.cancel();
962
attachedDisposable?.dispose();
963
return;
964
}
965
cancellationTimer.setIfNotSet(() => {
966
if (nextEditRequest.liveDependentants > 0) {
967
// there are others depending on this request
968
return;
969
}
970
nextEditRequest.cancellationTokenSource.cancel();
971
attachedDisposable?.dispose();
972
}, 1000); // This needs to be longer than the pause between two requests from Core otherwise we cancel running requests too early.
973
}));
974
975
disposables.add(toDisposable(() => {
976
removeDependant();
977
if (nextEditRequest.liveDependentants === 0) {
978
attachedDisposable?.dispose();
979
}
980
}));
981
982
nextEditRequest.liveDependentants++;
983
984
return disposables;
985
}
986
987
private computeMinimumResponseDelay({ triggerTime, isRebasedCachedEdit, isSubsequentCachedEdit, isFromSpeculativeRequest, enforceCacheDelay }: { triggerTime: number; isRebasedCachedEdit: boolean; isSubsequentCachedEdit: boolean; isFromSpeculativeRequest: boolean; enforceCacheDelay: boolean }, logger: ILogger): number {
988
989
if (!enforceCacheDelay) {
990
logger.trace('[minimumDelay] no minimum delay enforced due to enforceCacheDelay being false');
991
return 0;
992
}
993
994
const cacheDelay = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsCacheDelay, this._expService);
995
const rebasedCacheDelay = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsRebasedCacheDelay, this._expService);
996
const subsequentCacheDelay = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSubsequentCacheDelay, this._expService);
997
const speculativeRequestDelay = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestDelay, this._expService);
998
999
let minimumResponseDelay = cacheDelay;
1000
if (isRebasedCachedEdit && rebasedCacheDelay !== undefined) {
1001
minimumResponseDelay = rebasedCacheDelay;
1002
} else if (isSubsequentCachedEdit && subsequentCacheDelay !== undefined) {
1003
minimumResponseDelay = subsequentCacheDelay;
1004
} else if (isFromSpeculativeRequest && speculativeRequestDelay !== undefined) {
1005
minimumResponseDelay = speculativeRequestDelay;
1006
}
1007
1008
const nextEditProviderCallLatency = Date.now() - triggerTime;
1009
1010
// if the provider call took longer than the minimum delay, we don't need to delay further
1011
const delay = Math.max(0, minimumResponseDelay - nextEditProviderCallLatency);
1012
1013
logger.trace(`[minimumDelay] expected delay: ${minimumResponseDelay}ms, effective delay: ${delay}. isRebasedCachedEdit: ${isRebasedCachedEdit} (rebasedCacheDelay: ${rebasedCacheDelay}), isSubsequentCachedEdit: ${isSubsequentCachedEdit} (subsequentCacheDelay: ${subsequentCacheDelay}), isFromSpeculativeRequest: ${isFromSpeculativeRequest} (speculativeRequestDelay: ${speculativeRequestDelay})`);
1014
1015
return delay;
1016
}
1017
1018
public handleShown(suggestion: NextEditResult) {
1019
this._lastShownTime = Date.now();
1020
this._lastShownSuggestionId = suggestion.requestId;
1021
this._lastOutcome = undefined; // clear so that outcome is "pending" until resolved
1022
this._specManager.clearScheduled(); // clear any previously scheduled speculative
1023
1024
// Trigger speculative request for the post-edit document state
1025
const speculativeRequestsEnablement = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, this._expService);
1026
if (speculativeRequestsEnablement === SpeculativeRequestsEnablement.On) {
1027
// If the originating stream is still running, defer the speculative request
1028
// until the stream completes. If more edits come from this stream, the
1029
// schedule is cleared (the shown edit wasn't the last one). The speculative
1030
// request only fires when the stream ends with the shown edit as the last one.
1031
const originatingRequest = this._pendingStatelessNextEditRequest;
1032
if (originatingRequest && originatingRequest.headerRequestId === suggestion.source.headerRequestId) {
1033
this._specManager.schedule({
1034
suggestion,
1035
headerRequestId: originatingRequest.headerRequestId,
1036
});
1037
} else {
1038
void this._triggerSpeculativeRequest(suggestion);
1039
}
1040
}
1041
}
1042
1043
private async _triggerSpeculativeRequest(suggestion: NextEditResult): Promise<void> {
1044
const result = suggestion.result;
1045
if (!result?.edit) {
1046
return;
1047
}
1048
1049
const docId = result.targetDocumentId;
1050
if (!docId) {
1051
return;
1052
}
1053
1054
const logContext = new InlineEditRequestLogContext(docId.uri, 0, undefined);
1055
1056
const sw = new StopWatch();
1057
const logger = this._logger.createSubLogger('_triggerSpeculativeRequest')
1058
.withExtraTarget(LogTarget.fromCallback((_level, msg) => {
1059
logContext.trace(`[${Math.floor(sw.elapsed()).toString().padStart(4, ' ')}ms] ${msg}`);
1060
}));
1061
1062
// Compute the post-edit document content
1063
const postEditContent = result.edit.replace(result.documentBeforeEdits.value);
1064
const preciseEdit = result.edit.removeCommonSuffixPrefix(result.documentBeforeEdits.value);
1065
const postEditCursorOffset = preciseEdit.replaceRange.start + preciseEdit.newText.length;
1066
const postEditCursorOffsetRange = new OffsetRange(postEditCursorOffset, postEditCursorOffset);
1067
const selections = [postEditCursorOffsetRange];
1068
const rootedEdit = new RootedEdit(result.documentBeforeEdits, new StringEdit([result.edit]));
1069
1070
const postEditContentST = new StringText(postEditContent);
1071
let cachedEdit = this._nextEditCache.lookupNextEdit(docId, postEditContentST, selections);
1072
let shiftedSelection = postEditCursorOffsetRange;
1073
if (cachedEdit) {
1074
// first cachedEdit should be without edits because of noSuggestions caching
1075
if (cachedEdit.edit) {
1076
logger.trace('already have cached edit for post-edit state');
1077
return;
1078
} else if (cachedEdit.editWindow) {
1079
logger.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');
1080
const cursorPlacement = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestsCursorPlacement, this._expService);
1081
if (cursorPlacement === SpeculativeRequestsCursorPlacement.AfterEditWindow) {
1082
logger.trace('cursor placement config is AfterEditWindow, shifting selection to after edit window');
1083
shiftedSelection = NextEditProvider.shiftSelectionAfterEditWindow(postEditContentST, cachedEdit.editWindow);
1084
cachedEdit = this._nextEditCache.lookupNextEdit(docId, postEditContentST, [shiftedSelection]);
1085
if (cachedEdit?.edit) {
1086
logger.trace('already have cached edit for post-edit state (after shifting selection)');
1087
return;
1088
} else {
1089
logger.trace('no cached edit even after shifting selection');
1090
}
1091
} else {
1092
logger.trace(`cursor placement config is ${cursorPlacement}, not shifting selection`);
1093
}
1094
} else {
1095
logger.trace('already have cached no-suggestions entry for post-edit state');
1096
return;
1097
}
1098
}
1099
1100
// Check if we already have a pending request for the post-edit state
1101
if (this._pendingStatelessNextEditRequest?.documentBeforeEdits.value === postEditContent) {
1102
logger.trace('already have pending request for post-edit state');
1103
return;
1104
}
1105
1106
// Check if we already have a speculative request for this post-edit state
1107
const existingSpec = this._specManager.pending;
1108
if (existingSpec?.docId === docId && existingSpec?.postEditContent === postEditContent) {
1109
logger.trace('already have speculative request for post-edit state');
1110
return;
1111
}
1112
1113
// Get the document to trigger speculative fetch
1114
// Note: targetDocumentId is defined when the suggestion targets a different document
1115
// Otherwise, use the file path from the log context
1116
const doc = this._workspace.getDocument(docId);
1117
if (!doc) {
1118
logger.trace('document not found for speculative request');
1119
return;
1120
}
1121
1122
// Note: any previous speculative request will be cancelled (as `Replaced`)
1123
// by `_specManager.setPending` once the new request is actually installed —
1124
// see the `setPending` call at the end of this method. We deliberately do
1125
// not cancel earlier so the prior speculative stays available for reuse
1126
// while the new one is being constructed.
1127
1128
const historyContext = this._historyContextProvider.getHistoryContext(docId);
1129
if (!historyContext) {
1130
logger.trace('no history context for speculative request');
1131
return;
1132
}
1133
1134
const req = new NextEditFetchRequest(`sp-${suggestion.source.opportunityId}`, logContext, undefined, true, `sp-${generateUuid()}`);
1135
1136
logger.trace(`triggering speculative request for post-edit state (opportunityId=${req.opportunityId}, headerRequestId=${req.headerRequestId})`);
1137
1138
try {
1139
const speculativeRequest = await this._createSpeculativeRequest(
1140
req,
1141
doc,
1142
shiftedSelection,
1143
historyContext,
1144
postEditContent,
1145
rootedEdit,
1146
result.edit,
1147
{
1148
triggeredBySpeculativeRequest: suggestion.source.isSpeculative,
1149
isSubsequentEdit: suggestion.result?.isSubsequentEdit ?? false,
1150
},
1151
logger
1152
);
1153
1154
if (speculativeRequest) {
1155
// Capture trajectory data: while the user is typing in `docId`, the
1156
// document is on a "type-through" trajectory iff:
1157
// doc = preEdit[0..editStart] + newText[0..k] + preEdit[editEnd..]
1158
// for some 0 <= k <= newText.length. Storing the prefix/suffix/newText
1159
// (already-CRLF-normalized via `result.edit.newText` whose newlines
1160
// match the original document) lets us check this in O(|cur|) on doc changes.
1161
const preEditValue = result.documentBeforeEdits.value;
1162
const trajectoryPrefix = preEditValue.slice(0, preciseEdit.replaceRange.start);
1163
const trajectorySuffix = preEditValue.slice(preciseEdit.replaceRange.endExclusive);
1164
const trajectoryNewText = preciseEdit.newText;
1165
this._specManager.setPending({
1166
request: speculativeRequest,
1167
docId,
1168
postEditContent,
1169
trajectoryPrefix,
1170
trajectorySuffix,
1171
trajectoryNewText,
1172
});
1173
}
1174
} catch (e) {
1175
logger.trace(`speculative request failed: ${ErrorUtils.toString(e)}`);
1176
}
1177
}
1178
1179
/**
1180
* Creates and starts a speculative request for the post-edit document state.
1181
* The request will populate the cache so that when the user accepts the suggestion,
1182
* the next NES request can reuse or find the result in cache.
1183
*/
1184
private async _createSpeculativeRequest(
1185
req: NextEditFetchRequest,
1186
doc: IObservableDocument,
1187
shiftedSelection: OffsetRange,
1188
historyContext: HistoryContext,
1189
postEditContent: string,
1190
rootedEdit: RootedEdit,
1191
appliedEdit: StringReplacement,
1192
{ triggeredBySpeculativeRequest, isSubsequentEdit }: { triggeredBySpeculativeRequest: boolean; isSubsequentEdit: boolean },
1193
parentLogger: ILogger
1194
): Promise<StatelessNextEditRequest<CachedOrRebasedEdit> | undefined> {
1195
const curDocId = doc.id;
1196
1197
const recording = this._debugRecorder?.getRecentLog();
1198
const logContext = req.log;
1199
logContext.setStatelessNextEditProviderId(this._statelessNextEditProvider.ID);
1200
1201
const logger = parentLogger.createSubLogger('_createSpeculativeRequest');
1202
1203
const activeDocAndIdx = historyContext.getDocumentAndIdx(curDocId);
1204
if (!activeDocAndIdx) {
1205
logger.trace('active doc not found in history context');
1206
return undefined;
1207
}
1208
1209
// Create the post-edit document content
1210
const postEditText = new StringText(postEditContent);
1211
1212
// Process documents, but for the active document, use the post-edit state
1213
const projectedDocuments: ProcessedDoc[] = historyContext.documents.map(docHist => {
1214
if (docHist.docId !== curDocId) {
1215
return this._processDoc(docHist);
1216
} else {
1217
// For the active document, create a version representing post-edit state
1218
// The "recent edit" from the model's perspective is the NES edit we just applied
1219
const workspaceRoot = this._workspace.getWorkspaceRoot(curDocId);
1220
const postEditEdit = new StringEdit([appliedEdit]);
1221
const postEditLineEdit = RootedLineEdit.fromEdit(new RootedEdit(doc.value.get(), postEditEdit)).removeCommonSuffixPrefixLines().edit;
1222
1223
const nextEditDoc = new StatelessNextEditDocument(
1224
curDocId,
1225
workspaceRoot,
1226
docHist.languageId,
1227
doc.value.get().getLines(), // lines before the NES edit
1228
postEditLineEdit, // the NES edit as LineEdit
1229
doc.value.get(), // document before NES edit
1230
Edits.single(postEditEdit), // the NES edit as Edits
1231
shiftedSelection,
1232
);
1233
1234
return {
1235
recentEdit: new RootedEdit(doc.value.get(), postEditEdit),
1236
nextEditDoc,
1237
documentAfterEdits: postEditText,
1238
};
1239
}
1240
});
1241
1242
const xtabEditHistory = this._xtabHistoryTracker.getHistory();
1243
const suggestedEdit: IXtabHistoryEditEntry = { kind: 'edit', docId: curDocId, edit: rootedEdit };
1244
xtabEditHistory.push(suggestedEdit);
1245
1246
const firstEdit = new DeferredPromise<Result<CachedOrRebasedEdit, NoNextEditReason>>();
1247
1248
// FIXME@ulugbekna: implement advanced expansion
1249
const autoExpandEditWindowLinesSetting = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequestsAutoExpandEditWindowLines, this._expService);
1250
let nLinesEditWindow: number | undefined;
1251
switch (autoExpandEditWindowLinesSetting) {
1252
case SpeculativeRequestsAutoExpandEditWindowLines.Off:
1253
nLinesEditWindow = undefined;
1254
break;
1255
case SpeculativeRequestsAutoExpandEditWindowLines.Always:
1256
nLinesEditWindow = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAutoExpandEditWindowLines, this._expService);
1257
break;
1258
case SpeculativeRequestsAutoExpandEditWindowLines.Smart: {
1259
const isModelOnRightTrack = triggeredBySpeculativeRequest || isSubsequentEdit;
1260
nLinesEditWindow = (isModelOnRightTrack
1261
? this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAutoExpandEditWindowLines, this._expService)
1262
: undefined);
1263
break;
1264
}
1265
default:
1266
assertNever(autoExpandEditWindowLinesSetting);
1267
}
1268
1269
const nextEditRequest = new StatelessNextEditRequest(
1270
req.headerRequestId,
1271
req.opportunityId,
1272
postEditText, // documentBeforeEdits is the post-edit state
1273
projectedDocuments.map(d => d.nextEditDoc),
1274
activeDocAndIdx.idx,
1275
xtabEditHistory,
1276
firstEdit,
1277
nLinesEditWindow,
1278
true, // isSpeculative
1279
logContext,
1280
undefined, // recordingBookmark
1281
recording,
1282
undefined, // providerRequestStartDateTime
1283
);
1284
1285
logContext.setRequestInput(nextEditRequest);
1286
1287
logger.trace('starting speculative provider call');
1288
1289
// Start the provider call - this runs in the background and populates the cache
1290
const label = `NES | spec | ${basename(doc.id.toUri().fsPath)} (v${doc.version.get()})`;
1291
1292
const capturingToken = new CapturingToken(label, undefined);
1293
1294
void this._requestLogger.captureInvocation(capturingToken, async () => {
1295
this._addLiveLogContextEntry(logContext, label);
1296
try {
1297
await this._runSpeculativeProviderCall(nextEditRequest, projectedDocuments, curDocId, req, shiftedSelection.start, logger);
1298
} catch (e) {
1299
logContext.setError(e);
1300
} finally {
1301
logContext.markCompleted();
1302
}
1303
});
1304
1305
return nextEditRequest;
1306
}
1307
1308
/**
1309
* Runs the provider call for a speculative request and caches results.
1310
*/
1311
private async _runSpeculativeProviderCall(
1312
nextEditRequest: StatelessNextEditRequest<CachedOrRebasedEdit>,
1313
projectedDocuments: readonly ProcessedDoc[],
1314
curDocId: DocumentId,
1315
req: NextEditFetchRequest,
1316
cursorOffset: number,
1317
parentLogger: ILogger
1318
): Promise<void> {
1319
const logger = parentLogger.createSubLogger('_runSpeculativeProviderCall');
1320
1321
const xtabEditHistory = nextEditRequest.xtabEditHistory;
1322
1323
const statePerDoc = createDocStateLookupMap(projectedDocuments, xtabEditHistory);
1324
1325
const logContext = req.log;
1326
const editStream = this._statelessNextEditProvider.provideNextEdit(
1327
nextEditRequest,
1328
logger,
1329
logContext,
1330
nextEditRequest.cancellationTokenSource.token
1331
);
1332
1333
let ithEdit = -1;
1334
1335
try {
1336
let res = await editStream.next();
1337
1338
if (res.done) {
1339
nextEditRequest.firstEdit.complete(Result.error(res.value.v));
1340
nextEditRequest.setResult(new StatelessNextEditResult(
1341
Result.error(res.value.v),
1342
res.value.telemetryBuilder
1343
));
1344
logContext.markAsNoSuggestions();
1345
logger.trace('speculative request completed with no edits');
1346
} else {
1347
1348
(async () => {
1349
while (!res.done) {
1350
++ithEdit;
1351
const streamedEdit = res.value.v;
1352
1353
const targetDocState = statePerDoc.get(streamedEdit.targetDocument ?? curDocId);
1354
1355
const singleLineEdit = streamedEdit.edit;
1356
const lineEdit = new LineEdit([singleLineEdit]);
1357
const edit = convertLineEditToEdit(lineEdit, targetDocState.baseDocState);
1358
const rebasedEdit = edit.tryRebase(targetDocState.editsSoFar);
1359
1360
if (rebasedEdit === undefined) {
1361
logger.trace(`speculative edit ${ithEdit} rebasing failed`);
1362
res = await editStream.next();
1363
continue;
1364
}
1365
1366
targetDocState.editsSoFar = targetDocState.editsSoFar.compose(rebasedEdit);
1367
1368
if (rebasedEdit.replacements.length === 1) {
1369
const nextEditReplacement = rebasedEdit.replacements[0];
1370
targetDocState.nextEdits.push(nextEditReplacement);
1371
1372
// Populate the cache with the speculative result
1373
const cachedEdit = this._nextEditCache.setKthNextEdit(
1374
targetDocState.docId,
1375
targetDocState.docContents,
1376
ithEdit === 0 ? streamedEdit.window : undefined,
1377
nextEditReplacement,
1378
ithEdit,
1379
ithEdit === 0 ? targetDocState.nextEdits : undefined,
1380
undefined, // no userEditSince for speculative
1381
req,
1382
{ isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === curDocId ? cursorOffset : undefined }
1383
);
1384
1385
if (!nextEditRequest.firstEdit.isSettled && cachedEdit) {
1386
nextEditRequest.firstEdit.complete(Result.ok(cachedEdit));
1387
nextEditRequest.setResult(
1388
new StatelessNextEditResult(
1389
Result.ok(undefined),
1390
res.value.telemetryBuilder
1391
)
1392
);
1393
logContext.setResponseResults([nextEditReplacement]);
1394
}
1395
1396
logger.trace(`cached speculative edit ${ithEdit}`);
1397
}
1398
1399
targetDocState.docContents = rebasedEdit.applyOnText(targetDocState.docContents);
1400
1401
res = await editStream.next();
1402
}
1403
})().finally(() => {
1404
if (!nextEditRequest.firstEdit.isSettled) {
1405
nextEditRequest.firstEdit.complete(Result.error(new NoNextEditReason.Uncategorized(new Error('Speculative request ended without edits'))));
1406
nextEditRequest.setResult(
1407
new StatelessNextEditResult(
1408
Result.error(new NoNextEditReason.Uncategorized(new Error('Speculative request ended without edits'))),
1409
res.value.telemetryBuilder
1410
)
1411
);
1412
logContext.markAsNoSuggestions();
1413
}
1414
});
1415
}
1416
1417
logger.trace(`speculative request completed with ${ithEdit + 1} edits`);
1418
} catch (e) {
1419
logger.trace(`speculative provider call error: ${ErrorUtils.toString(e)}`);
1420
}
1421
}
1422
1423
private static shiftSelectionAfterEditWindow(postEditContentST: StringText, editWindowOffsetRange: OffsetRange): OffsetRange {
1424
const trans = postEditContentST.getTransformer();
1425
const endOfEditWindow = trans.getPosition(editWindowOffsetRange.endExclusive - 1);
1426
const shiftedCursorLineNumber = (endOfEditWindow.lineNumber + 1 < postEditContentST.lineRange.endLineNumberExclusive
1427
? endOfEditWindow.lineNumber + 1
1428
: endOfEditWindow.lineNumber);
1429
const shiftedSelectionCursorOffset = trans.getOffset(new Position(shiftedCursorLineNumber, 1));
1430
const shiftedSelection = new OffsetRange(shiftedSelectionCursorOffset, shiftedSelectionCursorOffset);
1431
return shiftedSelection;
1432
}
1433
1434
public handleAcceptance(docId: DocumentId, suggestion: NextEditResult) {
1435
this.runSnippy(docId, suggestion);
1436
this._statelessNextEditProvider.handleAcceptance?.();
1437
this._lastOutcome = NesOutcome.Accepted;
1438
1439
const logger = this._logger.createSubLogger(suggestion.source.opportunityId.substring(4, 8)).createSubLogger('handleAcceptance');
1440
if (suggestion === this._lastNextEditResult) {
1441
logger.trace('setting shouldExpandEditWindow to true due to acceptance of last suggestion');
1442
this._shouldExpandEditWindow = true;
1443
} else {
1444
logger.trace('NOT setting shouldExpandEditWindow to true because suggestion is not the last suggestion');
1445
}
1446
}
1447
1448
public handleRejection(docId: DocumentId, suggestion: NextEditResult) {
1449
assertType(suggestion.result, '@ulugbekna: undefined edit cannot be rejected?');
1450
1451
// The user rejected the suggestion, so the speculative request (which
1452
// predicted the post-accept state) will never be reused. Cancel it to
1453
// avoid wasting a server slot.
1454
this._specManager.cancelAll(SpeculativeCancelReason.Rejected);
1455
1456
const shownDuration = Date.now() - this._lastShownTime;
1457
if (shownDuration > 1000 && suggestion.result.edit) {
1458
// we can argue that the user had the time to review this
1459
// so it wasn't an accidental rejection
1460
this._rejectionCollector.reject(docId, suggestion.result.edit);
1461
this._nextEditCache.rejectedNextEdit(suggestion.source.headerRequestId);
1462
}
1463
1464
this._lastRejectionTime = Date.now();
1465
this._lastOutcome = NesOutcome.Rejected;
1466
1467
this._statelessNextEditProvider.handleRejection?.();
1468
}
1469
1470
public handleIgnored(docId: DocumentId, suggestion: NextEditResult, supersededBy: INextEditResult | undefined): void {
1471
this._lastOutcome = NesOutcome.Ignored;
1472
1473
// Check if this was the last shown suggestion
1474
const wasShown = this._lastShownSuggestionId === suggestion.requestId;
1475
const wasSuperseded = supersededBy !== undefined;
1476
if (wasShown && !wasSuperseded) {
1477
// The shown suggestion was dismissed (not superseded by a new one),
1478
// so the speculative request for its post-accept state is useless.
1479
this._specManager.cancelAll(SpeculativeCancelReason.IgnoredDismissed);
1480
this._statelessNextEditProvider.handleIgnored?.();
1481
}
1482
// Note: the superseded case is intentionally NOT handled here. The trajectory
1483
// check on `_specManager.onActiveDocumentChanged` already cancels the
1484
// speculative iff the user's edit moved off the type-through trajectory; if
1485
// the new (superseding) suggestion is just a continuation of the old one
1486
// (e.g. typed `i` while `ibonacci` was shown → now `bonacci` is shown), the
1487
// speculative's `postEditContent` is still the right bet and we keep it.
1488
}
1489
1490
private async runSnippy(docId: DocumentId, suggestion: NextEditResult) {
1491
if (suggestion.result === undefined || suggestion.result.edit === undefined) {
1492
return;
1493
}
1494
this._snippyService.handlePostInsertion(docId.toUri(), suggestion.result.documentBeforeEdits, suggestion.result.edit);
1495
}
1496
1497
private _addLiveLogContextEntry(logContext: InlineEditRequestLogContext, debugNameOverride?: string): void {
1498
this._requestLogger.addEntry({
1499
type: LoggedRequestKind.MarkdownContentRequest,
1500
debugName: debugNameOverride ?? logContext.getDebugName(),
1501
icon: () => logContext.getIcon(),
1502
startTimeMs: logContext.time,
1503
markdownContent: () => logContext.toLogDocument(),
1504
onDidChange: logContext.onDidChange,
1505
isVisible: () => logContext.includeInLogTree,
1506
});
1507
}
1508
1509
public clearCache() {
1510
this._nextEditCache.clear();
1511
this._rejectionCollector.clear();
1512
// Any in-flight speculative would land its result into a cache that's
1513
// meant to be empty (and may be based on a now-stale model/auth/prompt).
1514
this._specManager.cancelAll(SpeculativeCancelReason.CacheCleared);
1515
}
1516
}
1517
1518
function assertDefined<T>(value: T | undefined): T {
1519
if (!value) {
1520
throw new BugIndicatingError('expected value to be defined, but it was not');
1521
}
1522
return value;
1523
}
1524
1525
export class NextEditFetchRequest {
1526
constructor(
1527
public readonly opportunityId: string,
1528
public readonly log: InlineEditRequestLogContext,
1529
public readonly providerRequestStartDateTime: number | undefined,
1530
public readonly isSpeculative: boolean,
1531
public readonly headerRequestId = generateUuid(),
1532
) {
1533
}
1534
}
1535
1536