mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-20 00:04:14 +08:00
Merge 50a0ee538ea1e85227a9e8d1da3f6fe062b8f611 into e40a68f409d79d8699d4d2d72c9d3dcc95b78abd
This commit is contained in:
commit
ea2ba46c32
@ -46,6 +46,7 @@ import { INesConfigs } from './nesConfigs';
|
||||
import { CachedOrRebasedEdit, NextEditCache } from './nextEditCache';
|
||||
import { LlmNESTelemetryBuilder, ReusedRequestKind } from './nextEditProviderTelemetry';
|
||||
import { INextEditResult, NextEditResult } from './nextEditResult';
|
||||
import { SpeculativeCancelReason, SpeculativeRequestManager } from './speculativeRequestManager';
|
||||
|
||||
/**
|
||||
* Computes a reduced window range that encompasses both the original window (shrunk by one line
|
||||
@ -167,28 +168,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
|
||||
private _pendingStatelessNextEditRequest: StatelessNextEditRequest<CachedOrRebasedEdit> | null = null;
|
||||
|
||||
/**
|
||||
* Tracks a speculative request for the post-edit document state.
|
||||
* When a suggestion is shown, we speculatively fetch the next edit as if the user had already accepted.
|
||||
* This allows reusing the in-flight request when the user actually accepts the suggestion.
|
||||
*/
|
||||
private _speculativePendingRequest: {
|
||||
request: StatelessNextEditRequest<CachedOrRebasedEdit>;
|
||||
docId: DocumentId;
|
||||
postEditContent: string;
|
||||
} | null = null;
|
||||
|
||||
/**
|
||||
* A speculative request that is deferred until the originating stream completes.
|
||||
* When a suggestion is shown while its stream is still running, we schedule the
|
||||
* speculative request here instead of firing immediately. If more edits arrive
|
||||
* from the stream, the schedule is cleared (the shown edit wasn't the last one).
|
||||
* When the stream ends, if the schedule is still present, the speculative fires.
|
||||
*/
|
||||
private _scheduledSpeculativeRequest: {
|
||||
suggestion: NextEditResult;
|
||||
headerRequestId: string;
|
||||
} | null = null;
|
||||
private readonly _specManager: SpeculativeRequestManager;
|
||||
|
||||
private _lastShownTime = 0;
|
||||
/** The requestId of the last shown suggestion. We store only the requestId (not the object) to avoid preventing garbage collection. */
|
||||
@ -230,35 +210,48 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
|
||||
this._logger = this._logService.createSubLogger(['NES', 'NextEditProvider']);
|
||||
this._nextEditCache = new NextEditCache(this._workspace, this._logService, this._configService, this._expService);
|
||||
this._specManager = this._register(new SpeculativeRequestManager(this._logger.createSubLogger('SpeculativeRequestManager')));
|
||||
|
||||
mapObservableArrayCached(this, this._workspace.openDocuments, (doc, store) => {
|
||||
store.add(runOnChange(doc.value, (value) => {
|
||||
this._cancelPendingRequestDueToDocChange(doc.id, value);
|
||||
// FIXME: don't invoke before fixing false positive cancellations
|
||||
// this._specManager.onActiveDocumentChanged(doc.id, value.value);
|
||||
}));
|
||||
// When the per-doc store is disposed, the document was removed from
|
||||
// openDocuments. Cancel any speculative targeting it — its cached result
|
||||
// would never be hit again.
|
||||
store.add(toDisposable(() => this._specManager.onDocumentClosed(doc.id)));
|
||||
}).recomputeInitiallyAndOnChange(this._store);
|
||||
}
|
||||
|
||||
private _cancelSpeculativeRequest(): void {
|
||||
this._scheduledSpeculativeRequest = null;
|
||||
if (this._speculativePendingRequest) {
|
||||
this._speculativePendingRequest.request.cancellationTokenSource.cancel();
|
||||
this._speculativePendingRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the in-flight stateless next-edit request when the document it
|
||||
* was issued for has diverged from the request's expected post-edit state.
|
||||
*
|
||||
* Invoked from the per-document `runOnChange` autorun in the constructor
|
||||
* whenever an open document's value changes. The pending request was built
|
||||
* against a specific snapshot (`documentAfterEdits`); if the user has since
|
||||
* typed something that makes the current value differ from that snapshot,
|
||||
* the result would no longer be applicable and is cancelled eagerly.
|
||||
*
|
||||
* Skipped when:
|
||||
* - the `InlineEditsAsyncCompletions` experiment is enabled (that path
|
||||
* tolerates divergence and rebases later), or
|
||||
* - there is no pending request, or
|
||||
* - the changed document is not the one the pending request targets.
|
||||
*
|
||||
* Note: this only handles the regular pending stateless request. Speculative
|
||||
* requests have their own divergence handling via
|
||||
* `SpeculativeRequestManager.onActiveDocumentChanged` (trajectory check).
|
||||
*/
|
||||
private _cancelPendingRequestDueToDocChange(docId: DocumentId, docValue: StringText) {
|
||||
// Note: we intentionally do NOT cancel the speculative request here.
|
||||
// The speculative request's postEditContent represents a *future* document state
|
||||
// (after the user would accept the suggestion), so it will almost never match the
|
||||
// current document value while the user is still typing. Cancelling here would
|
||||
// wastefully kill and recreate the speculative request on every keystroke.
|
||||
// Instead, speculative requests are cancelled by the appropriate lifecycle handlers:
|
||||
// handleRejection, handleIgnored, _triggerSpeculativeRequest, and _executeNewNextEditRequest.
|
||||
|
||||
const isAsyncCompletions = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAsyncCompletions, this._expService);
|
||||
|
||||
if (isAsyncCompletions || this._pendingStatelessNextEditRequest === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeDoc = this._pendingStatelessNextEditRequest.getActiveDocument();
|
||||
if (activeDoc.id === docId && activeDoc.documentAfterEdits.value !== docValue.value) {
|
||||
this._pendingStatelessNextEditRequest.cancellationTokenSource.cancel();
|
||||
@ -547,11 +540,12 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
&& this._pendingStatelessNextEditRequest || undefined;
|
||||
|
||||
// Check if we can reuse the speculative pending request (from when a suggestion was shown)
|
||||
const speculativeRequestMatches = this._speculativePendingRequest?.docId === curDocId
|
||||
&& this._speculativePendingRequest?.postEditContent === documentAtInvocationTime.value
|
||||
&& !this._speculativePendingRequest.request.cancellationTokenSource.token.isCancellationRequested
|
||||
&& cursorInRequestEditWindow(this._speculativePendingRequest.request);
|
||||
const speculativeRequest = speculativeRequestMatches ? this._speculativePendingRequest?.request : undefined;
|
||||
const specPending = this._specManager.pending;
|
||||
const speculativeRequestMatches = specPending?.docId === curDocId
|
||||
&& specPending?.postEditContent === documentAtInvocationTime.value
|
||||
&& !specPending.request.cancellationTokenSource.token.isCancellationRequested
|
||||
&& cursorInRequestEditWindow(specPending.request);
|
||||
const speculativeRequest = speculativeRequestMatches ? specPending?.request : undefined;
|
||||
|
||||
// Prefer speculative request if it matches (it was specifically created for this post-edit state)
|
||||
const requestToReuse = speculativeRequest ?? existingNextEditRequest;
|
||||
@ -560,8 +554,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
// Nice! No need to make another request, we can reuse the result from a pending request.
|
||||
if (speculativeRequest) {
|
||||
logger.trace(`reusing speculative pending request (opportunityId=${speculativeRequest.opportunityId}, headerRequestId=${speculativeRequest.headerRequestId})`);
|
||||
// Clear the speculative request since we're using it
|
||||
this._speculativePendingRequest = null;
|
||||
// Detach the speculative — caller is consuming it now.
|
||||
this._specManager.consumePending();
|
||||
} else {
|
||||
logger.trace(`reusing in-flight pending request (opportunityId=${requestToReuse.opportunityId}, headerRequestId=${requestToReuse.headerRequestId})`);
|
||||
}
|
||||
@ -742,17 +736,12 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
// Clear any scheduled (but not yet triggered) speculative request tied to the
|
||||
// old stream — it would otherwise fire stale when the old stream's background
|
||||
// loop calls handleStreamEnd after the stream has already been superseded.
|
||||
this._scheduledSpeculativeRequest = null;
|
||||
this._specManager.clearScheduled();
|
||||
}
|
||||
|
||||
// Cancel speculative request if it doesn't match the document/state
|
||||
// of this new request — it was built for a different document or post-edit state.
|
||||
if (this._speculativePendingRequest
|
||||
&& (this._speculativePendingRequest.docId !== curDocId
|
||||
|| this._speculativePendingRequest.postEditContent !== nextEditRequest.documentBeforeEdits.value)
|
||||
) {
|
||||
this._cancelSpeculativeRequest();
|
||||
}
|
||||
this._specManager.cancelIfMismatch(curDocId, nextEditRequest.documentBeforeEdits.value, SpeculativeCancelReason.Superseded);
|
||||
|
||||
this._pendingStatelessNextEditRequest = nextEditRequest;
|
||||
|
||||
@ -889,9 +878,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
|
||||
// Fire any scheduled speculative request — the last shown edit
|
||||
// was indeed the last edit from this stream.
|
||||
if (this._scheduledSpeculativeRequest?.headerRequestId === nextEditRequest.headerRequestId) {
|
||||
const scheduled = this._scheduledSpeculativeRequest;
|
||||
this._scheduledSpeculativeRequest = null;
|
||||
const scheduled = this._specManager.consumeScheduled(nextEditRequest.headerRequestId);
|
||||
if (scheduled) {
|
||||
void this._triggerSpeculativeRequest(scheduled.suggestion);
|
||||
}
|
||||
|
||||
@ -921,9 +909,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
|
||||
// A new edit arrived from the stream — the previously-shown
|
||||
// edit was not the last one. Clear the scheduled speculative.
|
||||
if (this._scheduledSpeculativeRequest?.headerRequestId === nextEditRequest.headerRequestId) {
|
||||
this._scheduledSpeculativeRequest = null;
|
||||
}
|
||||
this._specManager.consumeScheduled(nextEditRequest.headerRequestId);
|
||||
|
||||
res = await editStream.next();
|
||||
}
|
||||
@ -1031,7 +1017,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
this._lastShownTime = Date.now();
|
||||
this._lastShownSuggestionId = suggestion.requestId;
|
||||
this._lastOutcome = undefined; // clear so that outcome is "pending" until resolved
|
||||
this._scheduledSpeculativeRequest = null; // clear any previously scheduled speculative
|
||||
this._specManager.clearScheduled(); // clear any previously scheduled speculative
|
||||
|
||||
// Trigger speculative request for the post-edit document state
|
||||
const speculativeRequestsEnablement = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, this._expService);
|
||||
@ -1042,10 +1028,10 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
// request only fires when the stream ends with the shown edit as the last one.
|
||||
const originatingRequest = this._pendingStatelessNextEditRequest;
|
||||
if (originatingRequest && originatingRequest.headerRequestId === suggestion.source.headerRequestId) {
|
||||
this._scheduledSpeculativeRequest = {
|
||||
this._specManager.schedule({
|
||||
suggestion,
|
||||
headerRequestId: originatingRequest.headerRequestId,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
void this._triggerSpeculativeRequest(suggestion);
|
||||
}
|
||||
@ -1116,7 +1102,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
}
|
||||
|
||||
// Check if we already have a speculative request for this post-edit state
|
||||
if (this._speculativePendingRequest?.docId === docId && this._speculativePendingRequest?.postEditContent === postEditContent) {
|
||||
const existingSpec = this._specManager.pending;
|
||||
if (existingSpec?.docId === docId && existingSpec?.postEditContent === postEditContent) {
|
||||
logger.trace('already have speculative request for post-edit state');
|
||||
return;
|
||||
}
|
||||
@ -1130,8 +1117,11 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any previous speculative request
|
||||
this._cancelSpeculativeRequest();
|
||||
// Note: any previous speculative request will be cancelled (as `Replaced`)
|
||||
// by `_specManager.setPending` once the new request is actually installed —
|
||||
// see the `setPending` call at the end of this method. We deliberately do
|
||||
// not cancel earlier so the prior speculative stays available for reuse
|
||||
// while the new one is being constructed.
|
||||
|
||||
const historyContext = this._historyContextProvider.getHistoryContext(docId);
|
||||
if (!historyContext) {
|
||||
@ -1160,11 +1150,24 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
);
|
||||
|
||||
if (speculativeRequest) {
|
||||
this._speculativePendingRequest = {
|
||||
// Capture trajectory data: while the user is typing in `docId`, the
|
||||
// document is on a "type-through" trajectory iff:
|
||||
// doc = preEdit[0..editStart] + newText[0..k] + preEdit[editEnd..]
|
||||
// for some 0 <= k <= newText.length. Storing the prefix/suffix/newText
|
||||
// (already-CRLF-normalized via `result.edit.newText` whose newlines
|
||||
// match the original document) lets us check this in O(|cur|) on doc changes.
|
||||
const preEditValue = result.documentBeforeEdits.value;
|
||||
const trajectoryPrefix = preEditValue.slice(0, preciseEdit.replaceRange.start);
|
||||
const trajectorySuffix = preEditValue.slice(preciseEdit.replaceRange.endExclusive);
|
||||
const trajectoryNewText = preciseEdit.newText;
|
||||
this._specManager.setPending({
|
||||
request: speculativeRequest,
|
||||
docId,
|
||||
postEditContent,
|
||||
};
|
||||
trajectoryPrefix,
|
||||
trajectorySuffix,
|
||||
trajectoryNewText,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.trace(`speculative request failed: ${ErrorUtils.toString(e)}`);
|
||||
@ -1446,7 +1449,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
// The user rejected the suggestion, so the speculative request (which
|
||||
// predicted the post-accept state) will never be reused. Cancel it to
|
||||
// avoid wasting a server slot.
|
||||
this._cancelSpeculativeRequest();
|
||||
this._specManager.cancelAll(SpeculativeCancelReason.Rejected);
|
||||
|
||||
const shownDuration = Date.now() - this._lastShownTime;
|
||||
if (shownDuration > 1000 && suggestion.result.edit) {
|
||||
@ -1471,9 +1474,15 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
if (wasShown && !wasSuperseded) {
|
||||
// The shown suggestion was dismissed (not superseded by a new one),
|
||||
// so the speculative request for its post-accept state is useless.
|
||||
this._cancelSpeculativeRequest();
|
||||
this._specManager.cancelAll(SpeculativeCancelReason.IgnoredDismissed);
|
||||
this._statelessNextEditProvider.handleIgnored?.();
|
||||
}
|
||||
// Note: the superseded case is intentionally NOT handled here. The trajectory
|
||||
// check on `_specManager.onActiveDocumentChanged` already cancels the
|
||||
// speculative iff the user's edit moved off the type-through trajectory; if
|
||||
// the new (superseding) suggestion is just a continuation of the old one
|
||||
// (e.g. typed `i` while `ibonacci` was shown → now `bonacci` is shown), the
|
||||
// speculative's `postEditContent` is still the right bet and we keep it.
|
||||
}
|
||||
|
||||
private async runSnippy(docId: DocumentId, suggestion: NextEditResult) {
|
||||
@ -1498,6 +1507,9 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
|
||||
public clearCache() {
|
||||
this._nextEditCache.clear();
|
||||
this._rejectionCollector.clear();
|
||||
// Any in-flight speculative would land its result into a cache that's
|
||||
// meant to be empty (and may be based on a now-stale model/auth/prompt).
|
||||
this._specManager.cancelAll(SpeculativeCancelReason.CacheCleared);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';
|
||||
import { StatelessNextEditRequest } from '../../../platform/inlineEdits/common/statelessNextEditProvider';
|
||||
import { ILogger } from '../../../platform/log/common/logService';
|
||||
import { Disposable } from '../../../util/vs/base/common/lifecycle';
|
||||
import { CachedOrRebasedEdit } from './nextEditCache';
|
||||
import { NextEditResult } from './nextEditResult';
|
||||
|
||||
/**
|
||||
* Reasons why a speculative request was cancelled. Recorded on the request's
|
||||
* log context so each cancellation has an attributable cause.
|
||||
*/
|
||||
export const enum SpeculativeCancelReason {
|
||||
|
||||
/** The originating suggestion was rejected by the user. */
|
||||
Rejected = 'rejected',
|
||||
|
||||
/** The originating suggestion was dismissed without being superseded. */
|
||||
IgnoredDismissed = 'ignoredDismissed',
|
||||
|
||||
/** A new fetch is starting whose `(docId, postEditContent)` doesn't match. */
|
||||
Superseded = 'superseded',
|
||||
|
||||
/** A newer speculative is being installed in this slot. */
|
||||
Replaced = 'replaced',
|
||||
|
||||
/** The user's edits moved off the type-through trajectory toward `postEditContent`. */
|
||||
DivergedFromTrajectoryForm = 'divergedFromTrajectoryForm',
|
||||
DivergedFromTrajectoryPrefix = 'divergedFromTrajectoryPrefix',
|
||||
DivergedFromTrajectoryMiddle = 'divergedFromTrajectoryMiddle',
|
||||
DivergedFromTrajectorySuffix = 'divergedFromTrajectorySuffix',
|
||||
|
||||
/** `clearCache()` was invoked. */
|
||||
CacheCleared = 'cacheCleared',
|
||||
|
||||
/** The target document was removed from the workspace. */
|
||||
DocumentClosed = 'documentClosed',
|
||||
|
||||
/** The provider was disposed. */
|
||||
Disposed = 'disposed',
|
||||
}
|
||||
|
||||
export interface SpeculativePendingRequest {
|
||||
readonly request: StatelessNextEditRequest<CachedOrRebasedEdit>;
|
||||
readonly docId: DocumentId;
|
||||
readonly postEditContent: string;
|
||||
/** preEditDocument[0..editStart] — the doc text before the edit window. */
|
||||
readonly trajectoryPrefix: string;
|
||||
/** preEditDocument[editEnd..] — the doc text after the edit window. */
|
||||
readonly trajectorySuffix: string;
|
||||
/** The replacement text the user would type to reach `postEditContent`. */
|
||||
readonly trajectoryNewText: string;
|
||||
}
|
||||
|
||||
export interface ScheduledSpeculativeRequest {
|
||||
readonly suggestion: NextEditResult;
|
||||
readonly headerRequestId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the lifecycle of NES speculative requests:
|
||||
*
|
||||
* - the in-flight `pending` speculative (the bet on a specific post-accept document state)
|
||||
* - the `scheduled` speculative deferred until its originating stream completes
|
||||
*
|
||||
* Centralizes cancellation with typed reasons so every triggered cancellation
|
||||
* (reject, supersede, doc-close, trajectory divergence, dispose, ...) goes through
|
||||
* one path and is logged on the request's log context.
|
||||
*/
|
||||
export class SpeculativeRequestManager extends Disposable {
|
||||
|
||||
private _pending: SpeculativePendingRequest | null = null;
|
||||
private _scheduled: ScheduledSpeculativeRequest | null = null;
|
||||
|
||||
constructor(private readonly _logger: ILogger) {
|
||||
super();
|
||||
}
|
||||
|
||||
get pending(): SpeculativePendingRequest | null {
|
||||
return this._pending;
|
||||
}
|
||||
|
||||
/** Replaces the current pending speculative; cancels the prior one as `Replaced`. */
|
||||
setPending(req: SpeculativePendingRequest): void {
|
||||
if (this._pending && this._pending.request !== req.request) {
|
||||
this._cancelPending(SpeculativeCancelReason.Replaced);
|
||||
}
|
||||
this._pending = req;
|
||||
}
|
||||
|
||||
/** Detaches the pending speculative without cancelling — caller is consuming it. */
|
||||
consumePending(): void {
|
||||
this._pending = null;
|
||||
}
|
||||
|
||||
schedule(s: ScheduledSpeculativeRequest): void {
|
||||
this._scheduled = s;
|
||||
}
|
||||
|
||||
clearScheduled(): void {
|
||||
this._scheduled = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and returns the scheduled entry iff its `headerRequestId` matches.
|
||||
* Used by the streaming path so that each stream only ever consumes its own
|
||||
* schedule, never another stream's.
|
||||
*/
|
||||
consumeScheduled(headerRequestId: string): ScheduledSpeculativeRequest | null {
|
||||
if (this._scheduled?.headerRequestId !== headerRequestId) {
|
||||
return null;
|
||||
}
|
||||
const s = this._scheduled;
|
||||
this._scheduled = null;
|
||||
return s;
|
||||
}
|
||||
|
||||
cancelAll(reason: SpeculativeCancelReason): void {
|
||||
this._scheduled = null;
|
||||
this._cancelPending(reason);
|
||||
}
|
||||
|
||||
/** Cancels the pending speculative iff `(docId, postEditContent)` doesn't match. */
|
||||
cancelIfMismatch(docId: DocumentId, postEditContent: string, reason: SpeculativeCancelReason): void {
|
||||
if (this._pending && (this._pending.docId !== docId || this._pending.postEditContent !== postEditContent)) {
|
||||
this._cancelPending(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels the pending and clears any scheduled targeting this document. */
|
||||
onDocumentClosed(docId: DocumentId): void {
|
||||
if (this._scheduled?.suggestion.result?.targetDocumentId === docId) {
|
||||
this._scheduled = null;
|
||||
}
|
||||
if (this._pending?.docId === docId) {
|
||||
this._cancelPending(SpeculativeCancelReason.DocumentClosed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trajectory check. The pending speculative is alive iff the current document
|
||||
* value is a *type-through prefix* toward the speculative's `postEditContent`:
|
||||
*
|
||||
* cur === trajectoryPrefix + middle + trajectorySuffix
|
||||
* where middle is some prefix of trajectoryNewText
|
||||
*
|
||||
* If not, the user's edits cannot reach `postEditContent` via continued typing
|
||||
* and the speculative will never be consumed — cancel now.
|
||||
*/
|
||||
onActiveDocumentChanged(docId: DocumentId, currentDocValue: string): void {
|
||||
const p = this._pending;
|
||||
if (!p || p.docId !== docId) {
|
||||
return;
|
||||
}
|
||||
// Cheap structural failure: doc shorter than the unedited frame.
|
||||
if (currentDocValue.length < p.trajectoryPrefix.length + p.trajectorySuffix.length) {
|
||||
this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectoryForm);
|
||||
return;
|
||||
}
|
||||
if (!currentDocValue.startsWith(p.trajectoryPrefix)) {
|
||||
this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectoryPrefix);
|
||||
return;
|
||||
}
|
||||
if (!currentDocValue.endsWith(p.trajectorySuffix)) {
|
||||
this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectorySuffix);
|
||||
return;
|
||||
}
|
||||
const middle = currentDocValue.slice(p.trajectoryPrefix.length, currentDocValue.length - p.trajectorySuffix.length);
|
||||
if (!p.trajectoryNewText.startsWith(middle)) {
|
||||
this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectoryMiddle);
|
||||
}
|
||||
}
|
||||
|
||||
private _cancelPending(reason: SpeculativeCancelReason): void {
|
||||
const p = this._pending;
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
this._pending = null;
|
||||
const headerRequestId = p.request.headerRequestId;
|
||||
this._logger.trace(`cancelling speculative request: ${reason} (headerRequestId=${headerRequestId})`);
|
||||
p.request.logContext.addLog(`speculative request cancelled: ${reason}`);
|
||||
const cts = p.request.cancellationTokenSource;
|
||||
cts.cancel();
|
||||
// Dispose to release the cancel-event listeners that the in-flight
|
||||
// provider call hooked onto the token. Safe even though the runner may
|
||||
// observe cancellation asynchronously — `cancel()` already fired the event.
|
||||
cts.dispose();
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.cancelAll(SpeculativeCancelReason.Disposed);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@ -18,8 +18,8 @@ import { EditStreamingWithTelemetry, IStatelessNextEditProvider, NoNextEditReaso
|
||||
import { NesHistoryContextProvider } from '../../../../platform/inlineEdits/common/workspaceEditTracker/nesHistoryContextProvider';
|
||||
import { NesXtabHistoryTracker } from '../../../../platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker';
|
||||
import { ILogger, ILogService, LogServiceImpl } from '../../../../platform/log/common/logService';
|
||||
import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';
|
||||
import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger';
|
||||
import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';
|
||||
import { ISnippyService, NullSnippyService } from '../../../../platform/snippy/common/snippyService';
|
||||
import { IExperimentationService, NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';
|
||||
import { mockNotebookService } from '../../../../platform/test/common/testNotebookService';
|
||||
@ -417,7 +417,7 @@ describe('NextEditProvider speculative requests', () => {
|
||||
await statelessProvider.calls[1].completed.p;
|
||||
});
|
||||
|
||||
it('does not cancel speculative request when active document diverges from expected post-edit state', async () => {
|
||||
it('cancels speculative request when active document edit moves off the type-through trajectory', async () => {
|
||||
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
|
||||
|
||||
const statelessProvider = new TestStatelessNextEditProvider();
|
||||
@ -436,30 +436,28 @@ describe('NextEditProvider speculative requests', () => {
|
||||
nextEditProvider.handleShown(suggestion);
|
||||
await statelessProvider.waitForCall(2);
|
||||
|
||||
// Editing the active document should NOT cancel the speculative request.
|
||||
// The speculative request targets a future post-edit state, not the current
|
||||
// document value, so keystroke-level changes should not invalidate it.
|
||||
// Inserting at the start of the document breaks the trajectory's prefix
|
||||
// (the doc no longer starts with `pre[0..editStart]`). The speculative
|
||||
// can no longer be reached via type-through-then-accept — cancel.
|
||||
doc.applyEdit(StringEdit.insert(0, '/* diverged */\n'));
|
||||
await flushMicrotasks();
|
||||
await statelessProvider.calls[1].cancellationRequested.p;
|
||||
|
||||
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
|
||||
|
||||
// Clean up: reject so the speculative request gets cancelled properly
|
||||
nextEditProvider.handleRejection(doc.id, suggestion);
|
||||
await statelessProvider.calls[1].completed.p;
|
||||
expect(statelessProvider.calls[1].wasCancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps speculative request alive when user types in the active document', async () => {
|
||||
it('keeps speculative alive while user types characters of the suggestion (type-through)', async () => {
|
||||
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
|
||||
|
||||
const statelessProvider = new TestStatelessNextEditProvider();
|
||||
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
|
||||
// Suggestion inserts `'barbaz'` between `'foo'` and `'();'`.
|
||||
// Resulting precise edit: replace [3, 3) with 'barbaz' (a pure insertion).
|
||||
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'foobarbaz();') });
|
||||
statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });
|
||||
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
|
||||
|
||||
const doc = workspace.addDocument({
|
||||
id: DocumentId.create(URI.file('/test/spec-typing.ts').toString()),
|
||||
initialValue: 'const value = 1;\nconsole.log(value);',
|
||||
initialValue: 'foo();\nconsole.log();',
|
||||
});
|
||||
doc.setSelection([new OffsetRange(0, 0)], undefined);
|
||||
|
||||
@ -468,23 +466,28 @@ describe('NextEditProvider speculative requests', () => {
|
||||
nextEditProvider.handleShown(suggestion);
|
||||
await statelessProvider.waitForCall(2);
|
||||
|
||||
// Simulate multiple keystrokes in the active document while the speculative
|
||||
// request is in flight — none of them should cancel it.
|
||||
doc.applyEdit(StringEdit.insert(0, 'a'));
|
||||
// User types characters of the suggestion at the edit position — each
|
||||
// keystroke keeps the document on a type-through trajectory toward
|
||||
// `postEditContent`, so the speculative must NOT be cancelled.
|
||||
doc.applyEdit(StringEdit.insert(3, 'b'));
|
||||
await flushMicrotasks();
|
||||
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
|
||||
|
||||
doc.applyEdit(StringEdit.insert(1, 'b'));
|
||||
doc.applyEdit(StringEdit.insert(4, 'a'));
|
||||
await flushMicrotasks();
|
||||
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
|
||||
|
||||
doc.applyEdit(StringEdit.insert(2, 'c'));
|
||||
doc.applyEdit(StringEdit.insert(5, 'r'));
|
||||
await flushMicrotasks();
|
||||
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
|
||||
|
||||
// Clean up via rejection
|
||||
nextEditProvider.handleRejection(doc.id, suggestion);
|
||||
await statelessProvider.calls[1].completed.p;
|
||||
// Now the user types a character that doesn't match the suggestion's
|
||||
// next character (`'b'` would be expected; they typed `'X'`). The
|
||||
// trajectory is broken — cancel.
|
||||
doc.applyEdit(StringEdit.insert(6, 'X'));
|
||||
await statelessProvider.calls[1].cancellationRequested.p;
|
||||
|
||||
expect(statelessProvider.calls[1].wasCancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels mismatched speculative request when starting a request for another document', async () => {
|
||||
@ -1370,4 +1373,83 @@ describe('NextEditProvider speculative requests', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle cancellation', () => {
|
||||
it('cancels in-flight speculative when clearCache() is called', async () => {
|
||||
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
|
||||
|
||||
const statelessProvider = new TestStatelessNextEditProvider();
|
||||
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
|
||||
statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });
|
||||
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
|
||||
|
||||
const doc = workspace.addDocument({
|
||||
id: DocumentId.create(URI.file('/test/spec-clear-cache.ts').toString()),
|
||||
initialValue: 'const value = 1;\nconsole.log(value);',
|
||||
});
|
||||
doc.setSelection([new OffsetRange(0, 0)], undefined);
|
||||
|
||||
const suggestion = await getNextEdit(nextEditProvider, doc.id);
|
||||
assert(suggestion.result?.edit);
|
||||
nextEditProvider.handleShown(suggestion);
|
||||
await statelessProvider.waitForCall(2);
|
||||
|
||||
nextEditProvider.clearCache();
|
||||
await statelessProvider.calls[1].cancellationRequested.p;
|
||||
|
||||
expect(statelessProvider.calls[1].wasCancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels in-flight speculative when its target document is closed', async () => {
|
||||
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
|
||||
|
||||
const statelessProvider = new TestStatelessNextEditProvider();
|
||||
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
|
||||
statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });
|
||||
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
|
||||
|
||||
const doc = workspace.addDocument({
|
||||
id: DocumentId.create(URI.file('/test/spec-doc-close.ts').toString()),
|
||||
initialValue: 'const value = 1;\nconsole.log(value);',
|
||||
});
|
||||
doc.setSelection([new OffsetRange(0, 0)], undefined);
|
||||
|
||||
const suggestion = await getNextEdit(nextEditProvider, doc.id);
|
||||
assert(suggestion.result?.edit);
|
||||
nextEditProvider.handleShown(suggestion);
|
||||
await statelessProvider.waitForCall(2);
|
||||
|
||||
// Closing the document removes it from openDocuments — the speculative's
|
||||
// cached result would never be hit again, so cancel it.
|
||||
doc.dispose();
|
||||
await statelessProvider.calls[1].cancellationRequested.p;
|
||||
|
||||
expect(statelessProvider.calls[1].wasCancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels in-flight speculative when the provider is disposed', async () => {
|
||||
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
|
||||
|
||||
const statelessProvider = new TestStatelessNextEditProvider();
|
||||
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
|
||||
statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });
|
||||
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
|
||||
|
||||
const doc = workspace.addDocument({
|
||||
id: DocumentId.create(URI.file('/test/spec-provider-dispose.ts').toString()),
|
||||
initialValue: 'const value = 1;\nconsole.log(value);',
|
||||
});
|
||||
doc.setSelection([new OffsetRange(0, 0)], undefined);
|
||||
|
||||
const suggestion = await getNextEdit(nextEditProvider, doc.id);
|
||||
assert(suggestion.result?.edit);
|
||||
nextEditProvider.handleShown(suggestion);
|
||||
await statelessProvider.waitForCall(2);
|
||||
|
||||
nextEditProvider.dispose();
|
||||
await statelessProvider.calls[1].cancellationRequested.p;
|
||||
|
||||
expect(statelessProvider.calls[1].wasCancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user