diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts index d1f7781fa7371..3659e0c210f89 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts @@ -63,9 +63,26 @@ export interface CachedEdit { * @see CachedEditOpts.cursorOffset */ cursorOffsetAtCacheTime?: number; + /** + * Set to `true` once this cached suggestion has been rendered as an inline + * (ghost text at cursor) suggestion. Used by the "mimic ghost text behavior" + * gating to suppress re-serving the same suggestion in a non-inline form. + */ + wasRenderedAsInlineSuggestion?: boolean; } -export type CachedOrRebasedEdit = CachedEdit & { rebasedEdit?: StringReplacement; rebasedEditIndex?: number; isFromSpeculativeRequest?: boolean }; +export type CachedOrRebasedEdit = CachedEdit & { + rebasedEdit?: StringReplacement; + rebasedEditIndex?: number; + isFromSpeculativeRequest?: boolean; + /** + * When this is a rebased view of a cached edit, points to the underlying + * stored {@link CachedEdit} so that flags such as + * {@link CachedEdit.wasRenderedAsInlineSuggestion} can be persisted on the + * stable cache entry instead of the transient rebased view. + */ + baseCacheEntry?: CachedEdit; +}; export class NextEditCache extends Disposable { private readonly _documentCaches = new Map(); @@ -325,7 +342,7 @@ class DocumentEditCache { if (!cachedEdit.rejected && this.isRejectedNextEdit(currentDocumentContents, res[0].rebasedEdit)) { cachedEdit.rejected = true; } - return { edit: { ...cachedEdit, ...res[0] } }; + return { edit: { ...cachedEdit, ...res[0], baseCacheEntry: cachedEdit } }; } else if (!originalEdits.length) { return { edit: cachedEdit }; // cached 'no edits' } diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts index 5a31c9be113f1..7a4b8585ce615 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts @@ -43,7 +43,7 @@ import { NesChangeHint } from '../common/nesTriggerHint'; import { RejectionCollector } from '../common/rejectionCollector'; import { DebugRecorder } from './debugRecorder'; import { INesConfigs } from './nesConfigs'; -import { CachedOrRebasedEdit, NextEditCache } from './nextEditCache'; +import { CachedEdit, CachedOrRebasedEdit, NextEditCache } from './nextEditCache'; import { LlmNESTelemetryBuilder, ReusedRequestKind } from './nextEditProviderTelemetry'; import { INextEditResult, NextEditResult } from './nextEditResult'; import { SpeculativeCancelReason, SpeculativeRequestManager } from './speculativeRequestManager'; @@ -344,6 +344,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider { ].join('\r\n'); expect(doc.value.get().value).toBe(expectedLines); }); + + it('exposes the cache entry on NextEditResult and preserves the wasRenderedAsInlineSuggestion flag across lookups', async () => { + const obsWorkspace = new MutableObservableWorkspace(); + const obsGit = new ObservableGit(gitExtensionService); + const statelessNextEditProvider = createStatelessNextEditProvider(); + + const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger); + + const doc = obsWorkspace.addDocument({ + id: DocumentId.create(URI.file('/test/test.ts').toString()), + initialValue: outdent` + class Point { + constructor( + private readonly x: number, + private readonly y: number, + ) { } + getDistance() { + return Math.sqrt(this.x ** 2 + this.y ** 2); + } + } + + const myPoint = new Point(0, 1);`.trimStart() + }); + doc.setSelection([new OffsetRange(1, 1)], undefined); + + doc.applyEdit(StringEdit.insert(11, '3D')); + + const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false }; + const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context); + const cancellationToken = CancellationToken.None; + + // First call: edit comes fresh from the (mock) provider but is also cached. + const tb1 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc); + const first = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb1.nesBuilder); + tb1.dispose(); + assert(first.result?.edit); + const firstCacheEntry = first.result.cacheEntry; + assert(firstCacheEntry, 'expected a cacheEntry reference on the first (fresh) NextEditResult'); + expect(firstCacheEntry.wasRenderedAsInlineSuggestion).toBeFalsy(); + + // Simulate the inline-completion-provider marking the entry as having been + // rendered as an inline (ghost text) suggestion. + firstCacheEntry.wasRenderedAsInlineSuggestion = true; + + // Second call (no document changes): we should still get the same cached + // edit back, and the flag must have been preserved on the same entry. + const tb2 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc); + const second = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb2.nesBuilder); + tb2.dispose(); + assert(second.result?.edit); + const secondCacheEntry = second.result.cacheEntry; + assert(secondCacheEntry, 'expected a cacheEntry reference on the second (cached) NextEditResult'); + expect(secondCacheEntry).toBe(firstCacheEntry); + expect(secondCacheEntry.wasRenderedAsInlineSuggestion).toBe(true); + }); }); diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts index f19d7d44be50f..8d9df088a1313 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts @@ -35,7 +35,6 @@ import { autorun, IObservable, observableFromEvent } from '../../../util/vs/base import { basename } from '../../../util/vs/base/common/path'; import { StringEdit } from '../../../util/vs/editor/common/core/edits/stringEdit'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { LineCheck } from './naturalLanguageHint'; import { createCorrelationId } from '../common/correlationId'; import { NesChangeHint } from '../common/nesTriggerHint'; import { NESInlineCompletionContext } from '../node/nextEditProvider'; @@ -48,6 +47,7 @@ import { DiagnosticsNextEditResult } from './features/diagnosticsInlineEditProvi import { InlineEditModel } from './inlineEditModel'; import { learnMoreCommandId, learnMoreLink } from './inlineEditProviderFeature'; import { toInlineSuggestion } from './isInlineSuggestion'; +import { LineCheck } from './naturalLanguageHint'; import { InlineEditLogger } from './parts/inlineEditLogger'; import { IVSCodeObservableDocument } from './parts/vscodeWorkspace'; import { raceAndAll } from './raceAndAll'; @@ -64,6 +64,12 @@ export interface NesCompletionItem extends InlineCompletionItem { readonly info: NesCompletionInfo; wasShown: boolean; isEditInAnotherDocument?: boolean; + /** + * Whether the underlying NES suggestion is being served as an inline (ghost + * text at cursor) suggestion as opposed to a non-inline NES (e.g. gutter or + * side hint). Used by the "mimic ghost text behavior" gating. + */ + isInlineCompletion?: boolean; } export class NesCompletionList extends InlineCompletionList { @@ -147,6 +153,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo private readonly _displayNextEditorNES: boolean; private readonly _renameSymbolSuggestions: IObservable; private readonly _inlineCompletionsAdvanced: IObservable; + private readonly _nesMimicGhostTextBehavior: IObservable; constructor( private readonly model: InlineEditModel, @@ -172,6 +179,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo this._displayNextEditorNES = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.UseAlternativeNESNotebookFormat, this._expService); this._renameSymbolSuggestions = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.Advanced.InlineEditsRenameSymbolSuggestions, this._expService); this._inlineCompletionsAdvanced = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsInlineCompletionsAdvanced, this._expService); + this._nesMimicGhostTextBehavior = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsNesMimicGhostTextBehavior, this._expService); this.setCurrentModelId = (modelId: string) => this._modelService.setCurrentModelId(modelId); @@ -431,6 +439,22 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo }; } + // Gate: when the "mimic ghost text behavior" setting is on, a cached suggestion + // that was previously rendered as an inline (ghost text) suggestion must not + // re-surface in any other form. Suppress here without evicting the cache entry — + // when the cursor returns to an inline-renderable position, we'll serve it again. + if ( + this._nesMimicGhostTextBehavior.get() + && !isInlineCompletion + && isLlmCompletionInfo(suggestionInfo) + && suggestionInfo.suggestion.result?.cacheEntry?.wasRenderedAsInlineSuggestion + ) { + logger.trace('Return: previously shown as inline; current context cannot render as inline'); + telemetryBuilder.setStatus('noEdit:suppressedNonInlineReshow'); + this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder); + return emptyList; + } + if (!completionItem) { this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder); return emptyList; @@ -463,6 +487,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo telemetryBuilder, action: learnMoreAction, isInlineEdit: !isInlineCompletion, + isInlineCompletion, showInlineEditMenu: !(unification && isInlineCompletion), wasShown: false, supportsRename, @@ -554,6 +579,15 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo this.logContextRecorder?.handleShown(info.suggestion); if (isLlmCompletionInfo(info)) { + // Mark the underlying cache entry as having been rendered as an inline + // (ghost text) suggestion. The "mimic ghost text behavior" gate uses this + // flag to suppress re-serving the same suggestion in a non-inline form. + if (completionItem.isInlineCompletion) { + const cacheEntry = info.suggestion.result?.cacheEntry; + if (cacheEntry) { + cacheEntry.wasRenderedAsInlineSuggestion = true; + } + } this.model.nextEditProvider.handleShown(info.suggestion); } else { this.model.diagnosticsBasedProvider?.handleShown(info.suggestion); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index dd15f344c6d03..78c8e4132a347 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -753,6 +753,14 @@ export namespace ConfigKey { export const InlineEditsNextCursorPredictionLintOptions = defineTeamInternalSetting | undefined>('chat.advanced.inlineEdits.nextCursorPrediction.lintOptions', ConfigType.Simple, undefined, xtabPromptOptions.LINT_OPTIONS_VALIDATOR); export const InlineEditsInlineCompletionsEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.inlineCompletions.enabled', ConfigType.Simple, true, vBoolean()); export const InlineEditsInlineCompletionsAdvanced = defineTeamInternalSetting('chat.advanced.inlineEdits.inlineCompletions.advancedDetection', ConfigType.ExperimentBased, true, vBoolean()); + /** + * When enabled, a cached NES suggestion that was once rendered as an inline + * (ghost text at cursor) suggestion will not be re-served from cache unless + * it can again be rendered as an inline suggestion. The cache entry is not + * evicted — it is simply gated until the cursor returns to an + * inline-renderable position. + */ + export const InlineEditsNesMimicGhostTextBehavior = defineTeamInternalSetting('chat.advanced.inlineEdits.nesMimicGhostTextBehavior', ConfigType.ExperimentBased, false, vBoolean()); export const InlineEditsXtabProviderUsePrediction = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.usePrediction', ConfigType.ExperimentBased, true, vBoolean()); export const InlineEditsXtabLanguageContextEnabledLanguages = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.enabledLanguages', ConfigType.Simple, LANGUAGE_CONTEXT_ENABLED_LANGUAGES); export const InlineEditsXtabLanguageContextTraitsPosition = defineTeamInternalSetting<'before' | 'after'>('chat.advanced.inlineEdits.xtabProvider.languageContext.traitsPosition', ConfigType.ExperimentBased, 'before');