This commit is contained in:
ulugbekna 2026-04-17 23:38:28 +02:00
parent 50f36fc4ff
commit 401fe38d71
No known key found for this signature in database
GPG Key ID: 0FF4AC049C88703E
3 changed files with 634 additions and 95 deletions

View File

@ -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,47 @@ 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);
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 +539,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 +553,11 @@ 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;
// Note: the speculative slot stays populated. Multiple in-flight
// `provideNextEdit` invocations targeting the same `(docId, postEditContent)`
// can all join this stream (mirroring `_pendingStatelessNextEditRequest`).
// The slot is cleared automatically when the underlying request settles —
// see `SpeculativeRequestManager.setPending`.
} else {
logger.trace(`reusing in-flight pending request (opportunityId=${requestToReuse.opportunityId}, headerRequestId=${requestToReuse.headerRequestId})`);
}
@ -742,17 +738,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 +880,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 +911,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 +1019,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 +1030,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 +1104,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 +1119,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 +1152,26 @@ 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;
const trajectoryOldTextLen = preciseEdit.replaceRange.length;
this._specManager.setPending({
request: speculativeRequest,
docId,
postEditContent,
};
trajectoryPrefix,
trajectorySuffix,
trajectoryNewText,
trajectoryOldTextLen,
});
}
} catch (e) {
logger.trace(`speculative request failed: ${ErrorUtils.toString(e)}`);
@ -1446,7 +1453,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 +1478,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 +1511,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);
}
}

View File

@ -0,0 +1,361 @@
/*---------------------------------------------------------------------------------------------
* 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`. */
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;
/**
* Length of the text replaced by the suggestion (preEdit[editStart..editEnd]).
* The type-through trajectory check only models pure insertions; for
* substitutions/removals (`trajectoryOldTextLen > 0`) the check is skipped
* and the speculative is kept alive until another lifecycle trigger fires.
*/
readonly trajectoryOldTextLen: number;
}
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`.
*
* The slot stays populated until the request is explicitly cancelled or
* superseded multiple `provideNextEdit` invocations targeting the same
* `(docId, postEditContent)` can all join the same in-flight stream,
* mirroring `_pendingStatelessNextEditRequest`'s dedupe behavior.
* Once the speculative settles, `_nextEditCache` covers further reuse;
* the now-stale slot is cleared by the next `setPending` / `cancelAll`
* / `cancelIfMismatch` / trajectory-divergence trigger.
*/
setPending(req: SpeculativePendingRequest): void {
if (this._pending && this._pending.request !== req.request) {
this._cancelPending(SpeculativeCancelReason.Replaced);
}
this._pending = req;
// Record the trajectory data on the request's own log context so any
// subsequent cancellation diagnostic can be cross-referenced against
// the state captured at speculative creation time.
req.request.logContext.addLog('speculative request installed (trajectory captured)');
req.request.logContext.addCodeblockToLog(JSON.stringify({
docId: req.docId.toString(),
postEditContentLen: req.postEditContent.length,
trajectoryPrefixLen: req.trajectoryPrefix.length,
trajectoryPrefixHead: truncate(req.trajectoryPrefix.slice(0, 80), 80),
trajectoryPrefixTail: truncate(req.trajectoryPrefix.slice(Math.max(0, req.trajectoryPrefix.length - 80)), 80),
trajectorySuffixLen: req.trajectorySuffix.length,
trajectorySuffixHead: truncate(req.trajectorySuffix.slice(0, 80), 80),
trajectorySuffixTail: truncate(req.trajectorySuffix.slice(Math.max(0, req.trajectorySuffix.length - 80)), 80),
trajectoryNewText: truncate(req.trajectoryNewText, 200),
trajectoryNewTextLen: req.trajectoryNewText.length,
trajectoryOldTextLen: req.trajectoryOldTextLen,
trajectoryTracked: req.trajectoryOldTextLen === 0,
}, null, 2), 'json');
}
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) { return; }
if (this._pending.docId === docId && this._pending.postEditContent === postEditContent) { return; }
this._cancelPending(reason, {
pendingDocId: this._pending.docId.toString(),
incomingDocId: docId.toString(),
pendingPostEditContentLen: this._pending.postEditContent.length,
incomingPostEditContentLen: postEditContent.length,
docIdMatches: this._pending.docId === docId,
postEditContentMatches: this._pending.postEditContent === postEditContent,
...(this._pending.postEditContent !== postEditContent
? { mismatch: describeStringMismatch(this._pending.postEditContent, postEditContent) }
: {}),
});
}
/** 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;
}
const check = checkTrajectory(p, currentDocValue);
if (check.ok) {
return;
}
this._cancelPending(check.reason, check.details);
}
private _cancelPending(reason: SpeculativeCancelReason, diagnostic?: Record<string, unknown>): 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}\``);
// Always include the request's own trajectory metadata so the log entry
// is self-contained — the reader doesn't need to cross-reference the
// originating `setPending` call.
const payload: Record<string, unknown> = {
reason,
docId: p.docId.toString(),
postEditContentLen: p.postEditContent.length,
trajectoryPrefixLen: p.trajectoryPrefix.length,
trajectorySuffixLen: p.trajectorySuffix.length,
trajectoryNewText: truncate(p.trajectoryNewText, 200),
trajectoryNewTextLen: p.trajectoryNewText.length,
...(diagnostic ?? {}),
};
p.request.logContext.addCodeblockToLog(JSON.stringify(payload, null, 2), 'json');
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();
}
}
type TrajectoryCheckResult =
| { ok: true }
| {
ok: false;
reason: SpeculativeCancelReason;
details: Record<string, unknown>;
};
/**
* Checks whether `currentDocValue` is on the type-through trajectory toward
* `p.postEditContent`. Returns a structured result so the cancellation site
* can record exactly what mismatched.
*/
function checkTrajectory(p: SpeculativePendingRequest, currentDocValue: string): TrajectoryCheckResult {
// The type-through trajectory model only fits pure insertions: starting from
// preEdit (`prefix + "" + suffix`), the user types characters of `newText`
// into the gap. For substitutions/removals (oldText non-empty), the initial
// state already has `oldText` in the gap, which is generally not a prefix of
// `newText`, so the check would over-cancel on the first keystroke. Skip it.
if (p.trajectoryOldTextLen > 0) {
return { ok: true };
}
const minLen = p.trajectoryPrefix.length + p.trajectorySuffix.length;
// Cheap structural failure: doc shorter than the unedited frame.
if (currentDocValue.length < minLen) {
return {
ok: false,
reason: SpeculativeCancelReason.DivergedFromTrajectoryPrefix,
details: {
explanation: 'currentDocValue is shorter than trajectoryPrefix + trajectorySuffix',
currentDocLen: currentDocValue.length,
minRequiredLen: minLen,
docHead: truncate(currentDocValue, 200),
},
};
}
if (!currentDocValue.startsWith(p.trajectoryPrefix)) {
return {
ok: false,
reason: SpeculativeCancelReason.DivergedFromTrajectoryMiddle,
details: {
explanation: 'currentDocValue does not start with trajectoryPrefix',
currentDocLen: currentDocValue.length,
...describeStringMismatch(p.trajectoryPrefix, currentDocValue.slice(0, p.trajectoryPrefix.length)),
},
};
}
if (!currentDocValue.endsWith(p.trajectorySuffix)) {
return {
ok: false,
reason: SpeculativeCancelReason.DivergedFromTrajectoryMiddle,
details: {
explanation: 'currentDocValue does not end with trajectorySuffix',
currentDocLen: currentDocValue.length,
...describeStringMismatch(
p.trajectorySuffix,
currentDocValue.slice(currentDocValue.length - p.trajectorySuffix.length),
),
},
};
}
const middle = currentDocValue.slice(p.trajectoryPrefix.length, currentDocValue.length - p.trajectorySuffix.length);
if (!p.trajectoryNewText.startsWith(middle)) {
return {
ok: false,
reason: SpeculativeCancelReason.DivergedFromTrajectorySuffix,
details: {
explanation: 'middle is not a prefix of trajectoryNewText',
middle: truncate(middle, 200),
middleLen: middle.length,
...describeStringMismatch(p.trajectoryNewText.slice(0, middle.length), middle),
},
};
}
return { ok: true };
}
/**
* Compares two strings character-by-character and returns the first index at
* which they differ, plus a small window of context around it.
*/
function describeStringMismatch(expected: string, actual: string): Record<string, unknown> {
const minLen = Math.min(expected.length, actual.length);
let i = 0;
while (i < minLen && expected.charCodeAt(i) === actual.charCodeAt(i)) {
i++;
}
const ctxStart = Math.max(0, i - 20);
const ctxEnd = i + 20;
return {
firstMismatchAt: i,
expectedLen: expected.length,
actualLen: actual.length,
expectedAround: visualize(expected.slice(ctxStart, ctxEnd)),
actualAround: visualize(actual.slice(ctxStart, ctxEnd)),
expectedCharAtMismatch: i < expected.length ? toCharRepr(expected.charCodeAt(i)) : '<EOF>',
actualCharAtMismatch: i < actual.length ? toCharRepr(actual.charCodeAt(i)) : '<EOF>',
};
}
function toCharRepr(code: number): string {
return `U+${code.toString(16).toUpperCase().padStart(4, '0')} (${JSON.stringify(String.fromCharCode(code))})`;
}
function visualize(s: string): string {
return s.replace(/\r/g, '␍').replace(/\n/g, '␊').replace(/\t/g, '␉');
}
function truncate(s: string, max: number): string {
if (s.length <= max) {
return visualize(s);
}
return visualize(s.slice(0, max)) + `…(+${s.length - max} chars)`;
}

View File

@ -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';
@ -333,6 +333,46 @@ describe('NextEditProvider speculative requests', () => {
expect(secondSuggestion.result.edit.newText).toBe('console.log(value + 1);');
});
it('lets multiple parallel provideNextEdit calls reuse the same speculative request', async () => {
// Mirrors the dedupe behavior of `_pendingStatelessNextEditRequest`: once a
// speculative request is in-flight for `(docId, postEditContent)`, multiple
// `provideNextEdit` invocations targeting that same state should join it
// rather than triggering additional stateless calls.
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: 'yieldEditThenNoSuggestions', edit: lineReplacement(2, 'console.log(value + 1);') });
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
const doc = workspace.addDocument({
id: DocumentId.create(URI.file('/test/spec-multi.ts').toString()),
initialValue: 'const value = 1;\nconsole.log(value);',
});
doc.setSelection([new OffsetRange(0, 0)], undefined);
const firstSuggestion = await getNextEdit(nextEditProvider, doc.id);
assert(firstSuggestion.result?.edit);
nextEditProvider.handleShown(firstSuggestion);
await statelessProvider.waitForCall(2);
await statelessProvider.calls[1].completed.p;
nextEditProvider.handleAcceptance(doc.id, firstSuggestion);
doc.applyEdit(firstSuggestion.result.edit.toEdit());
// Two parallel reuses of the same speculative — neither should trigger a new stateless call.
const [a, b] = await Promise.all([
getNextEdit(nextEditProvider, doc.id),
getNextEdit(nextEditProvider, doc.id),
]);
expect(statelessProvider.calls.length).toBe(2);
assert(a.result?.edit);
assert(b.result?.edit);
expect(a.result.edit.newText).toBe('console.log(value + 1);');
expect(b.result.edit.newText).toBe('console.log(value + 1);');
});
it('cancels speculative request on rejection', async () => {
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
@ -417,17 +457,20 @@ 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();
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
// Pure-insertion suggestion (replace `'foo();'` with `'foobar();'` is
// minimised to inserting `'bar'` at offset 3) — the trajectory check
// only applies to insertions.
statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'foobar();') });
statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
const doc = workspace.addDocument({
id: DocumentId.create(URI.file('/test/spec-diverge.ts').toString()),
initialValue: 'const value = 1;\nconsole.log(value);',
initialValue: 'foo();\nconsole.log();',
});
doc.setSelection([new OffsetRange(0, 0)], undefined);
@ -436,29 +479,76 @@ 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: 'foo();\nconsole.log();',
});
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);
// 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(4, 'a'));
await flushMicrotasks();
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
doc.applyEdit(StringEdit.insert(5, 'r'));
await flushMicrotasks();
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
// 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('does not cancel speculative for substitution suggestions on user edits (trajectory check skipped)', async () => {
// The type-through trajectory check only models pure insertions. For
// substitutions the live document still contains the to-be-replaced
// `oldText` in the gap, which is generally not a prefix of `newText`
// so a naive check would over-cancel on the first keystroke. Verify
// substitution speculatives stay alive across user edits.
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
const statelessProvider = new TestStatelessNextEditProvider();
// Suggestion: replace `'const value = 1;'` with `'const value = 2;'` (substitution).
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-substitution.ts').toString()),
initialValue: 'const value = 1;\nconsole.log(value);',
});
doc.setSelection([new OffsetRange(0, 0)], undefined);
@ -468,21 +558,14 @@ 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'));
// Make a small unrelated edit further down — for a pure-insertion
// trajectory this would diverge, but for a substitution the
// trajectory check is skipped and the speculative must stay alive.
doc.applyEdit(StringEdit.insert(doc.value.get().value.length, '\n// extra'));
await flushMicrotasks();
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
doc.applyEdit(StringEdit.insert(1, 'b'));
await flushMicrotasks();
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
doc.applyEdit(StringEdit.insert(2, 'c'));
await flushMicrotasks();
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
// Clean up via rejection
nextEditProvider.handleRejection(doc.id, suggestion);
await statelessProvider.calls[1].completed.p;
});
@ -1370,4 +1453,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);
});
});
});