Path: blob/main/extensions/copilot/src/extension/inlineEdits/node/speculativeRequestManager.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';6import { StatelessNextEditRequest } from '../../../platform/inlineEdits/common/statelessNextEditProvider';7import { ILogger } from '../../../platform/log/common/logService';8import { Disposable } from '../../../util/vs/base/common/lifecycle';9import { CachedOrRebasedEdit } from './nextEditCache';10import { NextEditResult } from './nextEditResult';1112/**13* Reasons why a speculative request was cancelled. Recorded on the request's14* log context so each cancellation has an attributable cause.15*/16export const enum SpeculativeCancelReason {1718/** The originating suggestion was rejected by the user. */19Rejected = 'rejected',2021/** The originating suggestion was dismissed without being superseded. */22IgnoredDismissed = 'ignoredDismissed',2324/** A new fetch is starting whose `(docId, postEditContent)` doesn't match. */25Superseded = 'superseded',2627/** A newer speculative is being installed in this slot. */28Replaced = 'replaced',2930/** The user's edits moved off the type-through trajectory toward `postEditContent`. */31DivergedFromTrajectoryForm = 'divergedFromTrajectoryForm',32DivergedFromTrajectoryPrefix = 'divergedFromTrajectoryPrefix',33DivergedFromTrajectoryMiddle = 'divergedFromTrajectoryMiddle',34DivergedFromTrajectorySuffix = 'divergedFromTrajectorySuffix',3536/** `clearCache()` was invoked. */37CacheCleared = 'cacheCleared',3839/** The target document was removed from the workspace. */40DocumentClosed = 'documentClosed',4142/** The provider was disposed. */43Disposed = 'disposed',44}4546export interface SpeculativePendingRequest {47readonly request: StatelessNextEditRequest<CachedOrRebasedEdit>;48readonly docId: DocumentId;49readonly postEditContent: string;50/** preEditDocument[0..editStart] — the doc text before the edit window. */51readonly trajectoryPrefix: string;52/** preEditDocument[editEnd..] — the doc text after the edit window. */53readonly trajectorySuffix: string;54/** The replacement text the user would type to reach `postEditContent`. */55readonly trajectoryNewText: string;56}5758export interface ScheduledSpeculativeRequest {59readonly suggestion: NextEditResult;60readonly headerRequestId: string;61}6263/**64* Owns the lifecycle of NES speculative requests:65*66* - the in-flight `pending` speculative (the bet on a specific post-accept document state)67* - the `scheduled` speculative deferred until its originating stream completes68*69* Centralizes cancellation with typed reasons so every triggered cancellation70* (reject, supersede, doc-close, trajectory divergence, dispose, ...) goes through71* one path and is logged on the request's log context.72*/73export class SpeculativeRequestManager extends Disposable {7475private _pending: SpeculativePendingRequest | null = null;76private _scheduled: ScheduledSpeculativeRequest | null = null;7778constructor(private readonly _logger: ILogger) {79super();80}8182get pending(): SpeculativePendingRequest | null {83return this._pending;84}8586/** Replaces the current pending speculative; cancels the prior one as `Replaced`. */87setPending(req: SpeculativePendingRequest): void {88if (this._pending && this._pending.request !== req.request) {89this._cancelPending(SpeculativeCancelReason.Replaced);90}91this._pending = req;92}9394/** Detaches the pending speculative without cancelling — caller is consuming it. */95consumePending(): void {96this._pending = null;97}9899schedule(s: ScheduledSpeculativeRequest): void {100this._scheduled = s;101}102103clearScheduled(): void {104this._scheduled = null;105}106107/**108* Removes and returns the scheduled entry iff its `headerRequestId` matches.109* Used by the streaming path so that each stream only ever consumes its own110* schedule, never another stream's.111*/112consumeScheduled(headerRequestId: string): ScheduledSpeculativeRequest | null {113if (this._scheduled?.headerRequestId !== headerRequestId) {114return null;115}116const s = this._scheduled;117this._scheduled = null;118return s;119}120121cancelAll(reason: SpeculativeCancelReason): void {122this._scheduled = null;123this._cancelPending(reason);124}125126/** Cancels the pending speculative iff `(docId, postEditContent)` doesn't match. */127cancelIfMismatch(docId: DocumentId, postEditContent: string, reason: SpeculativeCancelReason): void {128if (this._pending && (this._pending.docId !== docId || this._pending.postEditContent !== postEditContent)) {129this._cancelPending(reason);130}131}132133/** Cancels the pending and clears any scheduled targeting this document. */134onDocumentClosed(docId: DocumentId): void {135if (this._scheduled?.suggestion.result?.targetDocumentId === docId) {136this._scheduled = null;137}138if (this._pending?.docId === docId) {139this._cancelPending(SpeculativeCancelReason.DocumentClosed);140}141}142143/**144* Trajectory check. The pending speculative is alive iff the current document145* value is a *type-through prefix* toward the speculative's `postEditContent`:146*147* cur === trajectoryPrefix + middle + trajectorySuffix148* where middle is some prefix of trajectoryNewText149*150* If not, the user's edits cannot reach `postEditContent` via continued typing151* and the speculative will never be consumed — cancel now.152*/153onActiveDocumentChanged(docId: DocumentId, currentDocValue: string): void {154const p = this._pending;155if (!p || p.docId !== docId) {156return;157}158// Cheap structural failure: doc shorter than the unedited frame.159if (currentDocValue.length < p.trajectoryPrefix.length + p.trajectorySuffix.length) {160this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectoryForm);161return;162}163if (!currentDocValue.startsWith(p.trajectoryPrefix)) {164this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectoryPrefix);165return;166}167if (!currentDocValue.endsWith(p.trajectorySuffix)) {168this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectorySuffix);169return;170}171const middle = currentDocValue.slice(p.trajectoryPrefix.length, currentDocValue.length - p.trajectorySuffix.length);172if (!p.trajectoryNewText.startsWith(middle)) {173this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectoryMiddle);174}175}176177private _cancelPending(reason: SpeculativeCancelReason): void {178const p = this._pending;179if (!p) {180return;181}182this._pending = null;183const headerRequestId = p.request.headerRequestId;184this._logger.trace(`cancelling speculative request: ${reason} (headerRequestId=${headerRequestId})`);185p.request.logContext.addLog(`speculative request cancelled: ${reason}`);186const cts = p.request.cancellationTokenSource;187cts.cancel();188// Dispose to release the cancel-event listeners that the in-flight189// provider call hooked onto the token. Safe even though the runner may190// observe cancellation asynchronously — `cancel()` already fired the event.191cts.dispose();192}193194override dispose(): void {195this.cancelAll(SpeculativeCancelReason.Disposed);196super.dispose();197}198}199200201