Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentId, DocumentEditCache>();
Expand Down Expand Up @@ -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'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -344,6 +344,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
let isRebasedCachedEdit = false;
let isSubsequentCachedEdit = false;
let isFromSpeculativeRequest = false;
let cacheEntry: CachedEdit | undefined;

if (cachedEdit) {
logger.trace('using cached edit');
Expand All @@ -362,6 +363,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
telemetryBuilder.setSubsequentEditOrder(cachedEdit.rebasedEditIndex ?? cachedEdit.subsequentN);
// back-date the recording bookmark of the cached edit to the bookmark of the original request.
logContext.recordingBookmark = req.log.recordingBookmark;
cacheEntry = cachedEdit.baseCacheEntry ?? cachedEdit;

} else {
logger.trace(`fetching next edit with shouldExpandEditWindow=${shouldExpandEditWindow}`);
Expand Down Expand Up @@ -403,6 +405,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
logContext.setResponseResults([suggestedNextEdit]); // TODO: other streamed edits?
edit = { actualEdit: suggestedNextEdit, isFromCursorJump: result.val.isFromCursorJump };
isFromSpeculativeRequest = result.val.isFromSpeculativeRequest ?? false;
cacheEntry = result.val.baseCacheEntry ?? result.val;
}
}
}
Expand Down Expand Up @@ -450,7 +453,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne

telemetryBuilder.setStatus('notAccepted'); // Acceptance pending.

const nextEditResult = new NextEditResult(logContext.requestId, req, { edit: edit.actualEdit, isFromCursorJump: edit.isFromCursorJump, documentBeforeEdits: currentDocument, targetDocumentId, isSubsequentEdit: isSubsequentCachedEdit });
const nextEditResult = new NextEditResult(logContext.requestId, req, { edit: edit.actualEdit, isFromCursorJump: edit.isFromCursorJump, documentBeforeEdits: currentDocument, targetDocumentId, isSubsequentEdit: isSubsequentCachedEdit, cacheEntry });

telemetryBuilder.setHasNextEdit(true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { StringReplacement } from '../../../util/vs/editor/common/core/edits/str
import { Position } from '../../../util/vs/editor/common/core/position';
import { Range } from '../../../util/vs/editor/common/core/range';
import { StringText } from '../../../util/vs/editor/common/core/text/abstractText';
import type { CachedEdit } from './nextEditCache';
import { NextEditFetchRequest } from './nextEditProvider';

export interface INextEditDisplayLocation {
Expand Down Expand Up @@ -39,6 +40,13 @@ export class NextEditResult implements INextEditResult {
isFromCursorJump: boolean;
jumpToPosition?: Position;
isSubsequentEdit: boolean;
/**
* Reference to the underlying cache entry, when this result was either
* served from the cache or freshly produced and immediately cached.
* Consumers can use this to read/write per-entry flags such as
* {@link CachedEdit.wasRenderedAsInlineSuggestion}.
*/
cacheEntry?: CachedEdit;
} | undefined,
) { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { IStatelessNextEditProvider, NoNextEditReason, StatelessNextEditRequest,
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';
Expand Down Expand Up @@ -276,4 +276,59 @@ describe('NextEditProvider Caching', () => {
].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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -147,6 +153,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo
private readonly _displayNextEditorNES: boolean;
private readonly _renameSymbolSuggestions: IObservable<boolean>;
private readonly _inlineCompletionsAdvanced: IObservable<boolean>;
private readonly _nesMimicGhostTextBehavior: IObservable<boolean>;

constructor(
private readonly model: InlineEditModel,
Expand All @@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -463,6 +487,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo
telemetryBuilder,
action: learnMoreAction,
isInlineEdit: !isInlineCompletion,
isInlineCompletion,
showInlineEditMenu: !(unification && isInlineCompletion),
wasShown: false,
supportsRename,
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,14 @@ export namespace ConfigKey {
export const InlineEditsNextCursorPredictionLintOptions = defineTeamInternalSetting<Partial<xtabPromptOptions.LintOptions> | undefined>('chat.advanced.inlineEdits.nextCursorPrediction.lintOptions', ConfigType.Simple, undefined, xtabPromptOptions.LINT_OPTIONS_VALIDATOR);
export const InlineEditsInlineCompletionsEnabled = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.inlineCompletions.enabled', ConfigType.Simple, true, vBoolean());
export const InlineEditsInlineCompletionsAdvanced = defineTeamInternalSetting<boolean>('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<boolean>('chat.advanced.inlineEdits.nesMimicGhostTextBehavior', ConfigType.ExperimentBased, false, vBoolean());
export const InlineEditsXtabProviderUsePrediction = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.xtabProvider.usePrediction', ConfigType.ExperimentBased, true, vBoolean());
export const InlineEditsXtabLanguageContextEnabledLanguages = defineTeamInternalSetting<LanguageContextLanguages>('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');
Expand Down
Loading