Add documentation for using Milkdown with various frameworks
- Created a new document for using components in Milkdown. - Added a guide for using plugins in Milkdown, including toggling plugins programmatically and listing official plugins. - Introduced a recipe for integrating Milkdown with Angular, including installation steps and component creation. - Added a recipe for using Milkdown with Next.js, detailing installation and component setup. - Created a guide for integrating Milkdown with NuxtJS, including installation and component creation. - Added a comprehensive guide for using Milkdown with React, covering both Crepe and core Milkdown usage. - Introduced a recipe for SolidJS integration with Milkdown, including installation and component creation. - Added a guide for using Milkdown with Svelte, detailing installation and component setup. - Created a comprehensive guide for integrating Milkdown with Vue, covering both Crepe and core Milkdown usage. - Added a recipe for using Milkdown with Vue2, including installation and component creation.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IAuthenticationService } from '../../../../../../platform/authentication/common/authentication';
|
||||
import { CopilotToken } from '../../../../../../platform/authentication/common/copilotToken';
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { ThrottledDelayer } from '../../../../../../util/vs/base/common/async';
|
||||
import { Disposable } from '../../../../../../util/vs/base/common/lifecycle';
|
||||
export { CopilotToken } from '../../../../../../platform/authentication/common/copilotToken';
|
||||
|
||||
export const ICompletionsCopilotTokenManager = createServiceIdentifier<ICompletionsCopilotTokenManager>('ICompletionsCopilotTokenManager');
|
||||
export interface ICompletionsCopilotTokenManager {
|
||||
readonly _serviceBrand: undefined;
|
||||
get token(): CopilotToken | undefined;
|
||||
primeToken(): Promise<boolean>;
|
||||
getToken(): Promise<CopilotToken>;
|
||||
resetToken(httpError?: number): void;
|
||||
getLastToken(): Omit<CopilotToken, 'token'> | undefined;
|
||||
}
|
||||
|
||||
export class CopilotTokenManagerImpl extends Disposable implements ICompletionsCopilotTokenManager {
|
||||
declare _serviceBrand: undefined;
|
||||
private tokenRefetcher = new ThrottledDelayer(5_000);
|
||||
private _token: CopilotToken | undefined;
|
||||
get token() {
|
||||
void this.tokenRefetcher.trigger(() => this.updateCachedToken());
|
||||
return this._token;
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected primed = false,
|
||||
@IAuthenticationService private readonly authenticationService: IAuthenticationService
|
||||
) {
|
||||
super();
|
||||
|
||||
this.updateCachedToken();
|
||||
this._register(this.authenticationService.onDidAuthenticationChange(() => this.updateCachedToken()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a token and that the `StatusReporter` is up to date.
|
||||
*/
|
||||
primeToken(): Promise<boolean> {
|
||||
try {
|
||||
return this.getToken().then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
} catch (e) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
async getToken(): Promise<CopilotToken> {
|
||||
return this.updateCachedToken();
|
||||
}
|
||||
|
||||
private async updateCachedToken(): Promise<CopilotToken> {
|
||||
this._token = await this.authenticationService.getCopilotToken();
|
||||
return this._token;
|
||||
}
|
||||
|
||||
resetToken(httpError?: number): void {
|
||||
this.authenticationService.resetCopilotToken();
|
||||
}
|
||||
|
||||
getLastToken(): Omit<CopilotToken, 'token'> | undefined {
|
||||
return this.authenticationService.copilotToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IAuthenticationService } from '../../../../../../platform/authentication/common/authentication';
|
||||
import { CopilotToken } from '../../../../../../platform/authentication/common/copilotToken';
|
||||
|
||||
export function onCopilotToken(authService: IAuthenticationService, listener: (token: Omit<CopilotToken, 'token'>) => unknown) {
|
||||
return authService.onDidAuthenticationChange(() => {
|
||||
const copilotToken = authService.copilotToken;
|
||||
if (copilotToken) {
|
||||
listener(copilotToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CopilotToken } from './copilotTokenManager';
|
||||
|
||||
/**
|
||||
* A function used to determine if the org list contains an known organization
|
||||
* @param orgs The list of organizations the user is a member of
|
||||
* @returns The first known organization or undefined if none are known.
|
||||
*/
|
||||
function findKnownOrg(orgs: string[]): string | undefined {
|
||||
// Do not add org mapping
|
||||
const known_orgs = [
|
||||
'a5db0bcaae94032fe715fb34a5e4bce2',
|
||||
'7184f66dfcee98cb5f08a1cb936d5225',
|
||||
'faef89d9169d5eacf1d8c8dde3412e37',
|
||||
'4535c7beffc844b46bb1ed4aa04d759a',
|
||||
];
|
||||
return known_orgs.find(o => orgs.includes(o));
|
||||
}
|
||||
|
||||
export function getUserKind(token: Omit<CopilotToken, 'token'>): string {
|
||||
const orgs = token.organizationList ?? [];
|
||||
return findKnownOrg(orgs) ?? '';
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { Disposable } from '../../types/src';
|
||||
import { ICompletionsTextDocumentManagerService } from './textDocumentManager';
|
||||
|
||||
/**
|
||||
* A tracker which can take an arbitrary number of actions to run after a given timeout
|
||||
* When all pushed timeouts have been resolved, the tracker disposes of itself.
|
||||
*/
|
||||
export class ChangeTracker {
|
||||
private _offset: number;
|
||||
get offset(): number {
|
||||
return this._offset;
|
||||
}
|
||||
private _referenceCount = 0;
|
||||
private _tracker: Disposable;
|
||||
private _isDisposed = false;
|
||||
|
||||
constructor(
|
||||
fileURI: string,
|
||||
insertionOffset: number,
|
||||
@ICompletionsTextDocumentManagerService documentManager: ICompletionsTextDocumentManagerService
|
||||
) {
|
||||
this._offset = insertionOffset;
|
||||
|
||||
this._tracker = documentManager.onDidChangeTextDocument(e => {
|
||||
if (e.document.uri === fileURI) {
|
||||
for (const cc of e.contentChanges) {
|
||||
if (cc.rangeOffset + cc.rangeLength <= this.offset) {
|
||||
const delta = cc.text.length - cc.rangeLength;
|
||||
this._offset = this._offset + delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
push(action: () => void, timeout: number): void {
|
||||
if (this._isDisposed) {
|
||||
throw new Error('Unable to push new actions to a disposed ChangeTracker');
|
||||
}
|
||||
this._referenceCount++;
|
||||
setTimeout(() => {
|
||||
action();
|
||||
this._referenceCount--;
|
||||
if (this._referenceCount === 0) {
|
||||
this._tracker.dispose();
|
||||
this._isDisposed = true;
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
import { Disposable, IDisposable } from '../../../../../util/vs/base/common/lifecycle';
|
||||
import { IRange } from './textDocument';
|
||||
|
||||
export interface IPCitationDetail {
|
||||
license: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface IPDocumentCitation {
|
||||
inDocumentUri: string;
|
||||
offsetStart: number;
|
||||
offsetEnd: number;
|
||||
version?: number;
|
||||
location?: IRange;
|
||||
matchingText?: string;
|
||||
details: IPCitationDetail[];
|
||||
}
|
||||
|
||||
export const ICompletionsCitationManager = createServiceIdentifier<ICompletionsCitationManager>('ICompletionsCitationManager');
|
||||
export interface ICompletionsCitationManager {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
register(): IDisposable;
|
||||
handleIPCodeCitation(citation: IPDocumentCitation): Promise<void>;
|
||||
}
|
||||
|
||||
export class NoOpCitationManager implements ICompletionsCitationManager {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
register() { return Disposable.None; }
|
||||
|
||||
async handleIPCodeCitation(citation: IPDocumentCitation): Promise<void> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import EventEmitter from 'events';
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
import { ICompletionsTelemetryService } from '../../bridge/src/completionsTelemetryServiceBridge';
|
||||
import { CancellationToken, Disposable } from '../../types/src';
|
||||
import { CompletionState } from './completionState';
|
||||
import { GetGhostTextOptions } from './ghostText/ghostText';
|
||||
import { telemetryCatch, TelemetryWithExp } from './telemetry';
|
||||
import { ICompletionsPromiseQueueService } from './util/promiseQueue';
|
||||
|
||||
export type CompletionRequestedEvent = {
|
||||
completionId: string;
|
||||
completionState: CompletionState;
|
||||
telemetryData: TelemetryWithExp;
|
||||
cancellationToken?: CancellationToken;
|
||||
options?: Partial<GetGhostTextOptions>;
|
||||
};
|
||||
|
||||
const requestEventName = 'CompletionRequested';
|
||||
|
||||
export const ICompletionsNotifierService = createServiceIdentifier<ICompletionsNotifierService>('ICompletionsNotifierService');
|
||||
export interface ICompletionsNotifierService {
|
||||
readonly _serviceBrand: undefined;
|
||||
notifyRequest(
|
||||
completionState: CompletionState,
|
||||
completionId: string,
|
||||
telemetryData: TelemetryWithExp,
|
||||
cancellationToken?: CancellationToken,
|
||||
options?: Partial<GetGhostTextOptions>
|
||||
): void;
|
||||
|
||||
onRequest(listener: (event: CompletionRequestedEvent) => void): Disposable;
|
||||
}
|
||||
|
||||
export class CompletionNotifier implements ICompletionsNotifierService {
|
||||
declare _serviceBrand: undefined;
|
||||
#emitter = new EventEmitter();
|
||||
constructor(
|
||||
@ICompletionsPromiseQueueService protected completionsPromiseQueue: ICompletionsPromiseQueueService,
|
||||
@ICompletionsTelemetryService protected completionsTelemetryService: ICompletionsTelemetryService,
|
||||
) { }
|
||||
|
||||
notifyRequest(
|
||||
completionState: CompletionState,
|
||||
completionId: string,
|
||||
telemetryData: TelemetryWithExp,
|
||||
cancellationToken?: CancellationToken,
|
||||
options?: Partial<GetGhostTextOptions>
|
||||
) {
|
||||
return this.#emitter.emit(requestEventName, {
|
||||
completionId,
|
||||
completionState,
|
||||
telemetryData,
|
||||
cancellationToken,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
onRequest(listener: (event: CompletionRequestedEvent) => void): Disposable {
|
||||
const wrapper = telemetryCatch(this.completionsTelemetryService, this.completionsPromiseQueue, listener, `event.${requestEventName}`);
|
||||
this.#emitter.on(requestEventName, wrapper);
|
||||
return Disposable.create(() => this.#emitter.off(requestEventName, wrapper));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { Position, ProposedTextEdit, TextEdit } from '../../types/src';
|
||||
import { IntelliSenseInsertion, ITextDocument, type TextDocumentContents } from './textDocument';
|
||||
|
||||
export class CompletionState {
|
||||
readonly originalPosition: Position;
|
||||
readonly originalVersion: number;
|
||||
readonly originalOffset: number;
|
||||
private readonly _editsWithPosition: ReadonlyArray<ProposedTextEdit>;
|
||||
|
||||
constructor(
|
||||
private readonly _textDocument: ITextDocument,
|
||||
private readonly _position: Position,
|
||||
edits: ProposedTextEdit[] = [],
|
||||
originalPosition?: Position,
|
||||
originalVersion?: number,
|
||||
originalOffset?: number
|
||||
) {
|
||||
this.originalPosition = originalPosition ?? Position.create(_position.line, _position.character);
|
||||
this.originalVersion = originalVersion ?? _textDocument.version;
|
||||
this.originalOffset = originalOffset ?? _textDocument.offsetAt(this.originalPosition);
|
||||
this._editsWithPosition = [...edits];
|
||||
}
|
||||
|
||||
get textDocument(): TextDocumentContents {
|
||||
return this._textDocument;
|
||||
}
|
||||
|
||||
get position(): Position {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
get editsWithPosition(): ProposedTextEdit[] {
|
||||
return [...this._editsWithPosition];
|
||||
}
|
||||
|
||||
private updateState(textDocument: ITextDocument, position: Position, edits?: ProposedTextEdit[]): CompletionState {
|
||||
return new CompletionState(
|
||||
textDocument,
|
||||
position,
|
||||
edits ?? this.editsWithPosition,
|
||||
this.originalPosition,
|
||||
this.originalVersion,
|
||||
this.originalOffset
|
||||
);
|
||||
}
|
||||
|
||||
updatePosition(position: Position): CompletionState {
|
||||
return this.updateState(this._textDocument, position);
|
||||
}
|
||||
|
||||
addSelectedCompletionInfo(selectedCompletionInfo: IntelliSenseInsertion): CompletionState {
|
||||
if (this.editsWithPosition.find(edit => edit.source === 'selectedCompletionInfo')) {
|
||||
throw new Error('Selected completion info already applied');
|
||||
}
|
||||
|
||||
const edit: TextEdit = {
|
||||
range: selectedCompletionInfo.range,
|
||||
newText: selectedCompletionInfo.text,
|
||||
};
|
||||
return this.applyEdits([edit], true);
|
||||
}
|
||||
|
||||
applyEdits(edits: TextEdit[], isSelectedCompletionInfo = false): CompletionState {
|
||||
if (isSelectedCompletionInfo && edits.length > 1) {
|
||||
throw new Error('Selected completion info should be a single edit');
|
||||
}
|
||||
|
||||
let textDocument = this._textDocument;
|
||||
let position = this._position;
|
||||
let offset: number = textDocument.offsetAt(position);
|
||||
const newEdits = this.editsWithPosition;
|
||||
|
||||
for (const { range, newText } of edits) {
|
||||
const oldText = textDocument.getText(range);
|
||||
const oldEndOffset = textDocument.offsetAt(range.end);
|
||||
textDocument = textDocument.applyEdits([{ range, newText }]);
|
||||
// We err on the side of updating the position if it's exactly aligned with the start of the range. This is
|
||||
// what we want in the context of applying a completion, but it does make some operations impossible, like
|
||||
// preserving a position at the start of the document (line 0 column 0).
|
||||
if (offset < textDocument.offsetAt(range.start)) {
|
||||
const edit: ProposedTextEdit = {
|
||||
range,
|
||||
newText,
|
||||
positionAfterEdit: Position.create(position.line, position.character),
|
||||
};
|
||||
if (isSelectedCompletionInfo) {
|
||||
edit.source = 'selectedCompletionInfo';
|
||||
}
|
||||
newEdits.push(edit);
|
||||
continue;
|
||||
}
|
||||
if (offset < oldEndOffset) {
|
||||
offset = oldEndOffset;
|
||||
}
|
||||
offset += newText.length - oldText.length;
|
||||
position = textDocument.positionAt(offset);
|
||||
const edit: ProposedTextEdit = {
|
||||
range,
|
||||
newText,
|
||||
positionAfterEdit: Position.create(position.line, position.character),
|
||||
};
|
||||
if (isSelectedCompletionInfo) {
|
||||
edit.source = 'selectedCompletionInfo';
|
||||
}
|
||||
newEdits.push(edit);
|
||||
}
|
||||
|
||||
return this.updateState(textDocument, position, newEdits);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCompletionState(textDocument: ITextDocument, position: Position): CompletionState {
|
||||
return new CompletionState(textDocument, position);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { IObservableDocument } from '../../../../../platform/inlineEdits/common/observableWorkspace';
|
||||
import { IObservableWithChange } from '../../../../../util/vs/base/common/observableInternal';
|
||||
import { URI } from '../../../../../util/vs/base/common/uri';
|
||||
import { createDecorator as createServiceIdentifier } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
|
||||
export const ICompletionsObservableWorkspace = createServiceIdentifier<ICompletionsObservableWorkspace>('ICompletionsObservableWorkspace');
|
||||
export interface ICompletionsObservableWorkspace {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
get openDocuments(): IObservableWithChange<readonly IObservableDocument[], { added: readonly IObservableDocument[]; removed: readonly IObservableDocument[] }>;
|
||||
|
||||
getWorkspaceRoot(documentId: DocumentId): URI | undefined;
|
||||
|
||||
getFirstOpenDocument(): IObservableDocument | undefined;
|
||||
|
||||
getDocument(documentId: DocumentId): IObservableDocument | undefined;
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { packageJson } from '../../../../../platform/env/common/packagejson';
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
import { ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CopilotConfigPrefix } from './constants';
|
||||
import { Filter } from './experiments/filters';
|
||||
import { Emitter, Event } from './util/event';
|
||||
|
||||
export { packageJson };
|
||||
|
||||
export const ConfigKey = {
|
||||
Enable: 'enable',
|
||||
UserSelectedCompletionModel: 'selectedCompletionModel',
|
||||
|
||||
ShowEditorCompletions: 'editor.showEditorCompletions',
|
||||
EnableAutoCompletions: 'editor.enableAutoCompletions',
|
||||
DelayCompletions: 'editor.delayCompletions',
|
||||
FilterCompletions: 'editor.filterCompletions',
|
||||
CompletionsDelay: 'completionsDelay',
|
||||
CompletionsDebounce: 'completionsDebounce',
|
||||
|
||||
// Advanced config (don't add new config here)
|
||||
RelatedFilesVSCodeCSharp: 'advanced.relatedFilesVSCodeCSharp',
|
||||
RelatedFilesVSCodeTypeScript: 'advanced.relatedFilesVSCodeTypeScript',
|
||||
RelatedFilesVSCode: 'advanced.relatedFilesVSCode',
|
||||
ContextProviders: 'advanced.contextProviders',
|
||||
DebugFilterLogCategories: 'advanced.debug.filterLogCategories',
|
||||
DebugSnippyOverrideUrl: 'advanced.debug.codeRefOverrideUrl',
|
||||
UseSubsetMatching: 'advanced.useSubsetMatching',
|
||||
ContextProviderTimeBudget: 'advanced.contextProviderTimeBudget',
|
||||
|
||||
// Internal config
|
||||
DebugOverrideCapiUrl: 'internal.capiUrl',
|
||||
DebugOverrideCapiUrlLegacy: 'advanced.debug.overrideCapiUrl',
|
||||
DebugTestOverrideCapiUrl: 'internal.capiTestUrl',
|
||||
DebugTestOverrideCapiUrlLegacy: 'advanced.debug.testOverrideCapiUrl',
|
||||
DebugOverrideProxyUrl: 'internal.completionsUrl',
|
||||
DebugOverrideProxyUrlLegacy: 'advanced.debug.overrideProxyUrl',
|
||||
DebugTestOverrideProxyUrl: 'internal.completionsTestUrl',
|
||||
DebugTestOverrideProxyUrlLegacy: 'advanced.debug.testOverrideProxyUrl',
|
||||
DebugOverrideEngine: 'internal.completionModel',
|
||||
DebugOverrideEngineLegacy: 'advanced.debug.overrideEngine',
|
||||
/**
|
||||
* Internal experiment for always requesting multiline completions.
|
||||
* This might not result always in a multiline suggestion, but most often will.
|
||||
*/
|
||||
AlwaysRequestMultiline: 'internal.alwaysRequestMultiline',
|
||||
/**
|
||||
* Let the model terminate single line completions when AlwaysRequestMultiline is enabled.
|
||||
*/
|
||||
ModelAlwaysTerminatesSingleline: 'internal.modelAlwaysTerminatesSingleline',
|
||||
|
||||
/**
|
||||
* Overrides whether to use the Workspace Context Coordinator to coordinate workspace context.
|
||||
* This setting takes precedence over the value from ExP.
|
||||
*/
|
||||
UseWorkspaceContextCoordinator: 'internal.useWorkspaceContextCoordinator',
|
||||
|
||||
/**
|
||||
* Overrides whether to include neighboring files in the prompt
|
||||
* alongside context providers.
|
||||
* This setting takes precedence over the value from ExP.
|
||||
*/
|
||||
IncludeNeighboringFiles: 'internal.includeNeighboringFiles',
|
||||
ExcludeRelatedFiles: 'internal.excludeRelatedFiles',
|
||||
DebugOverrideCppHeadersEnableSwitch: 'internal.cppHeadersEnableSwitch',
|
||||
|
||||
/**
|
||||
* Internal config for using the completions prompt with split context.
|
||||
* https://github.com/github/copilot/issues/19286
|
||||
*/
|
||||
UseSplitContextPrompt: 'internal.useSplitContextPrompt',
|
||||
};
|
||||
|
||||
export type ConfigKeyType = string;
|
||||
|
||||
// How to determine where to terminate the completion to the current block.
|
||||
export enum BlockMode {
|
||||
/**
|
||||
* Parse the context + completion on the client using treesitter to
|
||||
* determine blocks.
|
||||
*/
|
||||
Parsing = 'parsing',
|
||||
/**
|
||||
* Let the server parse out blocks and assume that the completion terminates
|
||||
* at the end of a block.
|
||||
*/
|
||||
Server = 'server',
|
||||
/**
|
||||
* Runs both the treesitter parsing on the client plus indentation-based
|
||||
* truncation on the proxy.
|
||||
*/
|
||||
ParsingAndServer = 'parsingandserver',
|
||||
/**
|
||||
* Client-based heuristic to display more multiline completions.
|
||||
* It almost always requests a multiline completion from the server and tries to break it up to something useful on the client.
|
||||
*
|
||||
* This should not be rolled out at the moment (latency impact is high, UX needs further fine-tuning),
|
||||
* but can be used for internal experimentation.
|
||||
*/
|
||||
MoreMultiline = 'moremultiline',
|
||||
}
|
||||
|
||||
export function shouldDoServerTrimming(blockMode: BlockMode): boolean {
|
||||
return [BlockMode.Server, BlockMode.ParsingAndServer].includes(blockMode);
|
||||
}
|
||||
|
||||
// TODO rework this enum so that the normal/nightly and prod/dev distinctions are orthogonal. (dev builds should behave like nightly?)
|
||||
export enum BuildType {
|
||||
DEV = 'dev',
|
||||
PROD = 'prod',
|
||||
NIGHTLY = 'nightly',
|
||||
}
|
||||
|
||||
export const ICompletionsConfigProvider = createServiceIdentifier<ICompletionsConfigProvider>('ICompletionsConfigProvider');
|
||||
export interface ICompletionsConfigProvider {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
getConfig<T>(key: ConfigKeyType): T;
|
||||
getOptionalConfig<T>(key: ConfigKeyType): T | undefined;
|
||||
dumpForTelemetry(): { [key: string]: string };
|
||||
onDidChangeCopilotSettings: Event<ConfigProvider>;
|
||||
}
|
||||
|
||||
export abstract class ConfigProvider implements ICompletionsConfigProvider {
|
||||
declare _serviceBrand: undefined;
|
||||
abstract getConfig<T>(key: ConfigKeyType): T;
|
||||
abstract getOptionalConfig<T>(key: ConfigKeyType): T | undefined;
|
||||
abstract dumpForTelemetry(): { [key: string]: string };
|
||||
abstract onDidChangeCopilotSettings: Event<ConfigProvider>;
|
||||
|
||||
// The language server receives workspace configuration *after* it is fully initialized, which creates a race
|
||||
// condition where an incoming request immediately after initialization might have the default values. Awaiting
|
||||
// this promise allows consumers to ensure that the configuration is ready before using it.
|
||||
requireReady(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/** Provides only the default values, ignoring the user's settings.
|
||||
* @public KEEPING FOR TESTS
|
||||
*/
|
||||
export class DefaultsOnlyConfigProvider extends ConfigProvider {
|
||||
override getConfig<T>(key: ConfigKeyType): T {
|
||||
// hardcode default values for the agent, for now
|
||||
return getConfigDefaultForKey<T>(key);
|
||||
}
|
||||
|
||||
override getOptionalConfig<T>(key: ConfigKeyType): T | undefined {
|
||||
return getOptionalConfigDefaultForKey<T>(key);
|
||||
}
|
||||
|
||||
override dumpForTelemetry(): { [key: string]: string } {
|
||||
return {};
|
||||
}
|
||||
|
||||
override onDidChangeCopilotSettings = () => {
|
||||
// no-op, since this provider does not support changing settings
|
||||
return {
|
||||
dispose: () => { },
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A ConfigProvider that allows overriding of config values.
|
||||
* @public KEEPING FOR TESTS
|
||||
*/
|
||||
export class InMemoryConfigProvider extends ConfigProvider {
|
||||
protected readonly copilotEmitter = new Emitter<this>();
|
||||
readonly onDidChangeCopilotSettings = this.copilotEmitter.event;
|
||||
private overrides: Map<ConfigKeyType, unknown> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly baseConfigProvider: ConfigProvider,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
setOverrides(overrides: Map<ConfigKeyType, unknown>): void {
|
||||
this.overrides = overrides;
|
||||
}
|
||||
|
||||
clearOverrides(): void {
|
||||
this.overrides.clear();
|
||||
}
|
||||
|
||||
protected getOptionalOverride<T>(key: ConfigKeyType): T | undefined {
|
||||
return this.overrides.get(key) as T | undefined;
|
||||
}
|
||||
|
||||
override getConfig<T>(key: ConfigKeyType): T {
|
||||
return this.getOptionalOverride(key) ?? this.baseConfigProvider.getConfig(key);
|
||||
}
|
||||
|
||||
override getOptionalConfig<T>(key: ConfigKeyType): T | undefined {
|
||||
return this.getOptionalOverride(key) ?? this.baseConfigProvider.getOptionalConfig(key);
|
||||
}
|
||||
|
||||
setConfig(key: ConfigKeyType, value: unknown): void {
|
||||
this.setCopilotSettings({ [key]: value });
|
||||
}
|
||||
|
||||
setCopilotSettings(settings: Record<ConfigKeyType, unknown>): void {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (value !== undefined) {
|
||||
this.overrides.set(key, value);
|
||||
} else {
|
||||
this.overrides.delete(key);
|
||||
}
|
||||
}
|
||||
this.copilotEmitter.fire(this);
|
||||
}
|
||||
|
||||
override dumpForTelemetry(): { [key: string]: string } {
|
||||
const config = this.baseConfigProvider.dumpForTelemetry();
|
||||
// reflects what's mapped in Hydro
|
||||
for (const key of [
|
||||
ConfigKey.ShowEditorCompletions,
|
||||
ConfigKey.EnableAutoCompletions,
|
||||
ConfigKey.DelayCompletions,
|
||||
ConfigKey.FilterCompletions,
|
||||
]) {
|
||||
const value = this.overrides.get(key);
|
||||
if (value !== undefined) {
|
||||
config[key] = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export function getConfigKeyRecursively<T>(config: Record<string, unknown>, key: string): T | undefined {
|
||||
let value: unknown = config;
|
||||
const prefix: string[] = [];
|
||||
for (const segment of key.split('.')) {
|
||||
const child = [...prefix, segment].join('.');
|
||||
if (value && typeof value === 'object' && child in value) {
|
||||
value = (value as { [key: string]: unknown })[child];
|
||||
prefix.length = 0;
|
||||
} else {
|
||||
prefix.push(segment);
|
||||
}
|
||||
}
|
||||
if (value === undefined || prefix.length > 0) { return; }
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function getConfigDefaultForKey<T>(key: string): T {
|
||||
if (configDefaults.has(key)) {
|
||||
return configDefaults.get(key) as T;
|
||||
}
|
||||
throw new Error(`Missing config default value: ${CopilotConfigPrefix}.${key}`);
|
||||
}
|
||||
|
||||
export function getOptionalConfigDefaultForKey<T>(key: string): T | undefined {
|
||||
return <T>configDefaults.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults for "hidden" config keys. These are supplemented by the defaults in package.json.
|
||||
*/
|
||||
const configDefaults = new Map<ConfigKeyType, unknown>([
|
||||
[ConfigKey.DebugOverrideCppHeadersEnableSwitch, false],
|
||||
[ConfigKey.RelatedFilesVSCodeCSharp, false],
|
||||
[ConfigKey.RelatedFilesVSCodeTypeScript, false],
|
||||
[ConfigKey.RelatedFilesVSCode, false],
|
||||
[ConfigKey.IncludeNeighboringFiles, false],
|
||||
[ConfigKey.ExcludeRelatedFiles, false],
|
||||
[ConfigKey.ContextProviders, []],
|
||||
[ConfigKey.DebugSnippyOverrideUrl, ''],
|
||||
[ConfigKey.UseSubsetMatching, null],
|
||||
[ConfigKey.ContextProviderTimeBudget, undefined],
|
||||
[ConfigKey.DebugOverrideCapiUrl, ''],
|
||||
[ConfigKey.DebugTestOverrideCapiUrl, ''],
|
||||
[ConfigKey.DebugOverrideProxyUrl, ''],
|
||||
[ConfigKey.DebugTestOverrideProxyUrl, ''],
|
||||
[ConfigKey.DebugOverrideEngine, ''],
|
||||
[ConfigKey.AlwaysRequestMultiline, undefined],
|
||||
[ConfigKey.CompletionsDebounce, undefined],
|
||||
[ConfigKey.CompletionsDelay, undefined],
|
||||
[ConfigKey.ModelAlwaysTerminatesSingleline, undefined],
|
||||
[ConfigKey.UseWorkspaceContextCoordinator, undefined],
|
||||
|
||||
|
||||
// These are only used for telemetry from LSP based editors and do not affect any behavior.
|
||||
[ConfigKey.ShowEditorCompletions, undefined],
|
||||
[ConfigKey.EnableAutoCompletions, undefined],
|
||||
[ConfigKey.DelayCompletions, undefined],
|
||||
[ConfigKey.FilterCompletions, undefined],
|
||||
[ConfigKey.UseSplitContextPrompt, true],
|
||||
|
||||
// These are defaults from package.json
|
||||
[ConfigKey.Enable, { '*': true, 'plaintext': false, 'markdown': false, 'scminput': false }],
|
||||
[ConfigKey.UserSelectedCompletionModel, ''],
|
||||
|
||||
// These are advanced defaults from package.json
|
||||
[ConfigKey.DebugOverrideEngineLegacy, ''],
|
||||
[ConfigKey.DebugOverrideProxyUrlLegacy, ''],
|
||||
[ConfigKey.DebugTestOverrideProxyUrlLegacy, ''],
|
||||
[ConfigKey.DebugOverrideCapiUrlLegacy, ''],
|
||||
[ConfigKey.DebugTestOverrideCapiUrlLegacy, ''],
|
||||
[ConfigKey.DebugFilterLogCategories, []],
|
||||
]);
|
||||
|
||||
export function getConfig<T>(accessor: ServicesAccessor, key: ConfigKeyType): T {
|
||||
return accessor.get(ICompletionsConfigProvider).getConfig(key);
|
||||
}
|
||||
|
||||
export function dumpForTelemetry(accessor: ServicesAccessor) {
|
||||
try {
|
||||
return accessor.get(ICompletionsConfigProvider).dumpForTelemetry();
|
||||
} catch (e) {
|
||||
console.error(`Error dumping config for telemetry: ${e}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export class BuildInfo {
|
||||
|
||||
static isPreRelease(): boolean {
|
||||
return this.getBuildType() === BuildType.NIGHTLY;
|
||||
}
|
||||
|
||||
static isProduction(): boolean {
|
||||
return this.getBuildType() !== BuildType.DEV;
|
||||
}
|
||||
|
||||
static getBuildType(): BuildType {
|
||||
const buildType = <'dev' | 'prod'>packageJson.buildType;
|
||||
if (buildType === 'prod') {
|
||||
return BuildInfo.getVersion().length === 15 ? BuildType.NIGHTLY : BuildType.PROD;
|
||||
}
|
||||
return BuildType.DEV;
|
||||
}
|
||||
|
||||
static getVersion(): string {
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
static getBuild(): string {
|
||||
return packageJson.build;
|
||||
}
|
||||
}
|
||||
|
||||
type NameAndVersion = {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type EditorInfo = NameAndVersion & {
|
||||
// The root directory of the installation, currently only used to simplify stack traces.
|
||||
root?: string;
|
||||
// A programmatic name, used for error reporting.
|
||||
devName?: string;
|
||||
};
|
||||
|
||||
export type EditorPluginInfo = NameAndVersion;
|
||||
|
||||
export type EditorPluginFilter = { filter: Filter; value: string; isVersion?: boolean };
|
||||
|
||||
export function formatNameAndVersion({ name, version }: NameAndVersion): string {
|
||||
return `${name}/${version}`;
|
||||
}
|
||||
|
||||
export const ICompletionsEditorAndPluginInfo = createServiceIdentifier<ICompletionsEditorAndPluginInfo>('ICompletionsEditorAndPluginInfo');
|
||||
export interface ICompletionsEditorAndPluginInfo {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
getEditorInfo(): EditorInfo;
|
||||
getEditorPluginInfo(): EditorPluginInfo;
|
||||
getRelatedPluginInfo(): EditorPluginInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not use this in new code. Every endpoint has its own unique versioning.
|
||||
* Centralizing in a single constant was a mistake.
|
||||
* @deprecated
|
||||
*/
|
||||
export const apiVersion = '2025-05-01';
|
||||
|
||||
export function editorVersionHeaders(accessor: ServicesAccessor): { [key: string]: string } {
|
||||
const info = accessor.get(ICompletionsEditorAndPluginInfo);
|
||||
return {
|
||||
'Editor-Version': formatNameAndVersion(info.getEditorInfo()),
|
||||
'Editor-Plugin-Version': formatNameAndVersion(info.getEditorPluginInfo()),
|
||||
'Copilot-Language-Server-Version': BuildInfo.getVersion(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
export const CopilotConfigPrefix = 'github.copilot';
|
||||
@@ -0,0 +1,41 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { Logger, logger } from './logger';
|
||||
import { isAbortError } from './networking';
|
||||
import { ICompletionsStatusReporter } from './progress';
|
||||
|
||||
const oomCodes = new Set(['ERR_WORKER_OUT_OF_MEMORY', 'ENOMEM']);
|
||||
|
||||
function isOomError(error: NodeJS.ErrnoException) {
|
||||
return (
|
||||
oomCodes.has(error.code ?? '') ||
|
||||
// happens in loadWasmLanguage
|
||||
(error.name === 'RangeError' && error.message === 'WebAssembly.Memory(): could not allocate memory')
|
||||
);
|
||||
}
|
||||
|
||||
export function handleException(accessor: ServicesAccessor, err: unknown, origin: string, _logger: Logger = logger): void {
|
||||
if (isAbortError(err)) {
|
||||
// ignore cancelled fetch requests
|
||||
return;
|
||||
}
|
||||
const statusReporter = accessor.get(ICompletionsStatusReporter);
|
||||
if (err instanceof Error) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (isOomError(error)) {
|
||||
statusReporter.setWarning('Out of memory');
|
||||
} else if (error.code === 'EMFILE' || error.code === 'ENFILE') {
|
||||
statusReporter.setWarning('Too many open files');
|
||||
} else if (error.code === 'CopilotPromptLoadFailure') {
|
||||
statusReporter.setWarning('Corrupted Copilot installation');
|
||||
} else if (`${error.code}`.startsWith('CopilotPromptWorkerExit')) {
|
||||
statusReporter.setWarning('Worker unexpectedly exited');
|
||||
} else if (error.syscall === 'uv_cwd' && error.code === 'ENOENT') {
|
||||
statusReporter.setWarning('Current working directory does not exist');
|
||||
}
|
||||
}
|
||||
_logger.exception(accessor, err, origin);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { BuildInfo, ICompletionsEditorAndPluginInfo } from './config';
|
||||
import { TelemetryData } from './telemetry';
|
||||
|
||||
const os = {
|
||||
EOL: '\n',
|
||||
};
|
||||
|
||||
/** Diagnostics report made available in extension or agent. */
|
||||
interface Report {
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
type SectionItems = { [key: string]: boolean | string | number | undefined };
|
||||
|
||||
/** Section of a diagnostics report. */
|
||||
interface Section {
|
||||
name: string;
|
||||
items: SectionItems;
|
||||
}
|
||||
|
||||
export function collectCompletionDiagnostics(accessor: ServicesAccessor, telemetry: TelemetryData | undefined): Report {
|
||||
const telemetryItems: SectionItems = {};
|
||||
if (telemetry !== undefined) {
|
||||
if (telemetry.properties.headerRequestId) {
|
||||
telemetryItems['Header Request ID'] = telemetry.properties.headerRequestId;
|
||||
}
|
||||
if (telemetry.properties.choiceIndex) {
|
||||
telemetryItems['Choice Index'] = telemetry.properties.choiceIndex;
|
||||
}
|
||||
if (telemetry.properties.opportunityId) {
|
||||
telemetryItems['Opportunity ID'] = telemetry.properties.opportunityId;
|
||||
}
|
||||
if (telemetry.properties.clientCompletionId) {
|
||||
telemetryItems['Client Completion ID'] = telemetry.properties.clientCompletionId;
|
||||
}
|
||||
if (telemetry.properties.engineName) {
|
||||
telemetryItems['Model ID'] = telemetry.properties.engineName;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sections: [
|
||||
{
|
||||
name: 'Copilot Extension',
|
||||
items: {
|
||||
Version: BuildInfo.getVersion(),
|
||||
Editor: getEditorDisplayVersion(accessor),
|
||||
...telemetryItems,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDiagnosticsAsMarkdown(data: Report): string {
|
||||
const s = data.sections.map(formatSectionAsMarkdown);
|
||||
return s.join(os.EOL + os.EOL) + os.EOL;
|
||||
}
|
||||
|
||||
function formatSectionAsMarkdown(s: Section) {
|
||||
return (
|
||||
`## ${s.name}` +
|
||||
os.EOL +
|
||||
os.EOL +
|
||||
Object.keys(s.items)
|
||||
.filter(k => k !== 'name')
|
||||
.map(k => `- ${k}: ${s.items[k] ?? 'N/A'}`)
|
||||
.join(os.EOL)
|
||||
);
|
||||
}
|
||||
|
||||
function getEditorDisplayVersion(accessor: ServicesAccessor): string {
|
||||
const info = accessor.get(ICompletionsEditorAndPluginInfo).getEditorInfo();
|
||||
return `${info.name} ${info.version}`;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { LRUCacheMap } from './helpers/cache';
|
||||
import { TextDocumentIdentifier } from './textDocument';
|
||||
import { ICompletionsTextDocumentManagerService } from './textDocumentManager';
|
||||
|
||||
/**
|
||||
* A map from the string representation of a document URI to its last access time in ms since the
|
||||
* epoch.
|
||||
*/
|
||||
export const accessTimes = new LRUCacheMap<string, number>();
|
||||
|
||||
/**
|
||||
* Returns a copy of `docs` sorted by access time, from most to least recent.
|
||||
*/
|
||||
export function sortByAccessTimes<T extends TextDocumentIdentifier>(docs: readonly T[]): T[] {
|
||||
return [...docs].sort((a, b) => {
|
||||
const aAccessTime = accessTimes.get(a.uri) ?? 0;
|
||||
const bAccessTime = accessTimes.get(b.uri) ?? 0;
|
||||
return bAccessTime - aAccessTime;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a listener on the `window.onDidChangeActiveTextEditor` event that records/updates the
|
||||
* access time of the document.
|
||||
*/
|
||||
export const registerDocumentTracker = (accessor: ServicesAccessor) =>
|
||||
accessor.get(ICompletionsTextDocumentManagerService).onDidFocusTextDocument(e => {
|
||||
if (e.document) {
|
||||
accessTimes.set(e.document.uri.toString(), Date.now());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IEnvService } from '../../../../../../platform/env/common/envService';
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { URI } from '../../../../../../util/vs/base/common/uri';
|
||||
import { ICompletionsLogTargetService, Logger } from '../logger';
|
||||
import { ICompletionsNotificationSender } from '../notificationSender';
|
||||
|
||||
const CERTIFICATE_ERRORS = ['UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'CERT_SIGNATURE_FAILURE'];
|
||||
const errorMsg =
|
||||
'Your proxy connection requires a trusted certificate. Please make sure the proxy certificate and any issuers are configured correctly and trusted by your operating system.';
|
||||
const learnMoreLink = 'https://gh.io/copilot-network-errors';
|
||||
|
||||
export const ICompletionsUserErrorNotifierService = createServiceIdentifier<ICompletionsUserErrorNotifierService>('ICompletionsUserErrorNotifierService');
|
||||
export interface ICompletionsUserErrorNotifierService {
|
||||
readonly _serviceBrand: undefined;
|
||||
notifyUser(e: unknown): void;
|
||||
}
|
||||
|
||||
export class UserErrorNotifier implements ICompletionsUserErrorNotifierService {
|
||||
declare _serviceBrand: undefined;
|
||||
private readonly notifiedErrorCodes: string[] = [];
|
||||
|
||||
constructor(
|
||||
@ICompletionsLogTargetService private readonly _logTarget: ICompletionsLogTargetService,
|
||||
@ICompletionsNotificationSender private readonly _notificationSender: ICompletionsNotificationSender,
|
||||
@IEnvService private readonly _env: IEnvService
|
||||
) { }
|
||||
|
||||
notifyUser(e: unknown) {
|
||||
if (!(e instanceof Error)) { return; }
|
||||
const error: NodeJS.ErrnoException = e;
|
||||
if (error.code && CERTIFICATE_ERRORS.includes(error.code) && !this.didNotifyBefore(error.code)) {
|
||||
this.notifiedErrorCodes.push(error.code);
|
||||
void this.displayCertificateErrorNotification(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async displayCertificateErrorNotification(err: NodeJS.ErrnoException) {
|
||||
new Logger('certificates').error(
|
||||
this._logTarget,
|
||||
`${errorMsg} Please visit ${learnMoreLink} to learn more. Original cause:`,
|
||||
err
|
||||
);
|
||||
const learnMoreAction = { title: 'Learn more' };
|
||||
return this._notificationSender
|
||||
.showWarningMessage(errorMsg, learnMoreAction)
|
||||
.then(userResponse => {
|
||||
if (userResponse?.title === learnMoreAction.title) {
|
||||
return this._env.openExternal(URI.parse(learnMoreLink));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private didNotifyBefore(code: string) {
|
||||
return this.notifiedErrorCodes.indexOf(code) !== -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IAuthenticationService } from '../../../../../../platform/authentication/common/authentication';
|
||||
import { IExperimentationService } from '../../../../../../platform/telemetry/common/nullExperimentationService';
|
||||
import { IDisposable } from '../../../../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CopilotToken } from '../auth/copilotTokenManager';
|
||||
import { getUserKind } from '../auth/orgs';
|
||||
import {
|
||||
BuildInfo,
|
||||
BuildType,
|
||||
ConfigKey,
|
||||
getConfig
|
||||
} from '../config';
|
||||
import { getEngineRequestInfo } from '../openai/config';
|
||||
import { Filter, Release } from './filters';
|
||||
|
||||
export function setupCompletionsExperimentationService(accessor: ServicesAccessor): IDisposable {
|
||||
const authService = accessor.get(IAuthenticationService);
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
|
||||
const disposable = authService.onDidAccessTokenChange(() => {
|
||||
authService.getCopilotToken()
|
||||
.then(t => instantiationService.invokeFunction(updateCompletionsFilters, t))
|
||||
.catch(err => { });
|
||||
});
|
||||
|
||||
updateCompletionsFilters(accessor, authService.copilotToken);
|
||||
|
||||
return disposable;
|
||||
}
|
||||
|
||||
function getPluginRelease(accessor: ServicesAccessor): Release {
|
||||
if (BuildInfo.getBuildType() === BuildType.NIGHTLY) {
|
||||
return Release.Nightly;
|
||||
}
|
||||
return Release.Stable;
|
||||
}
|
||||
|
||||
function updateCompletionsFilters(accessor: ServicesAccessor, token: Omit<CopilotToken, 'token'> | undefined) {
|
||||
const exp = accessor.get(IExperimentationService);
|
||||
|
||||
const filters = createCompletionsFilters(accessor, token);
|
||||
|
||||
exp.setCompletionsFilters(filters);
|
||||
}
|
||||
|
||||
export function createCompletionsFilters(accessor: ServicesAccessor, token: Omit<CopilotToken, 'token'> | undefined) {
|
||||
const filters = new Map<Filter, string>();
|
||||
|
||||
filters.set(Filter.ExtensionRelease, getPluginRelease(accessor));
|
||||
filters.set(Filter.CopilotOverrideEngine, getConfig(accessor, ConfigKey.DebugOverrideEngine) || getConfig(accessor, ConfigKey.DebugOverrideEngineLegacy));
|
||||
filters.set(Filter.CopilotClientVersion, BuildInfo.isProduction() ? BuildInfo.getVersion() : '1.999.0');
|
||||
|
||||
if (token) {
|
||||
const userKind = getUserKind(token);
|
||||
const customModel = token.getTokenValue('ft') ?? '';
|
||||
const orgs = token.getTokenValue('ol') ?? '';
|
||||
const customModelNames = token.getTokenValue('cml') ?? '';
|
||||
const copilotTrackingId = token.getTokenValue('tid') ?? '';
|
||||
|
||||
filters.set(Filter.CopilotUserKind, userKind);
|
||||
filters.set(Filter.CopilotCustomModel, customModel);
|
||||
filters.set(Filter.CopilotOrgs, orgs);
|
||||
filters.set(Filter.CopilotCustomModelNames, customModelNames);
|
||||
filters.set(Filter.CopilotTrackingId, copilotTrackingId);
|
||||
filters.set(Filter.CopilotUserKind, getUserKind(token));
|
||||
}
|
||||
|
||||
const model = getEngineRequestInfo(accessor).modelId;
|
||||
filters.set(Filter.CopilotEngine, model);
|
||||
return filters;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { TelemetryData, telemetryExpProblem } from '../telemetry';
|
||||
import { ExpServiceTelemetryNames } from './telemetryNames';
|
||||
|
||||
// All variables we pull from Exp and might want to use
|
||||
export enum ExpTreatmentVariables {
|
||||
// the engine we want to request, used in actual experiment(s)
|
||||
CustomEngine = 'copilotcustomengine',
|
||||
// if set, any custom engine (see previous) will only apply when the current engine matches the value of this variable
|
||||
CustomEngineTargetEngine = 'copilotcustomenginetargetengine',
|
||||
|
||||
OverrideBlockMode = 'copilotoverrideblockmode',
|
||||
SuffixPercent = 'CopilotSuffixPercent', // the percentage of the prompt tokens to allocate to the suffix
|
||||
CppHeadersEnableSwitch = 'copilotcppheadersenableswitch', // whether to enable the inclusion of C++ headers as neighbors in the prompt
|
||||
UseSubsetMatching = 'copilotsubsetmatching', // whether to use subset matching instead of jaccard similarity experiment
|
||||
|
||||
// granularity specification
|
||||
SuffixMatchThreshold = 'copilotsuffixmatchthreshold', // the threshold that new suffix should match with old suffix
|
||||
|
||||
MaxPromptCompletionTokens = 'maxpromptcompletionTokens', // the maximum tokens of the prompt and completion
|
||||
|
||||
/**
|
||||
* Enable the use of the Workspace Context Coordinator to coordinate context from providers of workspace snippets.
|
||||
*/
|
||||
StableContextPercent = 'copilotstablecontextpercent', // the percentage of the prompt tokens to allocate to the stable context
|
||||
VolatileContextPercent = 'copilotvolatilecontextpercent', // the percentage of the prompt tokens to allocate to the volatile context
|
||||
|
||||
/**
|
||||
* Flags that control the enablement of the related files extensibility for various languages in VSCode.
|
||||
*/
|
||||
RelatedFilesVSCodeCSharp = 'copilotrelatedfilesvscodecsharp', // whether to include related files as neighbors in the prompt for C#, this takes precedence over RelatedFilesVSCode
|
||||
RelatedFilesVSCodeTypeScript = 'copilotrelatedfilesvscodetypescript', // whether to include related files as neighbors in the prompt for TS/JS, this takes precedence over RelatedFilesVSCode
|
||||
RelatedFilesVSCode = 'copilotrelatedfilesvscode', // whether to include related files as neighbors in the prompt, vscode experiment
|
||||
|
||||
/**
|
||||
* Flags that control the inclusion of open tab files as neighboring files for various languages.
|
||||
*/
|
||||
ContextProviders = 'copilotcontextproviders', // comma-separated list of context providers IDs (case sensitive) to enable
|
||||
IncludeNeighboringFiles = 'copilotincludeneighboringfiles', // Always include neighboring files alongside context providers
|
||||
ExcludeRelatedFiles = 'copilotexcluderelatedfiles', // Exclude related files even if neighboring files are enabled
|
||||
ContextProviderTimeBudget = 'copilotcontextprovidertimebudget', // time budget for context providers in milliseconds
|
||||
|
||||
/**
|
||||
* Values to control the ContextProvider API's CodeSnippets provided by the C++ Language Service.
|
||||
*/
|
||||
CppContextProviderParams = 'copilotcppContextProviderParams',
|
||||
|
||||
/**
|
||||
* Values to control the ContextProvider API's CodeSnippets provided by the C# Language Service.
|
||||
*/
|
||||
CSharpContextProviderParams = 'copilotcsharpcontextproviderparams',
|
||||
|
||||
/**
|
||||
* Values to control the ContextProvider API's CodeSnippets provided by the Java Language Service.
|
||||
*/
|
||||
JavaContextProviderParams = 'copilotjavacontextproviderparams',
|
||||
|
||||
/**
|
||||
* Values to control the MultiLanguageContextProvider parameters.
|
||||
*/
|
||||
MultiLanguageContextProviderParams = 'copilotmultilanguagecontextproviderparams',
|
||||
|
||||
/**
|
||||
* Values to control the TsContextProvider parameters.
|
||||
*/
|
||||
TsContextProviderParams = 'copilottscontextproviderparams',
|
||||
|
||||
/**
|
||||
* Controls the delay to apply to debouncing of completion requests.
|
||||
*/
|
||||
CompletionsDebounce = 'copilotcompletionsdebounce',
|
||||
|
||||
/**
|
||||
* Enable the electron networking in VS Code.
|
||||
*/
|
||||
ElectronFetcher = 'copilotelectronfetcher',
|
||||
FetchFetcher = 'copilotfetchfetcher',
|
||||
|
||||
/**
|
||||
* Sets the timeout for waiting for async completions in flight before
|
||||
* issuing a new network request. Set to -1 to disable the timeout entirely.
|
||||
*/
|
||||
AsyncCompletionsTimeout = 'copilotasynccompletionstimeout',
|
||||
|
||||
/**
|
||||
* Controls whether the prompt context for code completions needs to be split from the document prefix.
|
||||
*/
|
||||
EnablePromptContextProxyField = 'copilotenablepromptcontextproxyfield',
|
||||
|
||||
/**
|
||||
* Controls progressive reveal of completions.
|
||||
*/
|
||||
ProgressiveReveal = 'copilotprogressivereveal',
|
||||
// part of progressive reveal, controls whether the model or client terminates single-line completions
|
||||
ModelAlwaysTerminatesSingleline = 'copilotmodelterminatesingleline',
|
||||
// long look-ahead window size (in lines) for progressive reveal
|
||||
ProgressiveRevealLongLookaheadSize = 'copilotprogressivereveallonglookaheadsize',
|
||||
// short look-ahead window size (in lines) for progressive reveal
|
||||
ProgressiveRevealShortLookaheadSize = 'copilotprogressiverevealshortlookaheadsize',
|
||||
// maximum token count when requesting multi-line completions
|
||||
MaxMultilineTokens = 'copilotmaxmultilinetokens',
|
||||
|
||||
/**
|
||||
* Controls number of lines to trim to after accepting a completion.
|
||||
*/
|
||||
MultilineAfterAcceptLines = 'copilotmultilineafteracceptlines',
|
||||
|
||||
/**
|
||||
* Add a delay before rendering completions.
|
||||
*/
|
||||
CompletionsDelay = 'copilotcompletionsdelay',
|
||||
|
||||
/**
|
||||
* Request single line completions unless the previous completion was just accepted.
|
||||
*/
|
||||
SingleLineUnlessAccepted = 'copilotsinglelineunlessaccepted',
|
||||
}
|
||||
|
||||
export type ExpTreatmentVariableValue = boolean | string | number;
|
||||
|
||||
export class ExpConfig {
|
||||
variables: Partial<Record<ExpTreatmentVariables, ExpTreatmentVariableValue>>; // for the 'vscode' config
|
||||
features: string; // semicolon-separated feature IDs
|
||||
|
||||
constructor(
|
||||
variables: Partial<Record<ExpTreatmentVariables, ExpTreatmentVariableValue>>,
|
||||
features: string
|
||||
) {
|
||||
this.variables = variables;
|
||||
this.features = features;
|
||||
}
|
||||
|
||||
static createFallbackConfig(accessor: ServicesAccessor, reason: string): ExpConfig {
|
||||
telemetryExpProblem(accessor, { reason });
|
||||
return this.createEmptyConfig();
|
||||
}
|
||||
|
||||
static createEmptyConfig() {
|
||||
return new ExpConfig({}, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds (or overwrites) the given experiment config to the telemetry data.
|
||||
* @param telemetryData telemetryData object. If previous ExpConfigs are already present, they will be overwritten.
|
||||
*/
|
||||
addToTelemetry(telemetryData: TelemetryData): void {
|
||||
telemetryData.properties[ExpServiceTelemetryNames.featuresTelemetryPropertyName] = this.features;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ILogService } from '../../../../../../platform/log/common/logService';
|
||||
import { IExperimentationService } from '../../../../../../platform/telemetry/common/nullExperimentationService';
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import {
|
||||
DEFAULT_MAX_COMPLETION_LENGTH,
|
||||
DEFAULT_MAX_PROMPT_LENGTH,
|
||||
DEFAULT_PROMPT_ALLOCATION_PERCENT,
|
||||
DEFAULT_SUFFIX_MATCH_THRESHOLD
|
||||
} from '../../../prompt/src/prompt';
|
||||
import { CopilotToken, ICompletionsCopilotTokenManager } from '../auth/copilotTokenManager';
|
||||
import { BlockMode } from '../config';
|
||||
import { TelemetryData, TelemetryWithExp } from '../telemetry';
|
||||
import { createCompletionsFilters } from './defaultExpFilters';
|
||||
import { ExpConfig, ExpTreatmentVariables, ExpTreatmentVariableValue } from './expConfig';
|
||||
import { CompletionsFiltersInfo, ContextProviderExpSettings, ICompletionsFeaturesService } from './featuresService';
|
||||
import { Filter, FilterSettings } from './filters';
|
||||
|
||||
type InternalContextProviderExpSettings = {
|
||||
id?: string;
|
||||
ids?: string[];
|
||||
includeNeighboringFiles?: boolean;
|
||||
excludeRelatedFiles?: boolean;
|
||||
timeBudget?: number;
|
||||
params?: Record<string, string | boolean | number>;
|
||||
};
|
||||
|
||||
/** General-purpose API for accessing ExP variable values. */
|
||||
export class Features implements ICompletionsFeaturesService {
|
||||
declare _serviceBrand: undefined;
|
||||
constructor(
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@IExperimentationService private readonly experimentationService: IExperimentationService,
|
||||
@ICompletionsCopilotTokenManager private readonly copilotTokenManager: ICompletionsCopilotTokenManager,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Central logic for obtaining the assignments of treatment groups
|
||||
* for a given set of filters (i.e. descriptors of who is getting the treatment).
|
||||
* Also gets the values of variables controlled by experiment.
|
||||
*
|
||||
* This function should be called **exactly once** at the start of every
|
||||
* 'completion request' in the client (e.g. ghostText, panel request or chat conversation).
|
||||
*
|
||||
* It is called with an initial set of filters, (FeaturesFilterArgs)
|
||||
* but it adds many of its own.
|
||||
* At first the general background filters like extension version.
|
||||
* Then it will check ExP assignments for the first time, to find out
|
||||
* whether there are any assignments of a special granularity
|
||||
* (i.e. the concept that we want to redraw assignments based on
|
||||
* time bucket, or checksum of time, etc).
|
||||
*
|
||||
* On most calls to this function, the assignment fetches will be the
|
||||
* assignments from previously used filters, so they will be cached and return fast.
|
||||
*
|
||||
* @param telemetryData The base telemetry object to which the experimental filters, ExP
|
||||
* variable values, and experimental assignments will be added. All properties and measurements
|
||||
* of the input telemetryData will be present in the output TelemetryWithExp object.
|
||||
* Every telemetry data used to generate ExP scorecards (e.g. ghostText events) must
|
||||
* include the correct experiment assignments in order to properly create those
|
||||
* scorecards.
|
||||
*/
|
||||
async updateExPValuesAndAssignments(
|
||||
filtersInfo?: CompletionsFiltersInfo,
|
||||
telemetryData: TelemetryData = TelemetryData.createAndMarkAsIssued()
|
||||
): Promise<TelemetryWithExp> {
|
||||
// We should not allow accidentally overwriting existing ExP vals/assignments.
|
||||
// This doesn't stop all misuse cases, but should prevent some trivial ones.
|
||||
if (telemetryData instanceof TelemetryWithExp) {
|
||||
throw new Error('updateExPValuesAndAssignments should not be called with TelemetryWithExp');
|
||||
}
|
||||
|
||||
const token = this.copilotTokenManager.token ?? await this.copilotTokenManager.getToken();
|
||||
const { filters, exp } = this.createExpConfigAndFilters(token);
|
||||
|
||||
return new TelemetryWithExp(telemetryData.properties, telemetryData.measurements, telemetryData.issuedTime, {
|
||||
filters,
|
||||
exp: exp,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a Copilot token and use that token to call updateExPValuesAndAssignments. Do NOT call this at startup.
|
||||
* Instead, register a onCopilotToken handler and use that token with updateExPValuesAndAssignments directly.
|
||||
*/
|
||||
async fetchTokenAndUpdateExPValuesAndAssignments(
|
||||
filtersInfo?: CompletionsFiltersInfo,
|
||||
telemetryData?: TelemetryData
|
||||
) {
|
||||
return await this.updateExPValuesAndAssignments(filtersInfo, telemetryData);
|
||||
}
|
||||
|
||||
private createExpConfigAndFilters(token: CopilotToken) {
|
||||
|
||||
const exp2: Partial<Record<ExpTreatmentVariables, ExpTreatmentVariableValue>> = {};
|
||||
for (const varName of Object.values<ExpTreatmentVariables>(ExpTreatmentVariables)) {
|
||||
const value = this.experimentationService.getTreatmentVariable(varName);
|
||||
if (value !== undefined) {
|
||||
exp2[varName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const features = Object.entries(exp2).map(([name, value]) => {
|
||||
// Based on what tas-client does in https://github.com/microsoft/tas-client/blob/2bd24c976273b671892aad99139af2c7c7dc3b26/tas-client/src/tas-client/FeatureProvider/TasApiFeatureProvider.ts#L59
|
||||
return name + (value ? '' : 'cf');
|
||||
});
|
||||
const exp = new ExpConfig(exp2, features.join(';'));
|
||||
const filterMap = this.instantiationService.invokeFunction(createCompletionsFilters, token);
|
||||
const filterRecord: Partial<Record<Filter, string>> = {};
|
||||
for (const [key, value] of filterMap.entries()) {
|
||||
filterRecord[key] = value;
|
||||
}
|
||||
|
||||
const filters = new FilterSettings(filterRecord);
|
||||
return { filters, exp };
|
||||
}
|
||||
|
||||
/** Get the entries from this.assignments corresponding to given settings. */
|
||||
async getFallbackExpAndFilters(): Promise<{ filters: FilterSettings; exp: ExpConfig }> {
|
||||
const token = this.copilotTokenManager.token ?? await this.copilotTokenManager.getToken();
|
||||
return this.createExpConfigAndFilters(token);
|
||||
}
|
||||
|
||||
/** Override for BlockMode to send in the request. */
|
||||
overrideBlockMode(telemetryWithExp: TelemetryWithExp): BlockMode | undefined {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.OverrideBlockMode] as BlockMode) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Functions with arguments, passed via object destructuring */
|
||||
|
||||
/** @returns the string for copilotcustomengine, or "" if none is set. */
|
||||
customEngine(telemetryWithExp: TelemetryWithExp): string {
|
||||
return (telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.CustomEngine] as string) ?? '';
|
||||
}
|
||||
|
||||
/** @returns the string for copilotcustomenginetargetengine, or undefined if none is set. */
|
||||
customEngineTargetEngine(telemetryWithExp: TelemetryWithExp): string | undefined {
|
||||
return telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.CustomEngineTargetEngine] as string;
|
||||
}
|
||||
|
||||
/** @returns the percent of prompt tokens to be allocated to the suffix */
|
||||
suffixPercent(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.SuffixPercent] as number) ??
|
||||
DEFAULT_PROMPT_ALLOCATION_PERCENT.suffix
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns the percentage match threshold for using the cached suffix */
|
||||
suffixMatchThreshold(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.SuffixMatchThreshold] as number) ??
|
||||
DEFAULT_SUFFIX_MATCH_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns whether to enable the inclusion of C++ headers as neighbor files. */
|
||||
cppHeadersEnableSwitch(telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.CppHeadersEnableSwitch] as boolean) ??
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns whether to use included related files as neighbor files for C# (vscode experiment). */
|
||||
relatedFilesVSCodeCSharp(telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.RelatedFilesVSCodeCSharp] as boolean) ??
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns whether to use included related files as neighbor files for TS/JS (vscode experiment). */
|
||||
relatedFilesVSCodeTypeScript(telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[
|
||||
ExpTreatmentVariables.RelatedFilesVSCodeTypeScript
|
||||
] as boolean) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns whether to use included related files as neighbor files (vscode experiment). */
|
||||
relatedFilesVSCode(telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.RelatedFilesVSCode] as boolean) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns the list of context providers IDs to enable. The special value `*` enables all context providers. */
|
||||
contextProviders(telemetryWithExp: TelemetryWithExp): string[] {
|
||||
const providers = (telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.ContextProviders] ??
|
||||
'') as string;
|
||||
if (!providers) {
|
||||
return [];
|
||||
}
|
||||
return providers.split(',').map(provider => provider.trim());
|
||||
}
|
||||
|
||||
contextProviderTimeBudget(languageId: string, telemetryWithExp: TelemetryWithExp): number {
|
||||
const client = (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.ContextProviderTimeBudget] as number) ??
|
||||
150
|
||||
);
|
||||
if (client) {
|
||||
return client;
|
||||
}
|
||||
const chat = this.getContextProviderExpSettings(languageId);
|
||||
return chat?.timeBudget ?? 150;
|
||||
}
|
||||
|
||||
includeNeighboringFiles(languageId: string, telemetryWithExp: TelemetryWithExp): boolean {
|
||||
const client = (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.IncludeNeighboringFiles] as boolean) ??
|
||||
false
|
||||
);
|
||||
if (client) {
|
||||
return true;
|
||||
}
|
||||
const chat = this.getContextProviderExpSettings(languageId);
|
||||
return chat?.includeNeighboringFiles ?? false;
|
||||
}
|
||||
|
||||
excludeRelatedFiles(languageId: string, telemetryWithExp: TelemetryWithExp): boolean {
|
||||
const client = (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.ExcludeRelatedFiles] as boolean) ??
|
||||
false
|
||||
);
|
||||
if (client) {
|
||||
return true;
|
||||
}
|
||||
const chat = this.getContextProviderExpSettings(languageId);
|
||||
return chat?.excludeRelatedFiles ?? false;
|
||||
}
|
||||
|
||||
getContextProviderExpSettings(languageId: string): ContextProviderExpSettings | undefined {
|
||||
const value = this.experimentationService.getTreatmentVariable<string>(`config.github.copilot.chat.contextprovider.${languageId}`);
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed: Partial<InternalContextProviderExpSettings> = JSON.parse(value);
|
||||
const ids = this.getProviderIDs(parsed);
|
||||
delete parsed.id;
|
||||
delete parsed.ids;
|
||||
return Object.assign({ ids }, { includeNeighboringFiles: false, excludeRelatedFiles: false, timeBudget: 150 }, parsed as Omit<InternalContextProviderExpSettings, 'id' | 'ids'>);
|
||||
} catch (err) {
|
||||
this.instantiationService.invokeFunction((accessor) => {
|
||||
const logService = accessor.get(ILogService);
|
||||
logService.error(`Failed to parse context provider exp settings for language ${languageId}`);
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getProviderIDs(json: InternalContextProviderExpSettings): string[] {
|
||||
const result: string[] = [];
|
||||
if (typeof json.id === 'string' && json.id.length > 0) {
|
||||
result.push(json.id);
|
||||
}
|
||||
if (Array.isArray(json.ids)) {
|
||||
for (const id of json.ids) {
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
result.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @returns the maximal number of tokens of prompt AND completion */
|
||||
maxPromptCompletionTokens(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.MaxPromptCompletionTokens] as number) ??
|
||||
DEFAULT_MAX_PROMPT_LENGTH + DEFAULT_MAX_COMPLETION_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
stableContextPercent(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.StableContextPercent] as number) ??
|
||||
DEFAULT_PROMPT_ALLOCATION_PERCENT.stableContext
|
||||
);
|
||||
}
|
||||
|
||||
volatileContextPercent(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.VolatileContextPercent] as number) ??
|
||||
DEFAULT_PROMPT_ALLOCATION_PERCENT.volatileContext
|
||||
);
|
||||
}
|
||||
|
||||
/** Custom parameters for language specific Context Providers. */
|
||||
cppContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined {
|
||||
const cppContextProviderParams = telemetryWithExp.filtersAndExp.exp.variables[
|
||||
ExpTreatmentVariables.CppContextProviderParams
|
||||
] as string;
|
||||
return cppContextProviderParams;
|
||||
}
|
||||
|
||||
csharpContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined {
|
||||
const csharpContextProviderParams = telemetryWithExp.filtersAndExp.exp.variables[
|
||||
ExpTreatmentVariables.CSharpContextProviderParams
|
||||
] as string;
|
||||
return csharpContextProviderParams;
|
||||
}
|
||||
|
||||
javaContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined {
|
||||
const javaContextProviderParams = telemetryWithExp.filtersAndExp.exp.variables[
|
||||
ExpTreatmentVariables.JavaContextProviderParams
|
||||
] as string;
|
||||
return javaContextProviderParams;
|
||||
}
|
||||
|
||||
multiLanguageContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined {
|
||||
const multiLanguageContextProviderParams = telemetryWithExp.filtersAndExp.exp.variables[
|
||||
ExpTreatmentVariables.MultiLanguageContextProviderParams
|
||||
] as string;
|
||||
return multiLanguageContextProviderParams;
|
||||
}
|
||||
|
||||
tsContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined {
|
||||
const tsContextProviderParams = telemetryWithExp.filtersAndExp.exp.variables[
|
||||
ExpTreatmentVariables.TsContextProviderParams
|
||||
] as string;
|
||||
return tsContextProviderParams;
|
||||
}
|
||||
|
||||
completionsDebounce(telemetryWithExp: TelemetryWithExp): number | undefined {
|
||||
return telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.CompletionsDebounce] as
|
||||
| number
|
||||
| undefined;
|
||||
}
|
||||
|
||||
enableElectronFetcher(telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.ElectronFetcher] as boolean) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
enableFetchFetcher(telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.FetchFetcher] as boolean) ?? false;
|
||||
}
|
||||
|
||||
asyncCompletionsTimeout(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.AsyncCompletionsTimeout] as number) ??
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
enableProgressiveReveal(telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.ProgressiveReveal] as boolean) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
modelAlwaysTerminatesSingleline(telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[
|
||||
ExpTreatmentVariables.ModelAlwaysTerminatesSingleline
|
||||
] as boolean) ?? true
|
||||
);
|
||||
}
|
||||
|
||||
longLookaheadSize(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[
|
||||
ExpTreatmentVariables.ProgressiveRevealLongLookaheadSize
|
||||
] as number) ?? 9
|
||||
);
|
||||
}
|
||||
|
||||
shortLookaheadSize(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[
|
||||
ExpTreatmentVariables.ProgressiveRevealShortLookaheadSize
|
||||
] as number) ?? 3
|
||||
);
|
||||
}
|
||||
|
||||
maxMultilineTokens(telemetryWithExp: TelemetryWithExp): number {
|
||||
// p50 line length is 19 characters (p95 is 73)
|
||||
// average token length is around 4 characters
|
||||
// the below value has quite a bit of buffer while bringing the limit in significantly from 500
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.MaxMultilineTokens] as number) ?? 200
|
||||
);
|
||||
}
|
||||
|
||||
multilineAfterAcceptLines(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.MultilineAfterAcceptLines] as number) ??
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
completionsDelay(telemetryWithExp: TelemetryWithExp): number {
|
||||
return (telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.CompletionsDelay] as number) ?? 200;
|
||||
}
|
||||
|
||||
singleLineUnlessAccepted(telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (
|
||||
(telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.SingleLineUnlessAccepted] as boolean) ??
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { BlockMode } from '../config';
|
||||
import { TelemetryData, TelemetryWithExp } from '../telemetry';
|
||||
import { ExpConfig } from './expConfig';
|
||||
import { FilterSettings } from './filters';
|
||||
|
||||
export type CompletionsFiltersInfo = { uri: string; languageId: string };
|
||||
|
||||
export type ContextProviderExpSettings = {
|
||||
ids: string[];
|
||||
includeNeighboringFiles: boolean;
|
||||
excludeRelatedFiles: boolean;
|
||||
timeBudget: number;
|
||||
params?: Record<string, string | boolean | number>;
|
||||
}
|
||||
|
||||
export const ICompletionsFeaturesService = createServiceIdentifier<ICompletionsFeaturesService>('ICompletionsFeaturesService');
|
||||
export interface ICompletionsFeaturesService {
|
||||
readonly _serviceBrand: undefined;
|
||||
updateExPValuesAndAssignments(
|
||||
filtersInfo?: CompletionsFiltersInfo,
|
||||
telemetryData?: TelemetryData
|
||||
): Promise<TelemetryWithExp>;
|
||||
fetchTokenAndUpdateExPValuesAndAssignments(
|
||||
filtersInfo?: CompletionsFiltersInfo,
|
||||
telemetryData?: TelemetryData
|
||||
): Promise<TelemetryWithExp>;
|
||||
getFallbackExpAndFilters(): Promise<{ filters: FilterSettings; exp: ExpConfig }>;
|
||||
overrideBlockMode(telemetryWithExp: TelemetryWithExp): BlockMode | undefined;
|
||||
customEngine(telemetryWithExp: TelemetryWithExp): string;
|
||||
customEngineTargetEngine(telemetryWithExp: TelemetryWithExp): string | undefined;
|
||||
suffixPercent(telemetryWithExp: TelemetryWithExp): number;
|
||||
suffixMatchThreshold(telemetryWithExp: TelemetryWithExp): number;
|
||||
cppHeadersEnableSwitch(telemetryWithExp: TelemetryWithExp): boolean;
|
||||
relatedFilesVSCodeCSharp(telemetryWithExp: TelemetryWithExp): boolean;
|
||||
relatedFilesVSCodeTypeScript(telemetryWithExp: TelemetryWithExp): boolean;
|
||||
relatedFilesVSCode(telemetryWithExp: TelemetryWithExp): boolean;
|
||||
contextProviders(telemetryWithExp: TelemetryWithExp): string[];
|
||||
contextProviderTimeBudget(languageId: string, telemetryWithExp: TelemetryWithExp): number;
|
||||
includeNeighboringFiles(languageId: string, telemetryWithExp: TelemetryWithExp): boolean;
|
||||
excludeRelatedFiles(languageId: string, telemetryWithExp: TelemetryWithExp): boolean;
|
||||
getContextProviderExpSettings(languageId: string): ContextProviderExpSettings | undefined;
|
||||
maxPromptCompletionTokens(telemetryWithExp: TelemetryWithExp): number;
|
||||
stableContextPercent(telemetryWithExp: TelemetryWithExp): number;
|
||||
volatileContextPercent(telemetryWithExp: TelemetryWithExp): number;
|
||||
cppContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined;
|
||||
csharpContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined;
|
||||
javaContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined;
|
||||
multiLanguageContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined;
|
||||
tsContextProviderParams(telemetryWithExp: TelemetryWithExp): string | undefined;
|
||||
completionsDebounce(telemetryWithExp: TelemetryWithExp): number | undefined;
|
||||
enableElectronFetcher(telemetryWithExp: TelemetryWithExp): boolean;
|
||||
enableFetchFetcher(telemetryWithExp: TelemetryWithExp): boolean;
|
||||
asyncCompletionsTimeout(telemetryWithExp: TelemetryWithExp): number;
|
||||
enableProgressiveReveal(telemetryWithExp: TelemetryWithExp): boolean;
|
||||
modelAlwaysTerminatesSingleline(telemetryWithExp: TelemetryWithExp): boolean;
|
||||
longLookaheadSize(telemetryWithExp: TelemetryWithExp): number;
|
||||
shortLookaheadSize(telemetryWithExp: TelemetryWithExp): number;
|
||||
maxMultilineTokens(telemetryWithExp: TelemetryWithExp): number;
|
||||
multilineAfterAcceptLines(telemetryWithExp: TelemetryWithExp): number;
|
||||
completionsDelay(telemetryWithExp: TelemetryWithExp): number;
|
||||
singleLineUnlessAccepted(telemetryWithExp: TelemetryWithExp): boolean;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { TelemetryData } from '../telemetry';
|
||||
|
||||
/** The prefix used for related plugin version headers. */
|
||||
const CopilotRelatedPluginVersionPrefix = 'X-Copilot-RelatedPluginVersion-';
|
||||
|
||||
/** The filter headers that ExP knows about. */
|
||||
export enum Filter {
|
||||
// Default VSCode filters
|
||||
|
||||
ExtensionRelease = 'X-VSCode-ExtensionRelease',
|
||||
|
||||
// Copilot-specific filters
|
||||
|
||||
/** The machine ID concatenated with a 1-hour bucket. */
|
||||
CopilotClientTimeBucket = 'X-Copilot-ClientTimeBucket',
|
||||
/** The model currently in use. Not included in fallback filters */
|
||||
CopilotEngine = 'X-Copilot-Engine',
|
||||
/** The engine override value from settings, if present. */
|
||||
CopilotOverrideEngine = 'X-Copilot-OverrideEngine',
|
||||
/** Git repo info. Not included in fallback filters */
|
||||
CopilotRepository = 'X-Copilot-Repository',
|
||||
/** Language of the file on which a given request is being made. Not included in fallback filters */
|
||||
CopilotFileType = 'X-Copilot-FileType', // Wired to languageId
|
||||
/** The organization the user belongs to. Not included in fallback filters */
|
||||
CopilotUserKind = 'X-Copilot-UserKind',
|
||||
/** Declare experiment dogfood program if any. Not included in fallback filters */
|
||||
CopilotDogfood = 'X-Copilot-Dogfood',
|
||||
/** For custom Model Alpha. Not included in fallback filters */
|
||||
CopilotCustomModel = 'X-Copilot-CustomModel',
|
||||
/** Organizations. */
|
||||
CopilotOrgs = 'X-Copilot-Orgs',
|
||||
/** Identifiers for Custom Model(s) */
|
||||
CopilotCustomModelNames = 'X-Copilot-CustomModelNames',
|
||||
/** Copilot Tracking ID */
|
||||
CopilotTrackingId = 'X-Copilot-CopilotTrackingId',
|
||||
/** The Copilot Client Version */
|
||||
CopilotClientVersion = 'X-Copilot-ClientVersion',
|
||||
|
||||
CopilotRelatedPluginVersionCppTools = CopilotRelatedPluginVersionPrefix + 'msvscodecpptools',
|
||||
CopilotRelatedPluginVersionCMakeTools = CopilotRelatedPluginVersionPrefix + 'msvscodecmaketools',
|
||||
CopilotRelatedPluginVersionMakefileTools = CopilotRelatedPluginVersionPrefix + 'msvscodemakefiletools',
|
||||
CopilotRelatedPluginVersionCSharpDevKit = CopilotRelatedPluginVersionPrefix + 'msdotnettoolscsdevkit',
|
||||
CopilotRelatedPluginVersionPython = CopilotRelatedPluginVersionPrefix + 'mspythonpython',
|
||||
CopilotRelatedPluginVersionPylance = CopilotRelatedPluginVersionPrefix + 'mspythonvscodepylance',
|
||||
CopilotRelatedPluginVersionJavaPack = CopilotRelatedPluginVersionPrefix + 'vscjavavscodejavapack',
|
||||
CopilotRelatedPluginVersionJavaManager = CopilotRelatedPluginVersionPrefix + 'vscjavavscodejavadependency',
|
||||
CopilotRelatedPluginVersionTypescript = CopilotRelatedPluginVersionPrefix + 'vscodetypescriptlanguagefeatures',
|
||||
CopilotRelatedPluginVersionTypescriptNext = CopilotRelatedPluginVersionPrefix + 'msvscodevscodetypescriptnext',
|
||||
CopilotRelatedPluginVersionCSharp = CopilotRelatedPluginVersionPrefix + 'msdotnettoolscsharp',
|
||||
CopilotRelatedPluginVersionGithubCopilotChat = CopilotRelatedPluginVersionPrefix + 'githubcopilotchat',
|
||||
CopilotRelatedPluginVersionGithubCopilot = CopilotRelatedPluginVersionPrefix + 'githubcopilot',
|
||||
}
|
||||
|
||||
export enum Release {
|
||||
Stable = 'stable',
|
||||
Nightly = 'nightly',
|
||||
}
|
||||
|
||||
const telmetryNames: Partial<Record<Filter, string>> = {
|
||||
[Filter.CopilotClientTimeBucket]: 'timeBucket',
|
||||
[Filter.CopilotOverrideEngine]: 'engine',
|
||||
[Filter.CopilotRepository]: 'repo',
|
||||
[Filter.CopilotFileType]: 'fileType',
|
||||
[Filter.CopilotUserKind]: 'userKind',
|
||||
};
|
||||
|
||||
/**
|
||||
* The class FilterSettings holds the variables that were used to filter
|
||||
* experiment groups.
|
||||
*/
|
||||
export class FilterSettings {
|
||||
constructor(private readonly filters: Partial<Record<Filter, string>>) {
|
||||
// empyt string is equivalent to absent, so remove it
|
||||
for (const [filter, value] of Object.entries(this.filters)) {
|
||||
if (value === '') {
|
||||
delete this.filters[filter as Filter];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the telemetry Data with the current filter variables.
|
||||
* @param telemetryData Extended in place.
|
||||
*/
|
||||
addToTelemetry(telemetryData: TelemetryData) {
|
||||
// add all values:
|
||||
for (const [filter, value] of Object.entries(this.filters)) {
|
||||
const telemetryName = telmetryNames[filter as Filter];
|
||||
if (telemetryName === undefined) {
|
||||
continue;
|
||||
}
|
||||
telemetryData.properties[telemetryName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a copy of the filters. */
|
||||
toHeaders(): Partial<Record<Filter, string>> {
|
||||
return { ...this.filters };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { DEFAULT_NUM_SNIPPETS } from '../../../prompt/src/prompt';
|
||||
import { defaultSimilarFilesOptions, SimilarFilesOptions } from '../../../prompt/src/snippetInclusion/similarFiles';
|
||||
import { ConfigKey, getConfig } from '../config';
|
||||
import { TelemetryWithExp } from '../telemetry';
|
||||
import { ExpTreatmentVariables } from './expConfig';
|
||||
import { getCppNumberOfSnippets, getCppSimilarFilesOptions } from './similarFileOptionsProviderCpp';
|
||||
|
||||
type SimilarFilesOptionsProvider = (accessor: ServicesAccessor, exp: TelemetryWithExp) => SimilarFilesOptions;
|
||||
// Add here for more options for other language ids.
|
||||
const languageSimilarFilesOptions: ReadonlyMap<string, SimilarFilesOptionsProvider> = new Map<
|
||||
string,
|
||||
SimilarFilesOptionsProvider
|
||||
>([['cpp', getCppSimilarFilesOptions]]);
|
||||
|
||||
export function getSimilarFilesOptions(accessor: ServicesAccessor, exp: TelemetryWithExp, langId: string): SimilarFilesOptions {
|
||||
const optionsProvider: SimilarFilesOptionsProvider | undefined = languageSimilarFilesOptions.get(langId);
|
||||
if (optionsProvider) {
|
||||
return optionsProvider(accessor, exp);
|
||||
} else {
|
||||
return {
|
||||
...defaultSimilarFilesOptions,
|
||||
useSubsetMatching: useSubsetMatching(accessor, exp),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type NumberOfSnippetsProvider = (exp: TelemetryWithExp) => number;
|
||||
// Add here for more values for other language ids.
|
||||
const numberOfSnippets: ReadonlyMap<string, NumberOfSnippetsProvider> = new Map<string, NumberOfSnippetsProvider>([
|
||||
['cpp', getCppNumberOfSnippets],
|
||||
]);
|
||||
|
||||
export function getNumberOfSnippets(exp: TelemetryWithExp, langId: string): number {
|
||||
const provider: NumberOfSnippetsProvider | undefined = numberOfSnippets.get(langId);
|
||||
return provider ? provider(exp) : DEFAULT_NUM_SNIPPETS;
|
||||
}
|
||||
|
||||
export function useSubsetMatching(accessor: ServicesAccessor, telemetryWithExp: TelemetryWithExp): boolean {
|
||||
return (
|
||||
((telemetryWithExp.filtersAndExp.exp.variables[ExpTreatmentVariables.UseSubsetMatching] as boolean) ||
|
||||
getConfig(accessor, ConfigKey.UseSubsetMatching)) ??
|
||||
false
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { defaultCppSimilarFilesOptions, SimilarFilesOptions } from '../../../prompt/src/snippetInclusion/similarFiles';
|
||||
import { TelemetryWithExp } from '../telemetry';
|
||||
import { useSubsetMatching } from './similarFileOptionsProvider';
|
||||
|
||||
export function getCppSimilarFilesOptions(accessor: ServicesAccessor, telemetryWithExp: TelemetryWithExp): SimilarFilesOptions {
|
||||
return {
|
||||
...defaultCppSimilarFilesOptions,
|
||||
useSubsetMatching: useSubsetMatching(accessor, telemetryWithExp),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCppNumberOfSnippets(telemetryWithExp: TelemetryWithExp): number {
|
||||
return defaultCppSimilarFilesOptions.maxTopSnippets;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export enum ExpServiceTelemetryNames {
|
||||
// these are defined (but not exported) in the code for the tas client, currently here:
|
||||
// https://github.com/microsoft/tas-client/blob/75f8895b15ef5696653cbee134ccae24477b0b94/vscode-tas-client/src/vscode-tas-client/VSCodeTasClient.ts#L67
|
||||
featuresTelemetryPropertyName = 'VSCode.ABExp.Features',
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { extractRepoInfoInBackground } from '../../prompt/repository';
|
||||
import { TelemetryData } from '../../telemetry';
|
||||
import { createLibTestingContext } from '../../test/context';
|
||||
import { makeFsUri } from '../../util/uri';
|
||||
import { ICompletionsFeaturesService } from '../featuresService';
|
||||
|
||||
suite('updateExPValuesAndAssignments', function () {
|
||||
let accessor: ServicesAccessor;
|
||||
|
||||
const filenameUri = makeFsUri(__filename);
|
||||
|
||||
setup(async function () {
|
||||
accessor = createLibTestingContext().createTestingAccessor();
|
||||
// Trigger extractRepoInfoInBackground early + add a sleep to force repo info to be available
|
||||
extractRepoInfoInBackground(accessor, filenameUri);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
test('If no options are provided, repo filters should be empty and there should be no telemetry properties or measurements', async function () {
|
||||
const featuresService = accessor.get(ICompletionsFeaturesService);
|
||||
const telemetry = await featuresService.updateExPValuesAndAssignments();
|
||||
|
||||
assert.deepStrictEqual(telemetry.properties, {});
|
||||
assert.deepStrictEqual(telemetry.measurements, {});
|
||||
|
||||
const filters = telemetry.filtersAndExp.filters.toHeaders();
|
||||
assert.deepStrictEqual(filters['X-Copilot-Repository'], undefined);
|
||||
assert.deepStrictEqual(filters['X-Copilot-FileType'], undefined);
|
||||
});
|
||||
|
||||
test('If telemetry data is passed as a parameter, it should be used in the resulting telemetry object', async function () {
|
||||
const telemetryData = TelemetryData.createAndMarkAsIssued({ foo: 'bar' }, { baz: 42 });
|
||||
|
||||
const featuresService = accessor.get(ICompletionsFeaturesService);
|
||||
const telemetry = await featuresService.updateExPValuesAndAssignments(undefined, telemetryData);
|
||||
|
||||
assert.deepStrictEqual(telemetry.properties, { foo: 'bar' });
|
||||
assert.deepStrictEqual(telemetry.measurements, { baz: 42 });
|
||||
|
||||
const filters = telemetry.filtersAndExp.filters.toHeaders();
|
||||
assert.deepStrictEqual(filters['X-Copilot-Repository'], undefined);
|
||||
assert.deepStrictEqual(filters['X-Copilot-FileType'], undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsFileSystemService } from './fileSystem';
|
||||
import { CopilotTextDocument, ITextDocument, TextDocumentIdentifier, TextDocumentResult } from './textDocument';
|
||||
import { ICompletionsTextDocumentManagerService } from './textDocumentManager';
|
||||
import { isDocumentValid } from './util/documentEvaluation';
|
||||
import { basename } from './util/uri';
|
||||
|
||||
export const ICompletionsFileReaderService = createServiceIdentifier<ICompletionsFileReaderService>('ICompletionsFileReaderService');
|
||||
export interface ICompletionsFileReaderService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
getRelativePath(doc: TextDocumentIdentifier): string | undefined;
|
||||
|
||||
getOrReadTextDocument(doc: TextDocumentIdentifier): Promise<TextDocumentResult>;
|
||||
|
||||
getOrReadTextDocumentWithFakeClientProperties(
|
||||
doc: TextDocumentIdentifier
|
||||
): Promise<TextDocumentResult<ITextDocument>>;
|
||||
}
|
||||
|
||||
export class FileReader implements ICompletionsFileReaderService {
|
||||
declare _serviceBrand: undefined;
|
||||
constructor(
|
||||
@ICompletionsTextDocumentManagerService private readonly documentManagerService: ICompletionsTextDocumentManagerService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ICompletionsFileSystemService private readonly fileSystemService: ICompletionsFileSystemService,
|
||||
) { }
|
||||
|
||||
getRelativePath(doc: TextDocumentIdentifier) {
|
||||
return this.documentManagerService.getRelativePath(doc) ?? basename(doc.uri);
|
||||
}
|
||||
|
||||
getOrReadTextDocument(doc: TextDocumentIdentifier): Promise<TextDocumentResult> {
|
||||
return this.readFile(doc.uri);
|
||||
}
|
||||
|
||||
getOrReadTextDocumentWithFakeClientProperties(
|
||||
doc: TextDocumentIdentifier
|
||||
): Promise<TextDocumentResult<ITextDocument>> {
|
||||
return this.readFile(doc.uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `getOrReadTextDocument` instead
|
||||
*/
|
||||
protected async readFile(uri: string): Promise<TextDocumentResult<ITextDocument>> {
|
||||
const documentResult = await this.documentManagerService.getTextDocumentWithValidation({ uri });
|
||||
if (documentResult.status !== 'notfound') {
|
||||
return documentResult;
|
||||
}
|
||||
try {
|
||||
const fileSizeMB = await this.getFileSizeMB(uri);
|
||||
// Note: the real production behavior actually blocks files larger than 5MB
|
||||
if (fileSizeMB > 1) {
|
||||
// Using notfound instead of invalid because of the mapping in statusFromTextDocumentResult
|
||||
return { status: 'notfound' as const, message: 'File too large' };
|
||||
}
|
||||
const text = await this.doReadFile(uri);
|
||||
|
||||
// Note, that we check for blocked files even for empty files!
|
||||
const rcmResult = await this.instantiationService.invokeFunction(isDocumentValid, { uri });
|
||||
if (rcmResult.status === 'valid') {
|
||||
const doc = CopilotTextDocument.create(uri, 'UNKNOWN', -1, text);
|
||||
return { status: 'valid' as const, document: doc };
|
||||
}
|
||||
|
||||
return rcmResult;
|
||||
} catch (e) {
|
||||
return { status: 'notfound' as const, message: 'File not found' };
|
||||
}
|
||||
}
|
||||
|
||||
private async doReadFile(uri: string) {
|
||||
return await this.fileSystemService.readFileString(uri);
|
||||
}
|
||||
|
||||
private async getFileSizeMB(uri: string) {
|
||||
const stat = await this.fileSystemService.stat(uri);
|
||||
return stat.size / 1024 / 1024;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
|
||||
/**
|
||||
* `FileType` identifies the type of a file. `SymbolicLink` may be combined
|
||||
* with other types, e.g. `FileType.Directory | FileType.SymbolicLink`.
|
||||
*/
|
||||
export enum FileType {
|
||||
/** The file type is not known. */
|
||||
Unknown = 0,
|
||||
/** The file is a regular file. */
|
||||
File = 1,
|
||||
/** The file is a directory. */
|
||||
Directory = 2,
|
||||
/** The file is a symbolic link. */
|
||||
SymbolicLink = 64,
|
||||
}
|
||||
|
||||
/**
|
||||
* The `FileStat`-type represents metadata about a file
|
||||
*/
|
||||
export interface FileStat {
|
||||
/**
|
||||
* The creation timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC.
|
||||
*/
|
||||
ctime: number;
|
||||
|
||||
/**
|
||||
* The modification timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC.
|
||||
*
|
||||
* *Note:* If the file changed, it is important to provide an updated `mtime` that advanced
|
||||
* from the previous value. Otherwise there may be optimizations in place that will not show
|
||||
* the updated file contents in an editor for example.
|
||||
*/
|
||||
|
||||
mtime: number;
|
||||
/**
|
||||
* The size in bytes.
|
||||
*
|
||||
* *Note:* If the file changed, it is important to provide an updated `size`. Otherwise there
|
||||
* may be optimizations in place that will not show the updated file contents in an editor for
|
||||
* example.
|
||||
*/
|
||||
size: number;
|
||||
/**
|
||||
* The type of file.
|
||||
*
|
||||
* *Note:* This is a bit field. Multiple flags may be set on it, e.g.
|
||||
* `FileType.File | FileType.SymbolicLink`.
|
||||
*/
|
||||
type: FileType;
|
||||
}
|
||||
|
||||
export type FileIdentifier = string | { readonly uri: string };
|
||||
|
||||
export const ICompletionsFileSystemService = createServiceIdentifier<ICompletionsFileSystemService>('ICompletionsFileSystemService');
|
||||
export interface ICompletionsFileSystemService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readFileString(uri: FileIdentifier): Promise<string>;
|
||||
stat(uri: FileIdentifier): Promise<FileStat>;
|
||||
readDirectory(uri: FileIdentifier): Promise<[string, FileType][]>;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { CancellationTokenSource } from '../../../types/src';
|
||||
import { ICompletionsFeaturesService } from '../experiments/featuresService';
|
||||
import { LRUCacheMap } from '../helpers/cache';
|
||||
import { ICompletionsLogTargetService, Logger } from '../logger';
|
||||
import { APIChoice } from '../openai/openai';
|
||||
import { Prompt } from '../prompt/prompt';
|
||||
import { TelemetryWithExp } from '../telemetry';
|
||||
import { Deferred } from '../util/async';
|
||||
import { ReplaySubject } from '../util/subject';
|
||||
import { GetNetworkCompletionsType } from './ghostText';
|
||||
|
||||
enum AsyncCompletionRequestState {
|
||||
Completed,
|
||||
Error,
|
||||
Pending,
|
||||
}
|
||||
|
||||
interface BaseAsyncCompletionRequest {
|
||||
cancellationTokenSource: CancellationTokenSource;
|
||||
headerRequestId: string;
|
||||
partialCompletionText?: string;
|
||||
prefix: string;
|
||||
prompt: Prompt;
|
||||
subject: ReplaySubject<AsyncCompletionRequest>;
|
||||
}
|
||||
|
||||
interface PendingAsyncCompletionRequest extends BaseAsyncCompletionRequest {
|
||||
state: AsyncCompletionRequestState.Pending;
|
||||
}
|
||||
|
||||
interface CompletedAsyncCompletionRequest extends BaseAsyncCompletionRequest {
|
||||
state: AsyncCompletionRequestState.Completed;
|
||||
choice: APIChoice;
|
||||
result: GetNetworkCompletionsType;
|
||||
allChoicesPromise: Promise<void>;
|
||||
}
|
||||
|
||||
type AsyncCompletionRequest = PendingAsyncCompletionRequest | CompletedAsyncCompletionRequest;
|
||||
|
||||
export const ICompletionsAsyncManagerService = createServiceIdentifier<ICompletionsAsyncManagerService>('ICompletionsAsyncManagerService');
|
||||
export interface ICompletionsAsyncManagerService {
|
||||
readonly _serviceBrand: undefined;
|
||||
clear(): void;
|
||||
shouldWaitForAsyncCompletions(prefix: string, prompt: Prompt): boolean;
|
||||
updateCompletion(headerRequestId: string, text: string): void;
|
||||
queueCompletionRequest(
|
||||
headerRequestId: string,
|
||||
prefix: string,
|
||||
prompt: Prompt,
|
||||
cancellationTokenSource: CancellationTokenSource,
|
||||
resultPromise: Promise<GetNetworkCompletionsType>
|
||||
): Promise<void>;
|
||||
getFirstMatchingRequestWithTimeout(
|
||||
headerRequestId: string,
|
||||
prefix: string,
|
||||
prompt: Prompt,
|
||||
isSpeculative: boolean,
|
||||
telemetryWithExp: TelemetryWithExp
|
||||
): Promise<[APIChoice, Promise<void>] | undefined>;
|
||||
getFirstMatchingRequest(
|
||||
headerRequestId: string,
|
||||
prefix: string,
|
||||
prompt: Prompt,
|
||||
isSpeculative: boolean
|
||||
): Promise<[APIChoice, Promise<void>] | undefined>;
|
||||
}
|
||||
|
||||
export class AsyncCompletionManager implements ICompletionsAsyncManagerService {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
#logger = new Logger('AsyncCompletionManager');
|
||||
|
||||
/** Mapping of headerRequestId to completion request */
|
||||
private readonly requests = new LRUCacheMap<string, AsyncCompletionRequest>(100);
|
||||
|
||||
/** The most recently requested (either via getFirstMatchingRequest or
|
||||
* getFirstMatchingRequestWithTimeout) header request ID. Serves as a lock
|
||||
* for cancellation. Since we only want to cancel requests that don't match
|
||||
* the most recent request prefix. */
|
||||
private mostRecentRequestId = '';
|
||||
|
||||
constructor(
|
||||
@ICompletionsFeaturesService private readonly featuresService: ICompletionsFeaturesService,
|
||||
@ICompletionsLogTargetService private readonly logTarget: ICompletionsLogTargetService,
|
||||
) { }
|
||||
|
||||
clear() {
|
||||
this.requests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any candidate completions for the current position.
|
||||
* We need to strike the right balance between queuing completions as the
|
||||
* user types, without queuing one per keystroke. This method should return
|
||||
* true if we don't have any completions that match the current position.
|
||||
* This method should return false if we have reasonable candidates that
|
||||
* match the current position.
|
||||
*/
|
||||
shouldWaitForAsyncCompletions(prefix: string, prompt: Prompt): boolean {
|
||||
// TODO: Consider adding a minimum threshold for candidate completions,
|
||||
// where we will queue more if the user's typing seems to be diverging
|
||||
// from current speculation.
|
||||
for (const [_, request] of this.requests) {
|
||||
if (isCandidate(prefix, prompt, request)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from a FinishedCallback to report partial results as a completion
|
||||
* is streamed back from the server.
|
||||
*/
|
||||
updateCompletion(headerRequestId: string, text: string) {
|
||||
const request = this.requests.get(headerRequestId);
|
||||
if (request === undefined) { return; }
|
||||
request.partialCompletionText = text;
|
||||
request.subject.next(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an in-flight completion request to the requests map for tracking.
|
||||
* Once the request is completed it is removed from the requests map.
|
||||
*/
|
||||
queueCompletionRequest(
|
||||
headerRequestId: string,
|
||||
prefix: string,
|
||||
prompt: Prompt,
|
||||
cancellationTokenSource: CancellationTokenSource,
|
||||
resultPromise: Promise<GetNetworkCompletionsType>
|
||||
) {
|
||||
this.#logger.debug(this.logTarget,
|
||||
`[${headerRequestId}] Queueing async completion request:`,
|
||||
prefix.substring(prefix.lastIndexOf('\n') + 1)
|
||||
);
|
||||
const subject = new ReplaySubject<AsyncCompletionRequest>();
|
||||
this.requests.set(headerRequestId, {
|
||||
state: AsyncCompletionRequestState.Pending,
|
||||
cancellationTokenSource,
|
||||
headerRequestId,
|
||||
prefix,
|
||||
prompt,
|
||||
subject,
|
||||
});
|
||||
return resultPromise
|
||||
.then(result => {
|
||||
this.requests.delete(headerRequestId);
|
||||
if (result.type !== 'success') {
|
||||
this.#logger.debug(this.logTarget, `[${headerRequestId}] Request failed with`, result.reason);
|
||||
subject.error(result.reason);
|
||||
return;
|
||||
}
|
||||
const completed: CompletedAsyncCompletionRequest = {
|
||||
cancellationTokenSource,
|
||||
headerRequestId,
|
||||
prefix,
|
||||
prompt,
|
||||
subject,
|
||||
choice: result.value[0],
|
||||
result,
|
||||
state: AsyncCompletionRequestState.Completed,
|
||||
allChoicesPromise: result.value[1],
|
||||
};
|
||||
this.requests.set(headerRequestId, completed);
|
||||
subject.next(completed);
|
||||
subject.complete();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
this.#logger.error(this.logTarget, `[${headerRequestId}] Request errored with`, e);
|
||||
this.requests.delete(headerRequestId);
|
||||
subject.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the first matching completion or times out. */
|
||||
getFirstMatchingRequestWithTimeout(
|
||||
headerRequestId: string,
|
||||
prefix: string,
|
||||
prompt: Prompt,
|
||||
isSpeculative: boolean,
|
||||
telemetryWithExp: TelemetryWithExp
|
||||
): Promise<[APIChoice, Promise<void>] | undefined> {
|
||||
const timeout = this.featuresService.asyncCompletionsTimeout(telemetryWithExp);
|
||||
if (timeout < 0) {
|
||||
this.#logger.debug(this.logTarget, `[${headerRequestId}] Waiting for completions without timeout`);
|
||||
return this.getFirstMatchingRequest(headerRequestId, prefix, prompt, isSpeculative);
|
||||
}
|
||||
this.#logger.debug(this.logTarget, `[${headerRequestId}] Waiting for completions with timeout of ${timeout}ms`);
|
||||
return Promise.race([
|
||||
this.getFirstMatchingRequest(headerRequestId, prefix, prompt, isSpeculative),
|
||||
new Promise<null>(r => setTimeout(() => r(null), timeout)),
|
||||
]).then(result => {
|
||||
if (result === null) {
|
||||
this.#logger.debug(this.logTarget, `[${headerRequestId}] Timed out waiting for completion`);
|
||||
return undefined;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first resolved matching completion request. Modifies the
|
||||
* returned APIChoice to match the current prompt.
|
||||
*/
|
||||
async getFirstMatchingRequest(
|
||||
headerRequestId: string,
|
||||
prefix: string,
|
||||
prompt: Prompt,
|
||||
isSpeculative: boolean
|
||||
): Promise<[APIChoice, Promise<void>] | undefined> {
|
||||
if (!isSpeculative) { this.mostRecentRequestId = headerRequestId; }
|
||||
let resolved = false;
|
||||
const deferred = new Deferred<[APIChoice, Promise<void>] | undefined>();
|
||||
const subscriptions = new Map<string, () => void>();
|
||||
const finishRequest = (id: string) => () => {
|
||||
const subscription = subscriptions.get(id);
|
||||
if (subscription === undefined) { return; }
|
||||
subscription();
|
||||
subscriptions.delete(id);
|
||||
if (!resolved && subscriptions.size === 0) {
|
||||
// TODO: Check for new candidates before resolving.
|
||||
resolved = true;
|
||||
this.#logger.debug(this.logTarget, `[${headerRequestId}] No matching completions found`);
|
||||
deferred.resolve(undefined);
|
||||
}
|
||||
};
|
||||
const next = (request: AsyncCompletionRequest) => {
|
||||
if (isCandidate(prefix, prompt, request)) {
|
||||
if (request.state === AsyncCompletionRequestState.Completed) {
|
||||
const remainingPrefix = prefix.substring(request.prefix.length);
|
||||
let { completionText } = request.choice;
|
||||
if (
|
||||
!completionText.startsWith(remainingPrefix) ||
|
||||
completionText.length <= remainingPrefix.length
|
||||
) {
|
||||
finishRequest(request.headerRequestId)();
|
||||
return;
|
||||
}
|
||||
completionText = completionText.substring(remainingPrefix.length);
|
||||
request.choice.telemetryData.measurements.foundOffset = remainingPrefix.length;
|
||||
this.#logger.debug(this.logTarget,
|
||||
`[${headerRequestId}] Found completion at offset ${remainingPrefix.length}: ${JSON.stringify(completionText)}`
|
||||
);
|
||||
deferred.resolve([{ ...request.choice, completionText }, request.allChoicesPromise]);
|
||||
resolved = true;
|
||||
}
|
||||
} else {
|
||||
this.cancelRequest(headerRequestId, request);
|
||||
finishRequest(request.headerRequestId)();
|
||||
}
|
||||
};
|
||||
for (const [id, request] of this.requests) {
|
||||
if (isCandidate(prefix, prompt, request)) {
|
||||
subscriptions.set(
|
||||
id,
|
||||
request.subject.subscribe({
|
||||
next,
|
||||
error: finishRequest(id),
|
||||
complete: finishRequest(id),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.cancelRequest(headerRequestId, request);
|
||||
}
|
||||
}
|
||||
return deferred.promise.finally(() => {
|
||||
for (const dispose of subscriptions.values()) {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to cancel a request if it is still pending and the request
|
||||
* attempting the cancellation (that it no longer matches) is the most
|
||||
* recent request.
|
||||
*
|
||||
* @param headerRequestId The request id for the call to
|
||||
* getFirstMatchingRequest that the `request` no longer matches.
|
||||
* @param request The request to cancel
|
||||
*/
|
||||
private cancelRequest(headerRequestId: string, request: AsyncCompletionRequest) {
|
||||
if (headerRequestId !== this.mostRecentRequestId) { return; }
|
||||
if (request.state === AsyncCompletionRequestState.Completed) { return; }
|
||||
this.#logger.debug(this.logTarget, `[${headerRequestId}] Cancelling request: ${request.headerRequestId}`);
|
||||
request.cancellationTokenSource.cancel();
|
||||
this.requests.delete(request.headerRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidate(prefix: string, prompt: Prompt, request: AsyncCompletionRequest): boolean {
|
||||
if (request.prompt.suffix !== prompt.suffix) { return false; }
|
||||
if (!prefix.startsWith(request.prefix)) { return false; }
|
||||
const remainingPrefix = prefix.substring(request.prefix.length);
|
||||
if (request.state === AsyncCompletionRequestState.Completed) {
|
||||
return (
|
||||
request.choice.completionText.startsWith(remainingPrefix) &&
|
||||
request.choice.completionText.trimEnd().length > remainingPrefix.length
|
||||
);
|
||||
}
|
||||
if (request.partialCompletionText === undefined) { return true; }
|
||||
return request.partialCompletionText.startsWith(remainingPrefix);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { StatementNode, StatementTree } from './statementTree';
|
||||
import { IPosition, TextDocumentContents } from '../textDocument';
|
||||
|
||||
/**
|
||||
* BlockTrimmer base class.
|
||||
*/
|
||||
export abstract class BlockTrimmer {
|
||||
static isSupported(languageId: string): boolean {
|
||||
return StatementTree.isSupported(languageId);
|
||||
}
|
||||
|
||||
/** Tests for the subset of supported languages that are trimmed by default */
|
||||
static isTrimmedByDefault(languageId: string): boolean {
|
||||
return StatementTree.isTrimmedByDefault(languageId);
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected readonly languageId: string,
|
||||
protected readonly prefix: string,
|
||||
protected readonly completion: string
|
||||
) { }
|
||||
|
||||
abstract getCompletionTrimOffset(): Promise<number | undefined>;
|
||||
|
||||
protected async withParsedStatementTree<T>(fn: (tree: StatementTree) => Promise<T> | T): Promise<T> {
|
||||
const tree = StatementTree.create(
|
||||
this.languageId,
|
||||
this.prefix + this.completion,
|
||||
this.prefix.length,
|
||||
this.prefix.length + this.completion.length
|
||||
);
|
||||
await tree.build();
|
||||
|
||||
try {
|
||||
return await fn(tree);
|
||||
} finally {
|
||||
tree[Symbol.dispose]();
|
||||
}
|
||||
}
|
||||
|
||||
protected trimmedCompletion(offset: number | undefined): string {
|
||||
return offset === undefined ? this.completion : this.completion.substring(0, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the statement at the cursor position.
|
||||
* If the cursor is not within a statement (e.g. it's on an error node),
|
||||
* returns the first statement from the tree (if any).
|
||||
*/
|
||||
protected getStatementAtCursor(tree: StatementTree): StatementNode | undefined {
|
||||
return tree.statementAt(Math.max(this.prefix.length - 1, 0)) ?? tree.statements[0];
|
||||
}
|
||||
|
||||
protected getContainingBlockOffset(stmt: StatementNode | undefined): number | undefined {
|
||||
let trimTo: StatementNode | undefined;
|
||||
if (stmt && this.isCompoundStatement(stmt)) {
|
||||
// for compound statement types, trim to the current statement
|
||||
trimTo = stmt;
|
||||
} else if (stmt) {
|
||||
// for non-compound statement types, trim to the closest compound ancestor
|
||||
let parent = stmt.parent;
|
||||
while (parent && !this.isCompoundStatement(parent)) {
|
||||
parent = parent.parent;
|
||||
}
|
||||
trimTo = parent;
|
||||
}
|
||||
|
||||
if (trimTo) {
|
||||
const newOffset = this.asCompletionOffset(trimTo.node.endIndex);
|
||||
|
||||
// don't trim trailing whitespace as that will terminate the completion prematurely
|
||||
if (newOffset && this.completion.substring(newOffset).trim() !== '') { return newOffset; }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected hasNonStatementContentAfter(stmt: StatementNode | undefined): boolean {
|
||||
if (!stmt || !stmt.nextSibling) { return false; }
|
||||
const spanStart = this.asCompletionOffset(stmt.node.endIndex);
|
||||
const spanEnd = this.asCompletionOffset(stmt.nextSibling.node.startIndex);
|
||||
const content = this.completion.substring(Math.max(0, spanStart ?? 0), Math.max(0, spanEnd ?? 0));
|
||||
return content.trim() !== '';
|
||||
}
|
||||
|
||||
protected asCompletionOffset(offset: number | undefined): number | undefined {
|
||||
return offset === undefined ? undefined : offset - this.prefix.length;
|
||||
}
|
||||
|
||||
protected isCompoundStatement(stmt: StatementNode): boolean {
|
||||
return stmt.isCompoundStatementType || stmt.children.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A block trimmer that tries to obtain the longest reasonable completion
|
||||
* within its line limit. This results in a more verbose completion.
|
||||
*
|
||||
* Don't delete it is used in tests.
|
||||
*/
|
||||
export class VerboseBlockTrimmer extends BlockTrimmer {
|
||||
private readonly offsetLimit: number | undefined;
|
||||
|
||||
constructor(
|
||||
languageId: string,
|
||||
prefix: string,
|
||||
completion: string,
|
||||
private readonly lineLimit: number = 10
|
||||
) {
|
||||
super(languageId, prefix, completion);
|
||||
// determine the end of the lineLimit line as an offset into the completion
|
||||
const completionLineEnds = [...this.completion.matchAll(/\n/g)];
|
||||
if (completionLineEnds.length >= this.lineLimit && this.lineLimit > 0) {
|
||||
this.offsetLimit = completionLineEnds[this.lineLimit - 1].index;
|
||||
} else {
|
||||
this.offsetLimit = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getCompletionTrimOffset(): Promise<number | undefined> {
|
||||
return await this.withParsedStatementTree(tree => {
|
||||
const stmt = this.getStatementAtCursor(tree);
|
||||
|
||||
// do not go past the containing block
|
||||
let offset = this.getContainingBlockOffset(stmt);
|
||||
|
||||
// first try trimming at a blank line
|
||||
if (!this.isWithinLimit(offset)) {
|
||||
offset = this.trimToBlankLine(offset);
|
||||
}
|
||||
|
||||
// then try trimming at a statement
|
||||
if (!this.isWithinLimit(offset)) {
|
||||
offset = this.trimToStatement(stmt, offset);
|
||||
}
|
||||
|
||||
return offset;
|
||||
});
|
||||
}
|
||||
|
||||
private isWithinLimit(offset: number | undefined): boolean {
|
||||
return this.offsetLimit === undefined || (offset !== undefined && offset <= this.offsetLimit);
|
||||
}
|
||||
|
||||
private trimToBlankLine(offset: number | undefined): number | undefined {
|
||||
const blankLines = [...this.trimmedCompletion(offset).matchAll(/\r?\n\s*\r?\n/g)].reverse();
|
||||
while (blankLines.length > 0 && !this.isWithinLimit(offset)) {
|
||||
const match = blankLines.pop()!;
|
||||
offset = match.index;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private trimToStatement(stmt: StatementNode | undefined, offset: number | undefined): number | undefined {
|
||||
const min = this.prefix.length;
|
||||
const max = this.prefix.length + (this.offsetLimit ?? this.completion.length);
|
||||
let s = stmt;
|
||||
let next = stmt?.nextSibling;
|
||||
while (next && next.node.endIndex <= max && !this.hasNonStatementContentAfter(s)) {
|
||||
s = next;
|
||||
next = next.nextSibling;
|
||||
}
|
||||
if (s && s === stmt && s.node.endIndex <= min) {
|
||||
s = next;
|
||||
}
|
||||
if (s && s.node.endIndex > max) {
|
||||
// break at an internal statement if possible
|
||||
return this.trimToStatement(s.children[0], this.asCompletionOffset(s.node.endIndex));
|
||||
}
|
||||
return this.asCompletionOffset(s?.node?.endIndex) ?? offset;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A block trimmer that stops when it's likely the end of a logical section has
|
||||
* been reached, such as the start of a new compound statement. This results in
|
||||
* a more terse completion.
|
||||
*/
|
||||
export class TerseBlockTrimmer extends BlockTrimmer {
|
||||
private readonly limitOffset: number | undefined;
|
||||
private readonly lookAheadOffset: number | undefined;
|
||||
|
||||
constructor(
|
||||
languageId: string,
|
||||
prefix: string,
|
||||
completion: string,
|
||||
private readonly lineLimit: number = 3,
|
||||
private readonly lookAhead: number = 7
|
||||
) {
|
||||
super(languageId, prefix, completion);
|
||||
// determine the end of the lineLimit line as an offset into the completion
|
||||
const completionLineEnds = [...this.completion.matchAll(/\n/g)];
|
||||
const limitAndLookAhead = this.lineLimit + this.lookAhead;
|
||||
if (completionLineEnds.length >= this.lineLimit && this.lineLimit > 0) {
|
||||
this.limitOffset = completionLineEnds[this.lineLimit - 1].index;
|
||||
}
|
||||
if (completionLineEnds.length >= limitAndLookAhead && limitAndLookAhead > 0) {
|
||||
this.lookAheadOffset = completionLineEnds[limitAndLookAhead - 1].index;
|
||||
}
|
||||
}
|
||||
|
||||
async getCompletionTrimOffset(): Promise<number | undefined> {
|
||||
return await this.withParsedStatementTree(tree => {
|
||||
const stmt = tree.statementAt(this.stmtStartPos());
|
||||
|
||||
// do not go past the containing block
|
||||
let offset = this.getContainingBlockOffset(stmt);
|
||||
|
||||
// trim at any blank lines
|
||||
offset = this.trimAtFirstBlankLine(offset);
|
||||
|
||||
// trim at new blocks starts or areas of comments
|
||||
if (stmt) {
|
||||
offset = this.trimAtStatementChange(stmt, offset);
|
||||
}
|
||||
|
||||
// hard trim at the line limit if we have enough context
|
||||
if (this.limitOffset && this.lookAheadOffset && (offset === undefined || offset > this.lookAheadOffset)) {
|
||||
return this.limitOffset;
|
||||
}
|
||||
|
||||
return offset;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the position of the first non-whitespace character to the right
|
||||
* of the cursor, or the start of the completion if it is blank.
|
||||
*/
|
||||
private stmtStartPos(): number {
|
||||
const match = this.completion.match(/\S/);
|
||||
if (match && match.index !== undefined) {
|
||||
return this.prefix.length + match.index;
|
||||
}
|
||||
return Math.max(this.prefix.length - 1, 0);
|
||||
}
|
||||
|
||||
private trimAtFirstBlankLine(offset: number | undefined): number | undefined {
|
||||
const blankLines = [...this.trimmedCompletion(offset).matchAll(/\r?\n\s*\r?\n/g)];
|
||||
|
||||
while (blankLines.length > 0 && (offset === undefined || offset > blankLines[0].index)) {
|
||||
const match = blankLines.shift()!;
|
||||
if (this.completion.substring(0, match.index).trim() !== '') {
|
||||
return match.index;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private trimAtStatementChange(stmt: StatementNode, offset: number | undefined): number | undefined {
|
||||
const min = this.prefix.length;
|
||||
const max = this.prefix.length + (offset ?? this.completion.length);
|
||||
|
||||
// if the first statement is a compound statement, trim to the first statement
|
||||
if (stmt.node.endIndex > min && this.isCompoundStatement(stmt)) {
|
||||
// if we have a next sibling, the statement is likely finished
|
||||
if (stmt.nextSibling && stmt.node.endIndex < max) {
|
||||
return this.asCompletionOffset(stmt.node.endIndex);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
// otherwise, stop at the first compound statement or non-statement content
|
||||
let s = stmt;
|
||||
let next = stmt.nextSibling;
|
||||
while (
|
||||
next &&
|
||||
next.node.endIndex <= max &&
|
||||
!this.hasNonStatementContentAfter(s) &&
|
||||
!this.isCompoundStatement(next)
|
||||
) {
|
||||
s = next;
|
||||
next = next.nextSibling;
|
||||
}
|
||||
if (next && s.node.endIndex > min && s.node.endIndex < max) {
|
||||
return this.asCompletionOffset(s.node.endIndex);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
|
||||
export enum BlockPositionType {
|
||||
NonBlock = 'non-block',
|
||||
EmptyBlock = 'empty-block',
|
||||
BlockEnd = 'block-end',
|
||||
MidBlock = 'mid-block',
|
||||
}
|
||||
|
||||
export async function getBlockPositionType(
|
||||
document: TextDocumentContents,
|
||||
position: IPosition
|
||||
): Promise<BlockPositionType> {
|
||||
const text = document.getText();
|
||||
const offset = document.offsetAt(position);
|
||||
const tree = StatementTree.create(document.detectedLanguageId, text, 0, text.length);
|
||||
try {
|
||||
await tree.build();
|
||||
|
||||
const stmt = tree.statementAt(offset);
|
||||
|
||||
if (!stmt) { return BlockPositionType.NonBlock; }
|
||||
|
||||
if (!stmt.isCompoundStatementType && stmt.children.length === 0) {
|
||||
if (stmt.parent && !stmt.nextSibling && stmt.node.endPosition.row <= position.line) {
|
||||
return BlockPositionType.BlockEnd;
|
||||
} else if (stmt.parent) {
|
||||
return BlockPositionType.MidBlock;
|
||||
}
|
||||
return BlockPositionType.NonBlock;
|
||||
}
|
||||
|
||||
if (stmt.children.length === 0) {
|
||||
return BlockPositionType.EmptyBlock;
|
||||
}
|
||||
|
||||
const lastChild = stmt.children[stmt.children.length - 1];
|
||||
if (offset < lastChild.node.startIndex) {
|
||||
return BlockPositionType.MidBlock;
|
||||
}
|
||||
|
||||
return BlockPositionType.BlockEnd;
|
||||
} finally {
|
||||
tree[Symbol.dispose]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { LRURadixTrie } from '../helpers/radix';
|
||||
import { APIChoice } from '../openai/openai';
|
||||
|
||||
interface CompletionsCacheContents {
|
||||
content: {
|
||||
suffix: string;
|
||||
choice: APIChoice;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const ICompletionsCacheService = createServiceIdentifier<ICompletionsCacheService>('ICompletionsCacheService');
|
||||
export interface ICompletionsCacheService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Given a document prefix and suffix, return all of the completions that match. */
|
||||
findAll(prefix: string, suffix: string): APIChoice[];
|
||||
|
||||
/** Add cached completions for a given prefix. */
|
||||
append(prefix: string, suffix: string, choice: APIChoice): void;
|
||||
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/** Caches recent completions by document prefix. */
|
||||
export class CompletionsCache implements ICompletionsCacheService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
private cache = new LRURadixTrie<CompletionsCacheContents>(100);
|
||||
|
||||
/** Given a document prefix and suffix, return all of the completions that match. */
|
||||
findAll(prefix: string, suffix: string): APIChoice[] {
|
||||
return this.cache.findAll(prefix).flatMap(({ remainingKey, value }) =>
|
||||
value.content
|
||||
.filter(
|
||||
c =>
|
||||
c.suffix === suffix &&
|
||||
c.choice.completionText.startsWith(remainingKey) &&
|
||||
c.choice.completionText.length > remainingKey.length
|
||||
)
|
||||
.map(c => ({
|
||||
...c.choice,
|
||||
completionText: c.choice.completionText.slice(remainingKey.length),
|
||||
telemetryData: c.choice.telemetryData.extendedBy({}, { foundOffset: remainingKey.length }),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/** Add cached completions for a given prefix. */
|
||||
append(prefix: string, suffix: string, choice: APIChoice) {
|
||||
const existing = this.cache.findAll(prefix);
|
||||
// Append to an existing array if there is an exact match.
|
||||
if (existing.length > 0 && existing[0].remainingKey === '') {
|
||||
const content = existing[0].value.content;
|
||||
this.cache.set(prefix, { content: [...content, { suffix, choice }] });
|
||||
} else {
|
||||
// Otherwise, add a new value.
|
||||
this.cache.set(prefix, { content: [{ suffix, choice }] });
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache = new LRURadixTrie<CompletionsCacheContents>(100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// The following code was moved from config.ts into here to break the cyclic dependencies
|
||||
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { BlockMode } from '../../../../../completions/common/config';
|
||||
import { isSupportedLanguageId } from '../../../prompt/src/parse';
|
||||
import { ConfigKey, getConfig } from '../config';
|
||||
import { ICompletionsFeaturesService } from '../experiments/featuresService';
|
||||
import { TelemetryWithExp } from '../telemetry';
|
||||
import { BlockTrimmer } from './blockTrimmer';
|
||||
import { StatementTree } from './statementTree';
|
||||
|
||||
export const ICompletionsBlockModeConfig = createServiceIdentifier<ICompletionsBlockModeConfig>('ICompletionsBlockModeConfig');
|
||||
export interface ICompletionsBlockModeConfig {
|
||||
readonly _serviceBrand: undefined;
|
||||
forLanguage(languageId: string, telemetryData: TelemetryWithExp): BlockMode;
|
||||
}
|
||||
|
||||
export class ConfigBlockModeConfig implements ICompletionsBlockModeConfig {
|
||||
declare _serviceBrand: undefined;
|
||||
constructor(
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ICompletionsFeaturesService private readonly featuresService: ICompletionsFeaturesService,
|
||||
) { }
|
||||
|
||||
forLanguage(languageId: string, telemetryData: TelemetryWithExp): BlockMode {
|
||||
const overrideBlockMode = this.featuresService.overrideBlockMode(telemetryData);
|
||||
if (overrideBlockMode) {
|
||||
return toApplicableBlockMode(overrideBlockMode, languageId);
|
||||
}
|
||||
const progressiveReveal = this.featuresService.enableProgressiveReveal(telemetryData);
|
||||
const config = this.instantiationService.invokeFunction(getConfig, ConfigKey.AlwaysRequestMultiline);
|
||||
if (config ?? progressiveReveal) {
|
||||
return toApplicableBlockMode(BlockMode.MoreMultiline, languageId);
|
||||
}
|
||||
|
||||
if (BlockTrimmer.isTrimmedByDefault(languageId)) {
|
||||
return toApplicableBlockMode(BlockMode.MoreMultiline, languageId);
|
||||
}
|
||||
// special casing once cancellations based on tree-sitter propagate to
|
||||
// the proxy.
|
||||
if (languageId === 'ruby') {
|
||||
return BlockMode.Parsing;
|
||||
}
|
||||
// For existing multiline languages use standard tree-sitter based parsing
|
||||
// plus proxy-side trimming
|
||||
if (isSupportedLanguageId(languageId)) {
|
||||
return BlockMode.ParsingAndServer;
|
||||
}
|
||||
return BlockMode.Server;
|
||||
}
|
||||
}
|
||||
|
||||
function blockModeRequiresTreeSitter(blockMode: BlockMode): boolean {
|
||||
return [BlockMode.Parsing, BlockMode.ParsingAndServer, BlockMode.MoreMultiline].includes(blockMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents tree-sitter parsing from being applied to languages we don't include
|
||||
* parsers for.
|
||||
*/
|
||||
function toApplicableBlockMode(blockMode: BlockMode, languageId: string): BlockMode {
|
||||
if (blockMode === BlockMode.MoreMultiline && StatementTree.isSupported(languageId)) {
|
||||
return blockMode;
|
||||
}
|
||||
if (blockModeRequiresTreeSitter(blockMode) && !isSupportedLanguageId(languageId)) {
|
||||
return BlockMode.Server;
|
||||
}
|
||||
return blockMode;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
export const contextualFilterCharacterMap: { [key: string]: number } = {
|
||||
' ': 1,
|
||||
'!': 2,
|
||||
'"': 3,
|
||||
'#': 4,
|
||||
$: 5,
|
||||
'%': 6,
|
||||
'&': 7,
|
||||
'\'': 8,
|
||||
'(': 9,
|
||||
')': 10,
|
||||
'*': 11,
|
||||
'+': 12,
|
||||
',': 13,
|
||||
'-': 14,
|
||||
'.': 15,
|
||||
'/': 16,
|
||||
'0': 17,
|
||||
'1': 18,
|
||||
'2': 19,
|
||||
'3': 20,
|
||||
'4': 21,
|
||||
'5': 22,
|
||||
'6': 23,
|
||||
'7': 24,
|
||||
'8': 25,
|
||||
'9': 26,
|
||||
':': 27,
|
||||
';': 28,
|
||||
'<': 29,
|
||||
'=': 30,
|
||||
'>': 31,
|
||||
'?': 32,
|
||||
'@': 33,
|
||||
A: 34,
|
||||
B: 35,
|
||||
C: 36,
|
||||
D: 37,
|
||||
E: 38,
|
||||
F: 39,
|
||||
G: 40,
|
||||
H: 41,
|
||||
I: 42,
|
||||
J: 43,
|
||||
K: 44,
|
||||
L: 45,
|
||||
M: 46,
|
||||
N: 47,
|
||||
O: 48,
|
||||
P: 49,
|
||||
Q: 50,
|
||||
R: 51,
|
||||
S: 52,
|
||||
T: 53,
|
||||
U: 54,
|
||||
V: 55,
|
||||
W: 56,
|
||||
X: 57,
|
||||
Y: 58,
|
||||
Z: 59,
|
||||
'[': 60,
|
||||
'\\': 61,
|
||||
']': 62,
|
||||
'^': 63,
|
||||
_: 64,
|
||||
'`': 65,
|
||||
a: 66,
|
||||
b: 67,
|
||||
c: 68,
|
||||
d: 69,
|
||||
e: 70,
|
||||
f: 71,
|
||||
g: 72,
|
||||
h: 73,
|
||||
i: 74,
|
||||
j: 75,
|
||||
k: 76,
|
||||
l: 77,
|
||||
m: 78,
|
||||
n: 79,
|
||||
o: 80,
|
||||
p: 81,
|
||||
q: 82,
|
||||
r: 83,
|
||||
s: 84,
|
||||
t: 85,
|
||||
u: 86,
|
||||
v: 87,
|
||||
w: 88,
|
||||
x: 89,
|
||||
y: 90,
|
||||
z: 91,
|
||||
'{': 92,
|
||||
'|': 93,
|
||||
'}': 94,
|
||||
'~': 95,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { CopilotNamedAnnotationList } from '../../../../../../platform/completions-core/common/openai/copilotAnnotations';
|
||||
import { generateUuid } from '../../../../../../util/vs/base/common/uuid';
|
||||
import { TelemetryWithExp } from '../telemetry';
|
||||
import { IPosition, IRange, LocationFactory, TextDocumentContents } from '../textDocument';
|
||||
import { CompletionResult, ResultType } from './ghostText';
|
||||
import { ITextEditorOptions, normalizeIndentCharacter } from './normalizeIndent';
|
||||
|
||||
export interface CopilotCompletion {
|
||||
uuid: string;
|
||||
insertText: string;
|
||||
range: IRange;
|
||||
uri: string;
|
||||
telemetry: TelemetryWithExp;
|
||||
displayText: string;
|
||||
position: IPosition;
|
||||
offset: number;
|
||||
index: number;
|
||||
resultType: ResultType;
|
||||
copilotAnnotations?: CopilotNamedAnnotationList;
|
||||
clientCompletionId: string;
|
||||
}
|
||||
|
||||
export function completionsFromGhostTextResults(
|
||||
completionResults: CompletionResult[],
|
||||
resultType: ResultType,
|
||||
document: TextDocumentContents,
|
||||
position: IPosition,
|
||||
textEditorOptions?: ITextEditorOptions,
|
||||
lastShownCompletionIndex?: number
|
||||
): CopilotCompletion[] {
|
||||
const currentLine = document.lineAt(position);
|
||||
let completions = completionResults.map(result => {
|
||||
const range = LocationFactory.range(
|
||||
LocationFactory.position(position.line, 0),
|
||||
LocationFactory.position(position.line, position.character + result.suffixCoverage)
|
||||
);
|
||||
let insertText = '';
|
||||
if (textEditorOptions) {
|
||||
result.completion = normalizeIndentCharacter(
|
||||
textEditorOptions,
|
||||
result.completion,
|
||||
currentLine.isEmptyOrWhitespace
|
||||
);
|
||||
}
|
||||
if (
|
||||
currentLine.isEmptyOrWhitespace &&
|
||||
(result.completion.displayNeedsWsOffset || // Deindenting case
|
||||
// This enables stable behavior for deleting whitespace on blank lines
|
||||
result.completion.completionText.startsWith(currentLine.text))
|
||||
) {
|
||||
insertText = result.completion.completionText;
|
||||
} else {
|
||||
const rangeFromStart = LocationFactory.range(range.start, position);
|
||||
insertText = document.getText(rangeFromStart) + result.completion.displayText;
|
||||
}
|
||||
|
||||
const completion: CopilotCompletion = {
|
||||
uuid: generateUuid(),
|
||||
insertText,
|
||||
range,
|
||||
uri: document.uri,
|
||||
index: result.completion.completionIndex,
|
||||
telemetry: result.telemetry,
|
||||
displayText: result.completion.displayText,
|
||||
position,
|
||||
offset: document.offsetAt(position),
|
||||
resultType,
|
||||
copilotAnnotations: result.copilotAnnotations,
|
||||
clientCompletionId: result.clientCompletionId,
|
||||
};
|
||||
return completion;
|
||||
});
|
||||
//If we are in typing as suggested flow, we want to put the last displayed completion at the top of the list to keep it selected
|
||||
if (resultType === ResultType.TypingAsSuggested && lastShownCompletionIndex !== undefined) {
|
||||
const lastShownCompletion = completions.find(predicate => predicate.index === lastShownCompletionIndex);
|
||||
if (lastShownCompletion) {
|
||||
const restCompletions = completions.filter(predicate => predicate.index !== lastShownCompletionIndex);
|
||||
completions = [lastShownCompletion, ...restCompletions];
|
||||
}
|
||||
}
|
||||
return completions;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { APIChoice } from '../openai/openai';
|
||||
import { ResultType } from './ghostText';
|
||||
|
||||
export const ICompletionsCurrentGhostText = createServiceIdentifier<ICompletionsCurrentGhostText>('ICompletionsCurrentGhostText');
|
||||
export interface ICompletionsCurrentGhostText {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readonly clientCompletionId: string | undefined;
|
||||
|
||||
currentRequestId: string | undefined;
|
||||
|
||||
setGhostText(prefix: string, suffix: string, choices: APIChoice[], resultType: ResultType): void;
|
||||
getCompletionsForUserTyping(prefix: string, suffix: string): APIChoice[] | undefined;
|
||||
hasAcceptedCurrentCompletion(prefix: string, suffix: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the internal concept of the currently shown completion, as inferred by
|
||||
* the output of getGhostText. Used to check if a subsequent call to
|
||||
* getGhostText is typing-as-suggested.
|
||||
*/
|
||||
export class CurrentGhostText implements ICompletionsCurrentGhostText {
|
||||
declare _serviceBrand: undefined;
|
||||
/** The document prefix at the start of the typing-as-suggested flow. This
|
||||
* does not use the prompt prefix since the ellision means that prefix is a
|
||||
* sliding window over long documents. */
|
||||
private prefix?: string;
|
||||
|
||||
/** The prompt suffix at the start of the typing-as-suggested flow. */
|
||||
private suffix?: string;
|
||||
|
||||
/** The original APIChoice array created at the start of the
|
||||
* typing-as-suggested flow. The first element in the array should be the
|
||||
* completion shown to the user. */
|
||||
private choices: APIChoice[] = [];
|
||||
|
||||
/** The currently shown completion id. */
|
||||
get clientCompletionId(): string | undefined {
|
||||
return this.choices[0]?.clientCompletionId;
|
||||
}
|
||||
|
||||
/** The most recent inline completion request id, excluding speculative requests. */
|
||||
currentRequestId: string | undefined;
|
||||
|
||||
/** Updates the current ghost text if it was not produced via
|
||||
* TypingAsSuggested. Should only be called from the end of getGhostText. */
|
||||
setGhostText(prefix: string, suffix: string, choices: APIChoice[], resultType: ResultType) {
|
||||
if (resultType === ResultType.TypingAsSuggested) { return; }
|
||||
this.prefix = prefix;
|
||||
this.suffix = suffix;
|
||||
this.choices = choices;
|
||||
}
|
||||
|
||||
/** Returns the current choices if the request context matches. */
|
||||
getCompletionsForUserTyping(prefix: string, suffix: string): APIChoice[] | undefined {
|
||||
const remainingPrefix = this.getRemainingPrefix(prefix, suffix);
|
||||
if (remainingPrefix === undefined) { return; }
|
||||
// If the first choice text does not match return empty to fall through
|
||||
// to either the cache or network.
|
||||
if (!startsWithAndExceeds(this.choices[0].completionText, remainingPrefix)) { return; }
|
||||
return adjustChoicesStart(this.choices, remainingPrefix);
|
||||
}
|
||||
|
||||
/** Returns whether the current completion is fully completed, and covers a full line. */
|
||||
hasAcceptedCurrentCompletion(prefix: string, suffix: string): boolean {
|
||||
const remainingPrefix = this.getRemainingPrefix(prefix, suffix);
|
||||
if (remainingPrefix === undefined) { return false; }
|
||||
|
||||
// Check if the completion text matches exactly
|
||||
const exactMatch = remainingPrefix === this.choices?.[0].completionText;
|
||||
|
||||
// Check finishReason - return false if it indicates that the server cut off a part of it (thus it might not complete a full line), due to RAI or snippy
|
||||
const finishReason = this.choices?.[0].finishReason;
|
||||
return exactMatch && finishReason === 'stop';
|
||||
}
|
||||
|
||||
/** If the given document prefix and prompt suffix match the current
|
||||
* completion returns the remaining prefix of the document after the stored
|
||||
* prefix. Returns undefined if the completion does not match. */
|
||||
private getRemainingPrefix(prefix: string, suffix: string): string | undefined {
|
||||
// Check that there is a current completion.
|
||||
if (this.prefix === undefined || this.suffix === undefined || this.choices.length === 0) { return; }
|
||||
// Check that the prompt suffixes are an exact match.
|
||||
if (this.suffix !== suffix) { return; }
|
||||
// Check that the document prefix is a prefix of the new prefix.
|
||||
// This doesn't use the prompt prefix since the ellision means that
|
||||
// subsequent prefixes will not be a prefix of earlier ones.
|
||||
if (!prefix.startsWith(this.prefix)) { return; }
|
||||
// Return the remaining new document prefix after the prefix stored for
|
||||
// the current completion.
|
||||
return prefix.substring(this.prefix.length);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns choices adjusted to remove the remainingPrefix from the start of the
|
||||
* completionText if it matches. */
|
||||
function adjustChoicesStart(choices: APIChoice[], remainingPrefix: string): APIChoice[] {
|
||||
return choices
|
||||
.filter(choice => startsWithAndExceeds(choice.completionText, remainingPrefix))
|
||||
.map(choice => ({
|
||||
...choice,
|
||||
completionText: choice.completionText.substring(remainingPrefix.length),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Returns true if `prefix` is a prefix of `text` and `text` is longer. */
|
||||
function startsWithAndExceeds(text: string, prefix: string) {
|
||||
return text.startsWith(prefix) && text.length > prefix.length;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsLogTargetService, Logger } from '../logger';
|
||||
import { postInsertionTasks, postRejectionTasks } from '../postInsertion';
|
||||
import { countLines, SuggestionStatus } from '../suggestions/partialSuggestions';
|
||||
import { TelemetryWithExp } from '../telemetry';
|
||||
import { IPosition, TextDocumentContents, TextDocumentIdentifier } from '../textDocument';
|
||||
import { CopilotCompletion } from './copilotCompletion';
|
||||
import { ResultType } from './ghostText';
|
||||
import { GHOST_TEXT_CATEGORY, telemetryShown } from './telemetry';
|
||||
|
||||
const ghostTextLogger = new Logger('ghostText');
|
||||
|
||||
export const ICompletionsLastGhostText = createServiceIdentifier<ICompletionsLastGhostText>('ICompletionsLastGhostText');
|
||||
export interface ICompletionsLastGhostText {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
position: IPosition | undefined;
|
||||
uri: string | undefined;
|
||||
shownCompletions: CopilotCompletion[];
|
||||
index: number | undefined;
|
||||
totalLength: number | undefined;
|
||||
partiallyAcceptedLength: number | undefined;
|
||||
linesLeft: number | undefined;
|
||||
linesAccepted: number;
|
||||
lastLineAcceptedLength: number | undefined;
|
||||
|
||||
resetState(): void;
|
||||
setState(document: TextDocumentIdentifier, position: IPosition): void;
|
||||
resetPartialAcceptanceState(): void;
|
||||
}
|
||||
|
||||
export class LastGhostText implements ICompletionsLastGhostText {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
#position: IPosition | undefined;
|
||||
#uri: string | undefined;
|
||||
#shownCompletions: CopilotCompletion[] = [];
|
||||
index: number | undefined;
|
||||
totalLength: number | undefined;
|
||||
partiallyAcceptedLength: number | undefined;
|
||||
linesLeft: number | undefined; // Lines left to accept in the current completion, used for partial acceptance
|
||||
linesAccepted: number = 0; // Number of lines accepted in the current completion, used for partial acceptance
|
||||
lastLineAcceptedLength: number | undefined; // Length of the last accepted line, used for partial acceptance
|
||||
|
||||
get position() {
|
||||
return this.#position;
|
||||
}
|
||||
|
||||
get shownCompletions() {
|
||||
return this.#shownCompletions || [];
|
||||
}
|
||||
|
||||
get uri() {
|
||||
return this.#uri;
|
||||
}
|
||||
|
||||
resetState() {
|
||||
this.#uri = undefined;
|
||||
this.#position = undefined;
|
||||
this.#shownCompletions = [];
|
||||
this.resetPartialAcceptanceState();
|
||||
}
|
||||
|
||||
setState({ uri }: TextDocumentIdentifier, position: IPosition) {
|
||||
this.#uri = uri;
|
||||
this.#position = position;
|
||||
this.#shownCompletions = [];
|
||||
}
|
||||
|
||||
resetPartialAcceptanceState() {
|
||||
this.partiallyAcceptedLength = 0;
|
||||
this.totalLength = undefined;
|
||||
this.linesLeft = undefined;
|
||||
this.linesAccepted = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function computeRejectedCompletions<
|
||||
T extends { completionText: string; completionTelemetryData: TelemetryWithExp; offset: number },
|
||||
>(last: ICompletionsLastGhostText): T[] {
|
||||
const rejectedCompletions: T[] = [];
|
||||
last.shownCompletions.forEach(c => {
|
||||
if (c.displayText && c.telemetry) {
|
||||
let completionText;
|
||||
let completionTelemetryData;
|
||||
|
||||
if (last.partiallyAcceptedLength) {
|
||||
// suggestion got partially accepted already but rejecting the remainder
|
||||
completionText = c.displayText.substring(last.partiallyAcceptedLength - 1);
|
||||
completionTelemetryData = c.telemetry.extendedBy(
|
||||
{
|
||||
compType: 'partial',
|
||||
},
|
||||
{
|
||||
compCharLen: completionText.length,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
completionText = c.displayText;
|
||||
completionTelemetryData = c.telemetry;
|
||||
}
|
||||
const rejection = { completionText, completionTelemetryData, offset: c.offset };
|
||||
rejectedCompletions.push(rejection as T);
|
||||
}
|
||||
});
|
||||
return rejectedCompletions;
|
||||
}
|
||||
|
||||
export function rejectLastShown(accessor: ServicesAccessor, offset?: number) {
|
||||
const last = accessor.get(ICompletionsLastGhostText);
|
||||
if (!last.position || !last.uri) { return; }
|
||||
//The position has changed and we're not in typing-as-suggested flow
|
||||
// so previously shown completions can be reported as rejected
|
||||
const rejectedCompletions = computeRejectedCompletions(last);
|
||||
if (rejectedCompletions.length > 0) {
|
||||
postRejectionTasks(accessor, 'ghostText', offset ?? rejectedCompletions[0].offset, last.uri, rejectedCompletions);
|
||||
}
|
||||
last.resetState();
|
||||
last.resetPartialAcceptanceState();
|
||||
}
|
||||
|
||||
export function setLastShown(
|
||||
accessor: ServicesAccessor,
|
||||
document: TextDocumentContents,
|
||||
position: IPosition,
|
||||
resultType: ResultType
|
||||
) {
|
||||
const last = accessor.get(ICompletionsLastGhostText);
|
||||
if (
|
||||
last.position &&
|
||||
last.uri &&
|
||||
!(
|
||||
last.position.line === position.line &&
|
||||
last.position.character === position.character &&
|
||||
last.uri.toString() === document.uri.toString()
|
||||
) &&
|
||||
resultType !== ResultType.TypingAsSuggested // results for partial acceptance count as TypingAsSuggested
|
||||
) {
|
||||
rejectLastShown(accessor, document.offsetAt(last.position));
|
||||
}
|
||||
last.setState(document, position);
|
||||
return last.index;
|
||||
}
|
||||
|
||||
export function handleGhostTextShown(accessor: ServicesAccessor, cmp: CopilotCompletion) {
|
||||
const logTarget = accessor.get(ICompletionsLogTargetService);
|
||||
const last = accessor.get(ICompletionsLastGhostText);
|
||||
last.index = cmp.index;
|
||||
if (!last.shownCompletions.find(c => c.index === cmp.index)) {
|
||||
// Only update if .position is still at the position of the completion
|
||||
if (
|
||||
cmp.uri === last.uri &&
|
||||
last.position?.line === cmp.position.line &&
|
||||
last.position?.character === cmp.position.character
|
||||
) {
|
||||
last.shownCompletions.push(cmp);
|
||||
}
|
||||
// Show telemetry only if it was not shown before (i.e. don't sent repeated telemetry in cycling case when user cycled through every suggestions or goes back and forth)
|
||||
if (cmp.displayText) {
|
||||
const fromCache = !(cmp.resultType === ResultType.Network);
|
||||
ghostTextLogger.debug(
|
||||
logTarget,
|
||||
`[${cmp.telemetry.properties.headerRequestId}] shown choiceIndex: ${cmp.telemetry.properties.choiceIndex}, fromCache ${fromCache}`
|
||||
);
|
||||
cmp.telemetry.measurements.compCharLen = cmp.displayText.length;
|
||||
telemetryShown(accessor, cmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles partial acceptance for VS Code clients using line-based strategy.
|
||||
* VS Code tracks acceptance by lines and resets the accepted length per line.
|
||||
*/
|
||||
function handleLineAcceptance(accessor: ServicesAccessor, cmp: CopilotCompletion, acceptedLength: number) {
|
||||
const last = accessor.get(ICompletionsLastGhostText);
|
||||
|
||||
// If this is the first acceptance, we need to initialize the linesLeft
|
||||
if (last.linesLeft === undefined) {
|
||||
last.linesAccepted = countLines(cmp.insertText.substring(0, acceptedLength));
|
||||
last.linesLeft = countLines(cmp.displayText);
|
||||
}
|
||||
|
||||
const linesLeft = countLines(cmp.displayText);
|
||||
|
||||
if (last.linesLeft > linesLeft) {
|
||||
// If the number of lines left has decreased, we need to update the accepted lines count
|
||||
// and reset the last line accepted length
|
||||
last.linesAccepted += last.linesLeft - linesLeft;
|
||||
last.lastLineAcceptedLength = last.partiallyAcceptedLength;
|
||||
last.linesLeft = linesLeft;
|
||||
}
|
||||
|
||||
last.partiallyAcceptedLength = (last.lastLineAcceptedLength || 0) + acceptedLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles full acceptance of ghost text completions.
|
||||
* This method is primarily used by VS Code for explicit full acceptances.
|
||||
*/
|
||||
export function handleGhostTextPostInsert(
|
||||
accessor: ServicesAccessor,
|
||||
cmp: CopilotCompletion,
|
||||
) {
|
||||
const last = accessor.get(ICompletionsLastGhostText);
|
||||
|
||||
let suggestionStatus: SuggestionStatus;
|
||||
|
||||
if (last.partiallyAcceptedLength) {
|
||||
suggestionStatus = {
|
||||
compType: 'full',
|
||||
acceptedLength: (last.partiallyAcceptedLength || 0) + cmp.displayText.length,
|
||||
acceptedLines: last.linesAccepted + (last.linesLeft ?? 0),
|
||||
};
|
||||
} else {
|
||||
suggestionStatus = {
|
||||
compType: 'full',
|
||||
acceptedLength: cmp.displayText.length,
|
||||
acceptedLines: countLines(cmp.displayText),
|
||||
};
|
||||
}
|
||||
|
||||
//If any completion was accepted, clear the list of shown completions
|
||||
//that would be passed to rejected telemetry
|
||||
last.resetState();
|
||||
|
||||
return postInsertionTasks(
|
||||
accessor,
|
||||
GHOST_TEXT_CATEGORY,
|
||||
cmp.displayText,
|
||||
cmp.offset,
|
||||
cmp.uri,
|
||||
cmp.telemetry,
|
||||
suggestionStatus,
|
||||
cmp.copilotAnnotations
|
||||
);
|
||||
}
|
||||
|
||||
export function handlePartialGhostTextPostInsert(
|
||||
accessor: ServicesAccessor,
|
||||
cmp: CopilotCompletion,
|
||||
acceptedLength: number,
|
||||
) {
|
||||
const last = accessor.get(ICompletionsLastGhostText);
|
||||
|
||||
handleLineAcceptance(accessor, cmp, acceptedLength);
|
||||
|
||||
const suggestionStatus: SuggestionStatus = {
|
||||
compType: 'partial',
|
||||
acceptedLength: last.partiallyAcceptedLength || 0,
|
||||
acceptedLines: last.linesAccepted,
|
||||
};
|
||||
|
||||
return postInsertionTasks(
|
||||
accessor,
|
||||
GHOST_TEXT_CATEGORY,
|
||||
cmp.displayText,
|
||||
cmp.offset,
|
||||
cmp.uri,
|
||||
cmp.telemetry,
|
||||
suggestionStatus,
|
||||
cmp.copilotAnnotations
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Prompt } from '../prompt/prompt';
|
||||
import { contextualFilterCharacterMap } from './contextualFilterConstants';
|
||||
import { multilineModelPredict } from './multilineModelWeights';
|
||||
|
||||
// This comment map is based on the preprocessing used in training data.
|
||||
// It should not be changed without a corresponding change in model training.
|
||||
const commentMap: { [key: string]: string[] } = {
|
||||
javascript: ['//'],
|
||||
typescript: ['//'],
|
||||
typescriptreact: ['//'],
|
||||
javascriptreact: ['//'],
|
||||
vue: ['//', '-->'],
|
||||
php: ['//', '#'],
|
||||
dart: ['//'],
|
||||
go: ['//'],
|
||||
cpp: ['//'],
|
||||
scss: ['//'],
|
||||
csharp: ['//'],
|
||||
java: ['//'],
|
||||
c: ['//'],
|
||||
rust: ['//'],
|
||||
python: ['#'],
|
||||
markdown: ['#', '-->'],
|
||||
css: ['*/'],
|
||||
};
|
||||
|
||||
// This language map is based on the preprocessing used in training data.
|
||||
// It should not be changed without a corresponding change in model training.
|
||||
const languageMap: { [key: string]: number } = {
|
||||
javascript: 1,
|
||||
javascriptreact: 2,
|
||||
typescript: 3,
|
||||
typescriptreact: 4,
|
||||
python: 5,
|
||||
go: 6,
|
||||
ruby: 7,
|
||||
};
|
||||
|
||||
export function hasComment(text: string, lineNumber: number, language: string, ignoreEmptyLines = true): boolean {
|
||||
let lines = text.split('\n');
|
||||
if (ignoreEmptyLines) {
|
||||
lines = lines.filter(line => line.trim().length > 0);
|
||||
}
|
||||
if (Math.abs(lineNumber) > lines.length || lineNumber >= lines.length) {
|
||||
return false;
|
||||
}
|
||||
if (lineNumber < 0) {
|
||||
lineNumber = lines.length + lineNumber;
|
||||
}
|
||||
const line = lines[lineNumber];
|
||||
const commentChars = commentMap[language] ?? [];
|
||||
return commentChars.some(commentChar => line.includes(commentChar));
|
||||
}
|
||||
|
||||
export class PromptFeatures {
|
||||
language: string;
|
||||
length: number;
|
||||
firstLineLength: number;
|
||||
lastLineLength: number;
|
||||
lastLineRstripLength: number;
|
||||
lastLineStripLength: number;
|
||||
rstripLength: number;
|
||||
stripLength: number;
|
||||
rstripLastLineLength: number;
|
||||
rstripLastLineStripLength: number;
|
||||
secondToLastLineHasComment: boolean;
|
||||
rstripSecondToLastLineHasComment: boolean;
|
||||
prefixEndsWithNewline: boolean;
|
||||
lastChar: string;
|
||||
rstripLastChar: string;
|
||||
firstChar: string;
|
||||
lstripFirstChar: string;
|
||||
|
||||
constructor(promptComponentText: string, language: string) {
|
||||
const [firstLine, lastLine] = this.firstAndLast(promptComponentText);
|
||||
const firstAndLastTrimEnd = this.firstAndLast(promptComponentText.trimEnd());
|
||||
this.language = language;
|
||||
this.length = promptComponentText.length;
|
||||
this.firstLineLength = firstLine.length;
|
||||
this.lastLineLength = lastLine.length;
|
||||
this.lastLineRstripLength = lastLine.trimEnd().length;
|
||||
this.lastLineStripLength = lastLine.trim().length;
|
||||
this.rstripLength = promptComponentText.trimEnd().length;
|
||||
this.stripLength = promptComponentText.trim().length;
|
||||
this.rstripLastLineLength = firstAndLastTrimEnd[1].length;
|
||||
this.rstripLastLineStripLength = firstAndLastTrimEnd[1].trim().length;
|
||||
this.secondToLastLineHasComment = hasComment(promptComponentText, -2, language);
|
||||
this.rstripSecondToLastLineHasComment = hasComment(promptComponentText.trimEnd(), -2, language);
|
||||
this.prefixEndsWithNewline = promptComponentText.endsWith('\n');
|
||||
this.lastChar = promptComponentText.slice(-1);
|
||||
this.rstripLastChar = promptComponentText.trimEnd().slice(-1);
|
||||
this.firstChar = promptComponentText[0];
|
||||
this.lstripFirstChar = promptComponentText.trimStart().slice(0, 1);
|
||||
}
|
||||
|
||||
firstAndLast(text: string): string[] {
|
||||
const lines = text.split('\n');
|
||||
const numLines = lines.length;
|
||||
const firstLine = lines[0];
|
||||
let lastLine = lines[numLines - 1];
|
||||
if (lastLine === '' && numLines > 1) {
|
||||
lastLine = lines[numLines - 2];
|
||||
}
|
||||
return [firstLine, lastLine];
|
||||
}
|
||||
}
|
||||
|
||||
export class MultilineModelFeatures {
|
||||
language: string;
|
||||
prefixFeatures: PromptFeatures;
|
||||
suffixFeatures: PromptFeatures;
|
||||
|
||||
constructor(prefix: string, suffix: string, language: string) {
|
||||
this.language = language;
|
||||
this.prefixFeatures = new PromptFeatures(prefix, language);
|
||||
this.suffixFeatures = new PromptFeatures(suffix, language);
|
||||
}
|
||||
|
||||
constructFeatures(): number[] {
|
||||
// These features are ordered according to the features used in model training.
|
||||
// They should not be reordered or revised without a corresponding change in model training.
|
||||
// It is likely that not all of these features are truly necessary. However
|
||||
// for now we use the same features used in the model trained by AIP for initial evaluation.
|
||||
const numFeatures: number[] = new Array<number>(14).fill(0);
|
||||
numFeatures[0] = this.prefixFeatures.length;
|
||||
numFeatures[1] = this.prefixFeatures.firstLineLength;
|
||||
numFeatures[2] = this.prefixFeatures.lastLineLength;
|
||||
numFeatures[3] = this.prefixFeatures.lastLineRstripLength;
|
||||
numFeatures[4] = this.prefixFeatures.lastLineStripLength;
|
||||
numFeatures[5] = this.prefixFeatures.rstripLength;
|
||||
numFeatures[6] = this.prefixFeatures.rstripLastLineLength;
|
||||
numFeatures[7] = this.prefixFeatures.rstripLastLineStripLength;
|
||||
numFeatures[8] = this.suffixFeatures.length;
|
||||
numFeatures[9] = this.suffixFeatures.firstLineLength;
|
||||
numFeatures[10] = this.suffixFeatures.lastLineLength;
|
||||
numFeatures[11] = this.prefixFeatures.secondToLastLineHasComment ? 1 : 0;
|
||||
numFeatures[12] = this.prefixFeatures.rstripSecondToLastLineHasComment ? 1 : 0;
|
||||
numFeatures[13] = this.prefixFeatures.prefixEndsWithNewline ? 1 : 0;
|
||||
|
||||
const langFeatures: number[] = new Array<number>(Object.keys(languageMap).length + 1).fill(0);
|
||||
langFeatures[languageMap[this.language] ?? 0] = 1;
|
||||
|
||||
const prefixLastCharFeatures: number[] = new Array<number>(
|
||||
Object.keys(contextualFilterCharacterMap).length + 1
|
||||
).fill(0);
|
||||
prefixLastCharFeatures[contextualFilterCharacterMap[this.prefixFeatures.lastChar] ?? 0] = 1;
|
||||
|
||||
const prefixRstripLastCharFeatures: number[] = new Array<number>(
|
||||
Object.keys(contextualFilterCharacterMap).length + 1
|
||||
).fill(0);
|
||||
prefixRstripLastCharFeatures[contextualFilterCharacterMap[this.prefixFeatures.rstripLastChar] ?? 0] = 1;
|
||||
|
||||
const suffixFirstCharFeatures: number[] = new Array<number>(
|
||||
Object.keys(contextualFilterCharacterMap).length + 1
|
||||
).fill(0);
|
||||
suffixFirstCharFeatures[contextualFilterCharacterMap[this.suffixFeatures.firstChar] ?? 0] = 1;
|
||||
|
||||
const suffixLstripFirstCharFeatures: number[] = new Array<number>(
|
||||
Object.keys(contextualFilterCharacterMap).length + 1
|
||||
).fill(0);
|
||||
suffixLstripFirstCharFeatures[contextualFilterCharacterMap[this.suffixFeatures.lstripFirstChar] ?? 0] = 1;
|
||||
|
||||
return numFeatures.concat(
|
||||
langFeatures,
|
||||
prefixLastCharFeatures,
|
||||
prefixRstripLastCharFeatures,
|
||||
suffixFirstCharFeatures,
|
||||
suffixLstripFirstCharFeatures
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function constructMultilineFeatures(prompt: Prompt, language: string): MultilineModelFeatures {
|
||||
return new MultilineModelFeatures(prompt.prefix, prompt.suffix, language);
|
||||
}
|
||||
|
||||
export function requestMultilineScore(prompt: Prompt, language: string): number {
|
||||
// Construct features based on the prompt and language
|
||||
const features = constructMultilineFeatures(prompt, language).constructFeatures();
|
||||
// Return the score from the model which is the value at index 1 of the output array
|
||||
const score = multilineModelPredict(features)[1];
|
||||
return score;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { GhostCompletion } from './ghostText';
|
||||
|
||||
export interface ITextEditorOptions {
|
||||
tabSize?: number | string;
|
||||
insertSpaces?: boolean | string;
|
||||
}
|
||||
|
||||
export function normalizeIndentCharacter(
|
||||
options: ITextEditorOptions,
|
||||
completion: GhostCompletion,
|
||||
isEmptyLine: boolean
|
||||
): GhostCompletion {
|
||||
function replace(text: string, toReplace: string, replacer: (numberOfRemovedChars: number) => string): string {
|
||||
const regex = new RegExp(`^(${toReplace})+`, 'g');
|
||||
|
||||
return text
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
const trimmed = line.replace(regex, '');
|
||||
const removedCharacters = line.length - trimmed.length;
|
||||
return replacer(removedCharacters) + trimmed;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
//Get the "size" of indentation
|
||||
let indentSize: number;
|
||||
if (options.tabSize === undefined || typeof options.tabSize === 'string') {
|
||||
//Undefined or string case never happens when getting the indent size. This case is just for making TS typechecker happy.
|
||||
indentSize = 4;
|
||||
} else {
|
||||
indentSize = options.tabSize;
|
||||
}
|
||||
|
||||
//If editor indentation is set to tabs
|
||||
if (options.insertSpaces === false) {
|
||||
const r = (txt: string) =>
|
||||
replace(txt, ' ', n => '\t'.repeat(Math.floor(n / indentSize)) + ' '.repeat(n % indentSize));
|
||||
completion.displayText = r(completion.displayText);
|
||||
completion.completionText = r(completion.completionText);
|
||||
}
|
||||
//If editor indentation is set to spaces
|
||||
else if (options.insertSpaces === true) {
|
||||
const r = (txt: string) => replace(txt, '\t', n => ' '.repeat(n * indentSize));
|
||||
completion.displayText = r(completion.displayText);
|
||||
completion.completionText = r(completion.completionText);
|
||||
if (isEmptyLine) {
|
||||
const re = (txt: string) => {
|
||||
if (txt === '') {
|
||||
return txt;
|
||||
}
|
||||
const firstLine = txt.split('\n')[0];
|
||||
const spacesAtStart = firstLine.length - firstLine.trimStart().length;
|
||||
const remainder = spacesAtStart % indentSize;
|
||||
if (remainder !== 0 && spacesAtStart > 0) {
|
||||
const toReplace = ' '.repeat(remainder);
|
||||
return replace(txt, toReplace, n => ' '.repeat((Math.floor(n / indentSize) + 1) * indentSize));
|
||||
} else { return txt; }
|
||||
};
|
||||
|
||||
completion.displayText = re(completion.displayText);
|
||||
completion.completionText = re(completion.completionText);
|
||||
}
|
||||
}
|
||||
|
||||
return completion;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { LRUCacheMap } from '../helpers/cache';
|
||||
|
||||
type RequestFunction = () => Promise<unknown>;
|
||||
|
||||
export const ICompletionsSpeculativeRequestCache = createServiceIdentifier<ICompletionsSpeculativeRequestCache>('ICompletionsSpeculativeRequestCache');
|
||||
export interface ICompletionsSpeculativeRequestCache {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
set(completionId: string, requestFunction: RequestFunction): void;
|
||||
request(completionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class SpeculativeRequestCache implements ICompletionsSpeculativeRequestCache {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
private cache = new LRUCacheMap<string, RequestFunction>(100);
|
||||
|
||||
set(completionId: string, requestFunction: RequestFunction): void {
|
||||
this.cache.set(completionId, requestFunction);
|
||||
}
|
||||
|
||||
async request(completionId: string): Promise<void> {
|
||||
const fn = this.cache.get(completionId);
|
||||
if (fn === undefined) { return; }
|
||||
this.cache.delete(completionId);
|
||||
await fn();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import Parser, { SyntaxNode } from 'web-tree-sitter';
|
||||
import { parseTreeSitter } from '../../../prompt/src/parse';
|
||||
|
||||
export abstract class StatementNode {
|
||||
readonly children: StatementNode[] = [];
|
||||
parent: StatementNode | undefined;
|
||||
nextSibling: StatementNode | undefined;
|
||||
protected collapsed = false;
|
||||
|
||||
constructor(readonly node: SyntaxNode) { }
|
||||
|
||||
addChild(child: StatementNode) {
|
||||
child.parent = this;
|
||||
child.nextSibling = undefined;
|
||||
if (this.children.length > 0) {
|
||||
this.children[this.children.length - 1].nextSibling = child;
|
||||
}
|
||||
this.children.push(child);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the last child is added to this node when the tree is being
|
||||
* constructed. This is a callback derived classes can use to do any additional
|
||||
* processing once this branch of the tree is complete. The default behavior
|
||||
* is to do nothing.
|
||||
*/
|
||||
childrenFinished() { }
|
||||
|
||||
containsStatement(stmt: StatementNode): boolean {
|
||||
return this.node.startIndex <= stmt.node.startIndex && this.node.endIndex >= stmt.node.endIndex;
|
||||
}
|
||||
|
||||
statementAt(offset: number): StatementNode | undefined {
|
||||
if (this.node.startIndex > offset || this.node.endIndex < offset) { return undefined; }
|
||||
|
||||
let innerMatch: StatementNode | undefined = undefined;
|
||||
this.children.find(stmt => {
|
||||
innerMatch = stmt.statementAt(offset);
|
||||
return innerMatch !== undefined;
|
||||
});
|
||||
return innerMatch ?? this;
|
||||
}
|
||||
|
||||
abstract get isCompoundStatementType(): boolean;
|
||||
|
||||
/** Treat this node and its children as a single statement */
|
||||
protected collapse() {
|
||||
this.children.length = 0;
|
||||
this.collapsed = true;
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `${this.node.type} ([${this.node.startPosition.row},${this.node.startPosition.column}]..[${this.node.endPosition.row},${this.node.endPosition.column}]): ${JSON.stringify(this.node.text.length > 33 ? this.node.text.substring(0, 15) + '...' + this.node.text.slice(-15) : this.node.text)}`;
|
||||
}
|
||||
|
||||
dump(prefix1: string = '', prefix2: string = ''): string {
|
||||
const result = [`${prefix1}${this.description}`];
|
||||
this.children.forEach(child => {
|
||||
result.push(
|
||||
child.dump(`${prefix2}+- `, child.nextSibling === undefined ? `${prefix2} ` : `${prefix2}| `)
|
||||
);
|
||||
});
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
dumpPath(prefix1: string = '', prefix2: string = '', forChild = false): string {
|
||||
if (this.parent) {
|
||||
const path = this.parent.dumpPath(prefix1, prefix2, true);
|
||||
const indentSize = path.length - path.lastIndexOf('\n') - 1 - prefix2.length;
|
||||
const indent = ' '.repeat(indentSize);
|
||||
const nextPrefix = forChild ? `\n${prefix2}${indent}+- ` : '';
|
||||
return path + this.description + nextPrefix;
|
||||
} else {
|
||||
const nextPrefix = forChild ? `\n${prefix2}+- ` : '';
|
||||
return prefix1 + this.description + nextPrefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A simplified view of a syntax tree.
|
||||
*
|
||||
* It contains only nodes which represent complete statements. Because statements may
|
||||
* be compound, a single statement may contain other statements within it.
|
||||
*
|
||||
* "Statement" refers to a syntactic unit of the language. It represents the smallest
|
||||
* division of a code completion we would consider when truncating. It may be a simple
|
||||
* statement such as:
|
||||
*
|
||||
* `x = 1;`
|
||||
*
|
||||
* or a compound statement such as:
|
||||
*
|
||||
* `if (x > 0) { x = 2; }`
|
||||
*
|
||||
* where the entire string comprises the parent statement, and `x = 2;` is
|
||||
* a child statement of the parent. Note that `x > 0` is not a statement, but
|
||||
* an expression.
|
||||
*
|
||||
* The view is further constrained to a portion of the overall document (the start and
|
||||
* end offsets). This view contains all statements which intersect that region, so
|
||||
* containing statements are included in the view, even though they may extend
|
||||
* beyond the region.
|
||||
*/
|
||||
export abstract class StatementTree implements Disposable {
|
||||
protected tree: Parser.Tree | undefined;
|
||||
readonly statements: StatementNode[] = [];
|
||||
|
||||
static isSupported(languageId: string): boolean {
|
||||
return (
|
||||
JSStatementTree.languageIds.has(languageId) ||
|
||||
TSStatementTree.languageIds.has(languageId) ||
|
||||
PyStatementTree.languageIds.has(languageId) ||
|
||||
GoStatementTree.languageIds.has(languageId) ||
|
||||
PhpStatementTree.languageIds.has(languageId) ||
|
||||
RubyStatementTree.languageIds.has(languageId) ||
|
||||
JavaStatementTree.languageIds.has(languageId) ||
|
||||
CSharpStatementTree.languageIds.has(languageId) ||
|
||||
CStatementTree.languageIds.has(languageId)
|
||||
);
|
||||
}
|
||||
|
||||
static isTrimmedByDefault(languageId: string): boolean {
|
||||
return (
|
||||
JSStatementTree.languageIds.has(languageId) ||
|
||||
TSStatementTree.languageIds.has(languageId) ||
|
||||
GoStatementTree.languageIds.has(languageId)
|
||||
);
|
||||
}
|
||||
|
||||
static create(languageId: string, text: string, startOffset: number, endOffset: number): StatementTree {
|
||||
if (JSStatementTree.languageIds.has(languageId)) {
|
||||
return new JSStatementTree(languageId, text, startOffset, endOffset);
|
||||
} else if (TSStatementTree.languageIds.has(languageId)) {
|
||||
return new TSStatementTree(languageId, text, startOffset, endOffset);
|
||||
} else if (PyStatementTree.languageIds.has(languageId)) {
|
||||
return new PyStatementTree(languageId, text, startOffset, endOffset);
|
||||
} else if (GoStatementTree.languageIds.has(languageId)) {
|
||||
return new GoStatementTree(languageId, text, startOffset, endOffset);
|
||||
} else if (JavaStatementTree.languageIds.has(languageId)) {
|
||||
return new JavaStatementTree(languageId, text, startOffset, endOffset);
|
||||
} else if (PhpStatementTree.languageIds.has(languageId)) {
|
||||
return new PhpStatementTree(languageId, text, startOffset, endOffset);
|
||||
} else if (RubyStatementTree.languageIds.has(languageId)) {
|
||||
return new RubyStatementTree(languageId, text, startOffset, endOffset);
|
||||
} else if (CSharpStatementTree.languageIds.has(languageId)) {
|
||||
return new CSharpStatementTree(languageId, text, startOffset, endOffset);
|
||||
} else if (CStatementTree.languageIds.has(languageId)) {
|
||||
return new CStatementTree(languageId, text, startOffset, endOffset);
|
||||
} else {
|
||||
throw new Error(`Unsupported languageId: ${languageId}`);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly languageId: string,
|
||||
private readonly text: string,
|
||||
private readonly startOffset: number,
|
||||
private readonly endOffset: number
|
||||
) { }
|
||||
|
||||
[Symbol.dispose]() {
|
||||
if (this.tree) {
|
||||
this.tree.delete();
|
||||
this.tree = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.statements.length = 0;
|
||||
}
|
||||
|
||||
statementAt(offset: number): StatementNode | undefined {
|
||||
let match: StatementNode | undefined = undefined;
|
||||
this.statements.find(stmt => {
|
||||
match = stmt.statementAt(offset);
|
||||
return match !== undefined;
|
||||
});
|
||||
return match;
|
||||
}
|
||||
|
||||
async build(): Promise<void> {
|
||||
const parents: StatementNode[] = [];
|
||||
this.clear();
|
||||
const tree = await this.parse();
|
||||
const query = this.getStatementQuery(tree);
|
||||
query
|
||||
.captures(tree.rootNode, {
|
||||
startPosition: this.offsetToPosition(this.startOffset),
|
||||
endPosition: this.offsetToPosition(this.endOffset),
|
||||
})
|
||||
.forEach(capture => {
|
||||
const stmt = this.createNode(capture.node);
|
||||
while (parents.length > 0 && !parents[0].containsStatement(stmt)) {
|
||||
const completed = parents.shift(); // not a parent
|
||||
completed?.childrenFinished();
|
||||
}
|
||||
if (parents.length > 0) {
|
||||
parents[0].addChild(stmt); // this is our parent
|
||||
} else {
|
||||
this.addStatement(stmt); // top-level statement
|
||||
}
|
||||
parents.unshift(stmt); // add to the stack
|
||||
});
|
||||
// finish up
|
||||
parents.forEach(stmt => stmt.childrenFinished());
|
||||
}
|
||||
|
||||
protected abstract createNode(node: SyntaxNode): StatementNode;
|
||||
protected abstract getStatementQueryText(): string;
|
||||
|
||||
protected addStatement(stmt: StatementNode) {
|
||||
stmt.parent = undefined;
|
||||
stmt.nextSibling = undefined;
|
||||
if (this.statements.length > 0) {
|
||||
this.statements[this.statements.length - 1].nextSibling = stmt;
|
||||
}
|
||||
this.statements.push(stmt);
|
||||
}
|
||||
|
||||
protected async parse(): Promise<Parser.Tree> {
|
||||
if (!this.tree) {
|
||||
this.tree = await parseTreeSitter(this.languageId, this.text);
|
||||
}
|
||||
return this.tree;
|
||||
}
|
||||
|
||||
protected getStatementQuery(tree: Parser.Tree): Parser.Query {
|
||||
return this.getQuery(tree.getLanguage(), this.getStatementQueryText());
|
||||
}
|
||||
|
||||
protected getQuery(language: Parser.Language, queryText: string): Parser.Query {
|
||||
// TODO: query objects can be cached and reused
|
||||
return language.query(queryText);
|
||||
}
|
||||
|
||||
protected offsetToPosition(offset: number): Parser.Point {
|
||||
const lines = this.text.slice(0, offset).split('\n');
|
||||
const row = lines.length - 1;
|
||||
const column = lines[lines.length - 1].length;
|
||||
return { row, column };
|
||||
}
|
||||
|
||||
dump(prefix: string = ''): string {
|
||||
const result: string[] = [];
|
||||
this.statements.forEach((stmt, idx) => {
|
||||
const idxStr = `[${idx}]`;
|
||||
const idxSpaces = ' '.repeat(idxStr.length);
|
||||
result.push(stmt.dump(`${prefix} ${idxStr} `, `${prefix} ${idxSpaces} `));
|
||||
});
|
||||
return result.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Javascript and Typescript implementation
|
||||
*/
|
||||
|
||||
class JSStatementNode extends StatementNode {
|
||||
static compoundTypeNames = new Set([
|
||||
'function_declaration',
|
||||
'generator_function_declaration',
|
||||
'class_declaration',
|
||||
'statement_block',
|
||||
'if_statement',
|
||||
'switch_statement',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'while_statement',
|
||||
'do_statement',
|
||||
'try_statement',
|
||||
'with_statement',
|
||||
'labeled_statement',
|
||||
'method_definition',
|
||||
'interface_declaration',
|
||||
]);
|
||||
|
||||
get isCompoundStatementType(): boolean {
|
||||
return !this.collapsed && JSStatementNode.compoundTypeNames.has(this.node.type);
|
||||
}
|
||||
|
||||
override childrenFinished() {
|
||||
if (this.isSingleLineIfStatement()) { this.collapse(); }
|
||||
}
|
||||
|
||||
private isSingleLineIfStatement(): boolean {
|
||||
// must be an if statement
|
||||
if (this.node.type !== 'if_statement') { return false; }
|
||||
// must be a single line
|
||||
if (this.node.startPosition.row !== this.node.endPosition.row) { return false; }
|
||||
// Exclude if statements with braces so that block position is correct:
|
||||
// can have a single statement without braces
|
||||
if (this.children.length === 1 && this.children[0].node.type !== 'statement_block') { return true; }
|
||||
// or two statements without braces if an else is present
|
||||
if (
|
||||
this.children.length === 2 &&
|
||||
this.node.childForFieldName('alternative') !== null &&
|
||||
this.children[0].node.type !== 'statement_block' &&
|
||||
this.children[1].node.type !== 'statement_block'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class JSStatementTree extends StatementTree {
|
||||
static readonly languageIds = new Set(['javascript', 'javascriptreact', 'jsx']);
|
||||
|
||||
protected createNode(node: SyntaxNode): StatementNode {
|
||||
return new JSStatementNode(node);
|
||||
}
|
||||
|
||||
protected getStatementQueryText(): string {
|
||||
// From https://github.com/tree-sitter/tree-sitter-javascript/blob/fdeb68ac8d2bd5a78b943528bb68ceda3aade2eb/grammar.js#L199-L226
|
||||
// Because `statement` is declared `inline` in this version of the
|
||||
// grammar, we search for each choice from its definition plus two
|
||||
// class constructs we want to consider for trimming.
|
||||
return `[
|
||||
(export_statement)
|
||||
(import_statement)
|
||||
(debugger_statement)
|
||||
(expression_statement)
|
||||
(declaration)
|
||||
(statement_block)
|
||||
(if_statement)
|
||||
(switch_statement)
|
||||
(for_statement)
|
||||
(for_in_statement)
|
||||
(while_statement)
|
||||
(do_statement)
|
||||
(try_statement)
|
||||
(with_statement)
|
||||
(break_statement)
|
||||
(continue_statement)
|
||||
(return_statement)
|
||||
(throw_statement)
|
||||
(empty_statement)
|
||||
(labeled_statement)
|
||||
(method_definition)
|
||||
(field_definition)
|
||||
] @statement`;
|
||||
}
|
||||
}
|
||||
|
||||
class TSStatementTree extends StatementTree {
|
||||
static readonly languageIds = new Set(['typescript', 'typescriptreact']);
|
||||
|
||||
protected createNode(node: SyntaxNode): StatementNode {
|
||||
return new JSStatementNode(node);
|
||||
}
|
||||
|
||||
protected getStatementQueryText(): string {
|
||||
// From https://github.com/tree-sitter/tree-sitter-javascript/blob/fdeb68ac8d2bd5a78b943528bb68ceda3aade2eb/grammar.js#L199-L226
|
||||
// Because `statement` is declared `inline` in this version of the
|
||||
// grammar, we search for each choice from its definition plus two
|
||||
// class constructs we want to consider for trimming.
|
||||
return `[
|
||||
(export_statement)
|
||||
(import_statement)
|
||||
(debugger_statement)
|
||||
(expression_statement)
|
||||
(declaration)
|
||||
(statement_block)
|
||||
(if_statement)
|
||||
(switch_statement)
|
||||
(for_statement)
|
||||
(for_in_statement)
|
||||
(while_statement)
|
||||
(do_statement)
|
||||
(try_statement)
|
||||
(with_statement)
|
||||
(break_statement)
|
||||
(continue_statement)
|
||||
(return_statement)
|
||||
(throw_statement)
|
||||
(empty_statement)
|
||||
(labeled_statement)
|
||||
(method_definition)
|
||||
(public_field_definition)
|
||||
] @statement`;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Python implementation
|
||||
*/
|
||||
class PyStatementNode extends StatementNode {
|
||||
static compoundTypeNames = new Set([
|
||||
'if_statement',
|
||||
'for_statement',
|
||||
'while_statement',
|
||||
'try_statement',
|
||||
'with_statement',
|
||||
'function_definition',
|
||||
'class_definition',
|
||||
'decorated_definition',
|
||||
'match_statement',
|
||||
'block',
|
||||
]);
|
||||
|
||||
get isCompoundStatementType(): boolean {
|
||||
return !this.collapsed && PyStatementNode.compoundTypeNames.has(this.node.type);
|
||||
}
|
||||
|
||||
override childrenFinished() {
|
||||
if (this.isSingleLineIfStatement()) { this.collapse(); }
|
||||
}
|
||||
|
||||
private isSingleLineIfStatement(): boolean {
|
||||
// must be an if statement
|
||||
if (this.node.type !== 'if_statement') { return false; }
|
||||
// must be a single line
|
||||
return this.node.startPosition.row === this.node.endPosition.row;
|
||||
}
|
||||
}
|
||||
|
||||
class PyStatementTree extends StatementTree {
|
||||
static readonly languageIds = new Set(['python']);
|
||||
|
||||
protected createNode(node: SyntaxNode): StatementNode {
|
||||
return new PyStatementNode(node);
|
||||
}
|
||||
|
||||
protected getStatementQueryText(): string {
|
||||
// Search for nodes of type `_simple_statement` and `_compound_statement`.
|
||||
// Because these are both inlined, we search for each choice in the two
|
||||
// definitions. It also adds `block` to more closely match the tree
|
||||
// shape of JS/TS.
|
||||
//
|
||||
// For the _simple_statement definition see: https://github.com/tree-sitter/tree-sitter-python/blob/7473026494597de8bc403735b1bfec7ca846c0d6/grammar.js#L90-L106
|
||||
// For the _compound_statement definition see: https://github.com/tree-sitter/tree-sitter-python/blob/7473026494597de8bc403735b1bfec7ca846c0d6/grammar.js#L230-L240
|
||||
return `[
|
||||
(future_import_statement)
|
||||
(import_statement)
|
||||
(import_from_statement)
|
||||
(print_statement)
|
||||
(assert_statement)
|
||||
(expression_statement)
|
||||
(return_statement)
|
||||
(delete_statement)
|
||||
(raise_statement)
|
||||
(pass_statement)
|
||||
(break_statement)
|
||||
(continue_statement)
|
||||
(global_statement)
|
||||
(nonlocal_statement)
|
||||
(exec_statement)
|
||||
(if_statement)
|
||||
(for_statement)
|
||||
(while_statement)
|
||||
(try_statement)
|
||||
(with_statement)
|
||||
(function_definition)
|
||||
(class_definition)
|
||||
(decorated_definition)
|
||||
(match_statement)
|
||||
(block)
|
||||
] @statement`;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Go implementation
|
||||
*/
|
||||
class GoStatementNode extends StatementNode {
|
||||
static compoundTypeNames = new Set([
|
||||
'function_declaration',
|
||||
'method_declaration',
|
||||
'if_statement',
|
||||
'for_statement',
|
||||
'expression_switch_statement',
|
||||
'type_switch_statement',
|
||||
'select_statement',
|
||||
'block',
|
||||
]);
|
||||
|
||||
get isCompoundStatementType(): boolean {
|
||||
return !this.collapsed && GoStatementNode.compoundTypeNames.has(this.node.type);
|
||||
}
|
||||
}
|
||||
|
||||
class GoStatementTree extends StatementTree {
|
||||
static readonly languageIds = new Set(['go']);
|
||||
|
||||
protected createNode(node: SyntaxNode): StatementNode {
|
||||
return new GoStatementNode(node);
|
||||
}
|
||||
|
||||
protected getStatementQueryText(): string {
|
||||
// Search for nodes of type `_top_level_declaration` and `_statement`.
|
||||
// Because `_top_level_declaration` is inlined, we search for each
|
||||
// choice in its definition. It also adds `block` to match the tree
|
||||
// shape of JS/TS.
|
||||
//
|
||||
// For the _top_level_declaration definition see: https://github.com/tree-sitter/tree-sitter-go/blob/3c3775faa968158a8b4ac190a7fda867fd5fb748/grammar.js#L117-L122
|
||||
return `[
|
||||
(package_clause)
|
||||
(function_declaration)
|
||||
(method_declaration)
|
||||
(import_declaration)
|
||||
(_statement)
|
||||
(block)
|
||||
] @statement`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Php implementation
|
||||
*/
|
||||
class PhpStatementNode extends StatementNode {
|
||||
static compoundTypeNames = new Set([
|
||||
'if_statement',
|
||||
'else_clause',
|
||||
'else_if_clause',
|
||||
'for_statement',
|
||||
'foreach_statement',
|
||||
'while_statement',
|
||||
'do_statement',
|
||||
'switch_statement',
|
||||
'try_statement',
|
||||
'catch_clause',
|
||||
'finally_clause',
|
||||
'anonymous_function',
|
||||
'compound_statement',
|
||||
]);
|
||||
|
||||
get isCompoundStatementType(): boolean {
|
||||
return !this.collapsed && PhpStatementNode.compoundTypeNames.has(this.node.type);
|
||||
}
|
||||
}
|
||||
|
||||
class PhpStatementTree extends StatementTree {
|
||||
static readonly languageIds = new Set(['php']);
|
||||
|
||||
protected override createNode(node: SyntaxNode): StatementNode {
|
||||
return new PhpStatementNode(node);
|
||||
}
|
||||
protected override getStatementQueryText(): string {
|
||||
// Search for nodes of type `_statement` and a few other picked types.
|
||||
// `compound_statement`, `method_declaration`, `property_declaration`, `const_declaration`, and `use_declaration` are
|
||||
// not encompassed by `_statement` so we add them to the query to make multi-line reveal more useful.
|
||||
// For the _statement definition see: https://github.com/tree-sitter/tree-sitter-php/blob/eb289f127fc341ae7129902a2dd1c6c197a4c1e7/common/define-grammar.js#L141
|
||||
return `[
|
||||
(statement)
|
||||
(compound_statement)
|
||||
(method_declaration)
|
||||
(property_declaration)
|
||||
(const_declaration)
|
||||
(use_declaration)
|
||||
] @statement`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruby implementation
|
||||
*/
|
||||
|
||||
class RubyStatementNode extends StatementNode {
|
||||
static compoundTypeNames = new Set(['if', 'case', 'while', 'until', 'for', 'begin', 'module', 'class', 'method']);
|
||||
|
||||
get isCompoundStatementType(): boolean {
|
||||
return !this.collapsed && RubyStatementNode.compoundTypeNames.has(this.node.type);
|
||||
}
|
||||
}
|
||||
|
||||
class RubyStatementTree extends StatementTree {
|
||||
static readonly languageIds = new Set(['ruby']);
|
||||
|
||||
protected createNode(node: SyntaxNode): StatementNode {
|
||||
return new RubyStatementNode(node);
|
||||
}
|
||||
//(if_modifier)
|
||||
protected getStatementQueryText(): string {
|
||||
return `[
|
||||
(_statement)
|
||||
(when)
|
||||
] @statement`;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Java implementation
|
||||
*/
|
||||
|
||||
class JavaStatementNode extends StatementNode {
|
||||
static compoundTypeNames = new Set([
|
||||
'block',
|
||||
'do_statement',
|
||||
'enhanced_for_statement',
|
||||
'for_statement',
|
||||
'if_statement',
|
||||
'labeled_statement',
|
||||
'switch_expression',
|
||||
'synchronized_statement',
|
||||
'try_statement',
|
||||
'try_with_resources_statement',
|
||||
'while_statement',
|
||||
'interface_declaration',
|
||||
'method_declaration',
|
||||
'constructor_declaration',
|
||||
'compact_constructor_declaration',
|
||||
'class_declaration',
|
||||
'annotation_type_declaration',
|
||||
'static_initializer',
|
||||
]);
|
||||
|
||||
get isCompoundStatementType(): boolean {
|
||||
return !this.collapsed && JavaStatementNode.compoundTypeNames.has(this.node.type);
|
||||
}
|
||||
|
||||
override childrenFinished() {
|
||||
// Collapse if_statements on a single line
|
||||
if (this.isSingleLineIfStatement()) { this.collapse(); }
|
||||
}
|
||||
|
||||
private isSingleLineIfStatement(): boolean {
|
||||
// must be an if statement
|
||||
if (this.node.type !== 'if_statement') { return false; }
|
||||
// must be a single line
|
||||
if (this.node.startPosition.row !== this.node.endPosition.row) { return false; }
|
||||
// Exclude if statements with braces so that block position is correct:
|
||||
// can have a single statement without braces
|
||||
if (this.children.length === 1 && this.children[0].node.type !== 'block') { return true; }
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class JavaStatementTree extends StatementTree {
|
||||
// Grammar via: https://github.com/tree-sitter/tree-sitter-java/blob/master/src/grammar.json
|
||||
// Node types via: https://github.com/tree-sitter/tree-sitter-java/blob/master/src/node-types.json
|
||||
static readonly languageIds = new Set(['java']);
|
||||
|
||||
protected createNode(node: SyntaxNode): StatementNode {
|
||||
return new JavaStatementNode(node);
|
||||
}
|
||||
|
||||
// _class_body_declaration is inlined, so add those subtypes to the query
|
||||
protected getStatementQueryText(): string {
|
||||
return `[
|
||||
(statement)
|
||||
(field_declaration)
|
||||
(record_declaration)
|
||||
(method_declaration)
|
||||
(compact_constructor_declaration)
|
||||
(class_declaration)
|
||||
(interface_declaration)
|
||||
(annotation_type_declaration)
|
||||
(enum_declaration)
|
||||
(block)
|
||||
(static_initializer)
|
||||
(constructor_declaration)
|
||||
] @statement`;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* C# implementation
|
||||
*/
|
||||
class CSharpStatementNode extends StatementNode {
|
||||
static compoundTypeNames = new Set([
|
||||
'block',
|
||||
'checked_statement',
|
||||
'class_declaration',
|
||||
'constructor_declaration',
|
||||
'destructor_declaration',
|
||||
'do_statement',
|
||||
'fixed_statement',
|
||||
'for_statement',
|
||||
'foreach_statement',
|
||||
'if_statement',
|
||||
'interface_declaration',
|
||||
'lock_statement',
|
||||
'method_declaration',
|
||||
'struct_declaration',
|
||||
'switch_statement',
|
||||
'try_statement',
|
||||
'unsafe_statement',
|
||||
'while_statement',
|
||||
]);
|
||||
|
||||
get isCompoundStatementType(): boolean {
|
||||
return !this.collapsed && CSharpStatementNode.compoundTypeNames.has(this.node.type);
|
||||
}
|
||||
|
||||
override childrenFinished() {
|
||||
if (this.isSingleLineIfStatement()) { this.collapse(); }
|
||||
}
|
||||
|
||||
private isSingleLineIfStatement(): boolean {
|
||||
// must be an if statement
|
||||
if (this.node.type !== 'if_statement') { return false; }
|
||||
// must be a single line
|
||||
if (this.node.startPosition.row !== this.node.endPosition.row) { return false; }
|
||||
// Exclude if statements with braces so that block position is correct:
|
||||
// can have a single statement without braces
|
||||
if (this.children.length === 1 && this.children[0].node.type !== 'block') { return true; }
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class CSharpStatementTree extends StatementTree {
|
||||
static readonly languageIds = new Set(['csharp']);
|
||||
|
||||
protected createNode(node: SyntaxNode): StatementNode {
|
||||
return new CSharpStatementNode(node);
|
||||
}
|
||||
|
||||
protected getStatementQueryText(): string {
|
||||
return `[
|
||||
(extern_alias_directive)
|
||||
(using_directive)
|
||||
(global_attribute)
|
||||
(preproc_if)
|
||||
(namespace_declaration)
|
||||
(file_scoped_namespace_declaration)
|
||||
(statement)
|
||||
(type_declaration)
|
||||
(declaration)
|
||||
(accessor_declaration)
|
||||
(block)
|
||||
] @statement`;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* C, C++ implementation
|
||||
*/
|
||||
|
||||
class CStatementNode extends StatementNode {
|
||||
static compoundTypeNames = new Set([
|
||||
'declaration',
|
||||
'function_definition',
|
||||
'enum_specifier',
|
||||
'field_declaration_list',
|
||||
'type_definition',
|
||||
'compound_statement',
|
||||
'if_statement',
|
||||
'switch_statement',
|
||||
'while_statement',
|
||||
'for_statement',
|
||||
'do_statement',
|
||||
'preproc_if',
|
||||
'preproc_ifdef',
|
||||
|
||||
// C++ specific:
|
||||
'namespace_definition',
|
||||
'class_specifier',
|
||||
'field_declaration_list',
|
||||
'concept_definition',
|
||||
'template_declaration',
|
||||
]);
|
||||
|
||||
get isCompoundStatementType(): boolean {
|
||||
return !this.collapsed && CStatementNode.compoundTypeNames.has(this.node.type);
|
||||
}
|
||||
|
||||
override childrenFinished() {
|
||||
if (this.isSingleLineDeclarationStatement() || this.isSingleLineConceptDefinition()) { this.collapse(); }
|
||||
}
|
||||
|
||||
private isSingleLineDeclarationStatement(): boolean {
|
||||
// must be an declaration statement
|
||||
if (this.node.type !== 'declaration') { return false; }
|
||||
// must be a single line
|
||||
if (this.node.startPosition.row !== this.node.endPosition.row) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
private isSingleLineConceptDefinition(): boolean {
|
||||
// must be a concept definition
|
||||
if (this.node.type !== 'concept_definition') { return false; }
|
||||
// must be a single line
|
||||
if (this.node.startPosition.row !== this.node.endPosition.row) { return false; }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class CStatementTree extends StatementTree {
|
||||
static readonly languageIds = new Set(['c', 'cpp']);
|
||||
|
||||
protected createNode(node: SyntaxNode): StatementNode {
|
||||
return new CStatementNode(node);
|
||||
}
|
||||
|
||||
protected getStatementQueryText(): string {
|
||||
return `[
|
||||
(declaration)
|
||||
(function_definition)
|
||||
(type_definition)
|
||||
(field_declaration)
|
||||
(enum_specifier)
|
||||
(return_statement)
|
||||
(compound_statement)
|
||||
(if_statement)
|
||||
(expression_statement)
|
||||
(switch_statement)
|
||||
(break_statement)
|
||||
(case_statement)
|
||||
(while_statement)
|
||||
(for_statement)
|
||||
(do_statement)
|
||||
(goto_statement)
|
||||
(labeled_statement)
|
||||
(preproc_if)
|
||||
(preproc_def)
|
||||
(preproc_ifdef)
|
||||
(preproc_include)
|
||||
(preproc_call)
|
||||
(preproc_function_def)
|
||||
(continue_statement)
|
||||
|
||||
;C++ specific:
|
||||
(namespace_definition)
|
||||
(class_specifier)
|
||||
(field_declaration_list)
|
||||
(field_declaration)
|
||||
(concept_definition)
|
||||
(compound_requirement)
|
||||
(template_declaration)
|
||||
(using_declaration)
|
||||
(alias_declaration)
|
||||
(static_assert_declaration)
|
||||
] @statement`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { CopilotNamedAnnotationList } from '../../../../../../platform/completions-core/common/openai/copilotAnnotations';
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { FinishedCallback, RequestDelta, SolutionDecision } from '../openai/fetch';
|
||||
import { APIChoice, convertToAPIChoice } from '../openai/openai';
|
||||
import { TerseBlockTrimmer } from './blockTrimmer';
|
||||
|
||||
class StreamingCompletion {
|
||||
startOffset = 0;
|
||||
text = '';
|
||||
trimCount = 0;
|
||||
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly documentPrefix: string
|
||||
) { }
|
||||
|
||||
updateText(text: string): void {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
get addedToPrefix(): string {
|
||||
return this.text.substring(0, this.startOffset);
|
||||
}
|
||||
|
||||
get effectivePrefix(): string {
|
||||
return this.documentPrefix + this.addedToPrefix;
|
||||
}
|
||||
|
||||
get effectiveText(): string {
|
||||
return this.text.substring(this.startOffset);
|
||||
}
|
||||
|
||||
get isFirstCompletion(): boolean {
|
||||
return this.trimCount === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the line ending to use when trimming the completion
|
||||
* as a "single line" completion. This allows the completion to begin with
|
||||
* a single leading new line as a special case for completing the next line.
|
||||
* It supports CRLF and LF line endings. The index is the start of the line
|
||||
* terminator. Returns -1 if a suitable line ending was not found.
|
||||
*/
|
||||
get firstNewlineOffset(): number {
|
||||
const matches = [...this.text.matchAll(/\r?\n/g)];
|
||||
if (matches.length > 0 && matches[0].index === 0) {
|
||||
matches.shift();
|
||||
}
|
||||
return matches.length > 0 ? matches[0].index : -1;
|
||||
}
|
||||
|
||||
trimAt(effectiveOffset: number): StreamingCompletion {
|
||||
const trimmed = new StreamingCompletion(this.index, this.documentPrefix);
|
||||
trimmed.startOffset = this.startOffset;
|
||||
trimmed.text = this.text.substring(0, this.startOffset + effectiveOffset);
|
||||
trimmed.trimCount = this.trimCount;
|
||||
this.startOffset += effectiveOffset;
|
||||
this.trimCount++;
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamedCompletionSplitter {
|
||||
private readonly lineLimit = 3;
|
||||
private readonly completions = new Map<number, StreamingCompletion>();
|
||||
|
||||
constructor(
|
||||
private readonly prefix: string,
|
||||
private readonly languageId: string,
|
||||
private readonly initialSingleLine: boolean,
|
||||
private readonly trimmerLookahead: number,
|
||||
private readonly cacheFunction: (prefixAddition: string, item: APIChoice) => void,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
) { }
|
||||
|
||||
getFinishedCallback(): FinishedCallback {
|
||||
return async (completionText: string, delta: RequestDelta): Promise<SolutionDecision> => {
|
||||
const index = delta.index ?? 0;
|
||||
const completion = this.getCompletion(index, completionText);
|
||||
|
||||
// emmulate single line completion when this.initialSingleLine is set
|
||||
if (completion.isFirstCompletion && this.initialSingleLine && completion.firstNewlineOffset >= 0) {
|
||||
const result = {
|
||||
yieldSolution: true,
|
||||
continueStreaming: true,
|
||||
finishOffset: completion.firstNewlineOffset,
|
||||
};
|
||||
completion.trimAt(result.finishOffset);
|
||||
if (delta.finished) {
|
||||
await this.trimAll(delta, completion);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return delta.finished ? await this.trimAll(delta, completion) : await this.trimOnce(delta, completion);
|
||||
};
|
||||
}
|
||||
|
||||
private getCompletion(index: number, newText: string): StreamingCompletion {
|
||||
let completion = this.completions.get(index);
|
||||
if (!completion) {
|
||||
completion = new StreamingCompletion(index, this.prefix);
|
||||
this.completions.set(index, completion);
|
||||
}
|
||||
completion.updateText(newText);
|
||||
return completion;
|
||||
}
|
||||
|
||||
private async trimOnce(delta: RequestDelta, completion: StreamingCompletion): Promise<SolutionDecision> {
|
||||
const offset = await this.trim(completion);
|
||||
if (offset === undefined) {
|
||||
return {
|
||||
yieldSolution: false,
|
||||
continueStreaming: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (completion.isFirstCompletion) {
|
||||
completion.trimAt(offset);
|
||||
return {
|
||||
yieldSolution: true,
|
||||
continueStreaming: true,
|
||||
finishOffset: offset,
|
||||
};
|
||||
} else {
|
||||
this.cacheCompletion(delta, completion, offset);
|
||||
return {
|
||||
yieldSolution: false,
|
||||
continueStreaming: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async trimAll(delta: RequestDelta, completion: StreamingCompletion): Promise<SolutionDecision> {
|
||||
let offset: number | undefined;
|
||||
let firstOffset: number | undefined;
|
||||
|
||||
do {
|
||||
offset = await this.trim(completion);
|
||||
|
||||
if (completion.isFirstCompletion) {
|
||||
firstOffset = offset;
|
||||
completion.trimAt(offset ?? completion.effectiveText.length);
|
||||
} else {
|
||||
this.cacheCompletion(delta, completion, offset);
|
||||
}
|
||||
} while (offset !== undefined);
|
||||
|
||||
if (firstOffset !== undefined) {
|
||||
return {
|
||||
yieldSolution: true,
|
||||
continueStreaming: true,
|
||||
finishOffset: firstOffset,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
yieldSolution: false,
|
||||
continueStreaming: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async trim(completion: StreamingCompletion): Promise<number | undefined> {
|
||||
const trimmer = new TerseBlockTrimmer(
|
||||
this.languageId,
|
||||
completion.effectivePrefix,
|
||||
completion.effectiveText,
|
||||
this.lineLimit,
|
||||
this.trimmerLookahead
|
||||
);
|
||||
return await trimmer.getCompletionTrimOffset();
|
||||
}
|
||||
|
||||
private cacheCompletion(delta: RequestDelta, completion: StreamingCompletion, offset?: number) {
|
||||
const trimmed = completion.trimAt(offset ?? completion.effectiveText.length);
|
||||
if (trimmed.effectiveText.trim() === '') {
|
||||
return;
|
||||
}
|
||||
const apiChoice = this.instantiationService.invokeFunction(convertToAPIChoice,
|
||||
trimmed.effectiveText.trimEnd(),
|
||||
delta.getAPIJsonData!(), // FIXME@ulugbekna
|
||||
trimmed.index,
|
||||
delta.requestId!, // FIXME@ulugbekna
|
||||
offset !== undefined,
|
||||
delta.telemetryData!
|
||||
);
|
||||
apiChoice.copilotAnnotations = this.adjustedAnnotations(apiChoice, completion, trimmed);
|
||||
apiChoice.generatedChoiceIndex = trimmed.trimCount;
|
||||
|
||||
this.cacheFunction(trimmed.addedToPrefix, apiChoice);
|
||||
}
|
||||
|
||||
private adjustedAnnotations(
|
||||
choice: APIChoice,
|
||||
fullCompletion: StreamingCompletion,
|
||||
trimmedCompletion: StreamingCompletion
|
||||
): CopilotNamedAnnotationList | undefined {
|
||||
if (choice.copilotAnnotations === undefined) { return undefined; }
|
||||
|
||||
const newStartOffset = trimmedCompletion.addedToPrefix.length;
|
||||
const newEndOffset = newStartOffset + choice.completionText.length;
|
||||
// whether the current split choice is at the end of the original choice
|
||||
const atEnd = newEndOffset >= fullCompletion.text.length;
|
||||
|
||||
const adjusted: CopilotNamedAnnotationList = {};
|
||||
for (const [name, annotationGroup] of Object.entries(choice.copilotAnnotations)) {
|
||||
const adjustedAnnotations = annotationGroup
|
||||
.filter(a => {
|
||||
return (
|
||||
a.start_offset - newStartOffset < choice.completionText.length &&
|
||||
a.stop_offset - newStartOffset > 0
|
||||
);
|
||||
})
|
||||
.map(a => {
|
||||
const newA = { ...a };
|
||||
newA.start_offset -= newStartOffset;
|
||||
newA.stop_offset -= newStartOffset;
|
||||
if (!atEnd) { newA.stop_offset = Math.min(newA.stop_offset, choice.completionText.length); }
|
||||
return newA;
|
||||
});
|
||||
if (adjustedAnnotations.length > 0) {
|
||||
adjusted[name] = adjustedAnnotations;
|
||||
}
|
||||
}
|
||||
return Object.keys(adjusted).length > 0 ? adjusted : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsLogTargetService, Logger } from '../logger';
|
||||
import { PromptResponse } from '../prompt/prompt';
|
||||
import { now, telemetry, TelemetryData, telemetryRaw, TelemetryWithExp } from '../telemetry';
|
||||
import { CopilotCompletion } from './copilotCompletion';
|
||||
import { ResultType } from './ghostText';
|
||||
import { ICompletionsSpeculativeRequestCache } from './speculativeRequestCache';
|
||||
|
||||
export type PostInsertionCategory = 'ghostText' | 'solution';
|
||||
|
||||
export const GHOST_TEXT_CATEGORY: PostInsertionCategory = 'ghostText';
|
||||
|
||||
export const logger = new Logger('getCompletions');
|
||||
|
||||
/** Send `.shown` event */
|
||||
export function telemetryShown(accessor: ServicesAccessor, completion: CopilotCompletion) {
|
||||
const speculativeRequestCache = accessor.get(ICompletionsSpeculativeRequestCache);
|
||||
void speculativeRequestCache.request(completion.clientCompletionId);
|
||||
completion.telemetry.markAsDisplayed(); // TODO: Consider removing displayedTime as unused and generally incorrect.
|
||||
completion.telemetry.properties.reason = resultTypeToString(completion.resultType);
|
||||
telemetry(accessor, `ghostText.shown`, completion.telemetry);
|
||||
}
|
||||
|
||||
/** Send `.accepted` event */
|
||||
export function telemetryAccepted(
|
||||
accessor: ServicesAccessor,
|
||||
insertionCategory: PostInsertionCategory,
|
||||
telemetryData: TelemetryData
|
||||
) {
|
||||
const telemetryName = insertionCategory + '.accepted';
|
||||
|
||||
telemetry(accessor, telemetryName, telemetryData);
|
||||
}
|
||||
|
||||
/** Send `.rejected` event */
|
||||
export function telemetryRejected(
|
||||
accessor: ServicesAccessor,
|
||||
insertionCategory: PostInsertionCategory,
|
||||
telemetryData: TelemetryData
|
||||
) {
|
||||
const telemetryName = insertionCategory + '.rejected';
|
||||
|
||||
telemetry(accessor, telemetryName, telemetryData);
|
||||
}
|
||||
|
||||
/** Cut down telemetry type for "result" telemetry, to avoid too much data load on Azure Monitor.
|
||||
*
|
||||
*/
|
||||
type BasicResultTelemetry = {
|
||||
headerRequestId: string;
|
||||
copilot_trackingId: string;
|
||||
opportunityId?: string;
|
||||
sku?: string;
|
||||
organizations_list?: string;
|
||||
enterprise_list?: string;
|
||||
clientCompletionId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* For `ghostText.canceled` we include all fields for backwards compatibility, as this event had it initially,
|
||||
* Note that we now send the event from more places, but it still makes sense to be consistent.
|
||||
*/
|
||||
type CanceledResultTelemetry = {
|
||||
telemetryBlob: TelemetryData;
|
||||
cancelledNetworkRequest?: boolean; // omitted is equivalent to false
|
||||
};
|
||||
|
||||
/**
|
||||
* When we request ghost text, we also send a `ghostText.issued` telemetry event. To measure
|
||||
* Copilot's overall reliability, we want to make sure we consistently send a matching "result" event.
|
||||
*
|
||||
* This type allows us to keep track of what happened during the pipeline that produces ghost text results,
|
||||
* and use the TypeScript type system to reduce the chances of accidentally forgetting to send the result event.
|
||||
*
|
||||
* At the end of that pipeline, we will either have a final ghost text result and we can send a `ghostText.produced`
|
||||
* message, or something will have prevented us producing a result and we can send an alternative mesages.
|
||||
*/
|
||||
export type GhostTextResultWithTelemetry<T> =
|
||||
/**
|
||||
* A result was produced successfully. If this is the final ghost text result,
|
||||
* we should send the result message `ghostText.produced`.
|
||||
*/
|
||||
| {
|
||||
type: 'success';
|
||||
value: T;
|
||||
telemetryData: BasicResultTelemetry;
|
||||
// This is needed to populate the telemetryBlob in `ghostText.canceled` if this happens later.
|
||||
telemetryBlob: TelemetryWithExp;
|
||||
resultType: ResultType;
|
||||
performanceMetrics?: [string, number][];
|
||||
}
|
||||
/**
|
||||
* We decided not to request ghost text this time. No `ghostText.issued` message
|
||||
* was sent so there is no need send any result telemetry.
|
||||
*/
|
||||
| { type: 'abortedBeforeIssued'; reason: string; telemetryData: BasicResultTelemetry }
|
||||
/**
|
||||
* We requested ghost text, but we decided to cancel mid-way, for example because the
|
||||
* user kept typing. This will turn into a `ghostText.canceled` result message.
|
||||
* Note: this uses the preferred American spelling "canceled" rather than "cancelled",
|
||||
* because the telemetry message has always done that, even though it may be inconsistent
|
||||
* with log messages and code comments etc.
|
||||
*/
|
||||
| { type: 'canceled'; reason: string; telemetryData: CanceledResultTelemetry }
|
||||
/**
|
||||
* We requested ghost text, but didn't come up with any results for some "expected"
|
||||
* reason, such as slur redaction or snippy. This will turn into a `ghostText.empty`
|
||||
* result message.
|
||||
*/
|
||||
| { type: 'empty'; reason: string; telemetryData: BasicResultTelemetry }
|
||||
/**
|
||||
* We requested ghost text, but didn't come up with any results because something
|
||||
* unexpected went wrong. This will turn into a `ghostText.failed` result message.
|
||||
*/
|
||||
| { type: 'failed'; reason: string; telemetryData: BasicResultTelemetry }
|
||||
/**
|
||||
* The promptOnly parameter was set to true in the request. We only need the prompt
|
||||
* that was about to be sent to the model. This is for experimentation purposes, so
|
||||
* there is not any need for telemetry in this case.
|
||||
*/
|
||||
| { type: 'promptOnly'; reason: string; prompt: PromptResponse };
|
||||
|
||||
export function mkCanceledResultTelemetry(
|
||||
telemetryBlob: TelemetryData,
|
||||
extraFlags: { cancelledNetworkRequest?: boolean } = {}
|
||||
): CanceledResultTelemetry {
|
||||
return {
|
||||
...extraFlags,
|
||||
telemetryBlob,
|
||||
};
|
||||
}
|
||||
|
||||
export function mkBasicResultTelemetry(
|
||||
telemetryBlob: TelemetryWithExp,
|
||||
): BasicResultTelemetry {
|
||||
const result: BasicResultTelemetry = {
|
||||
headerRequestId: telemetryBlob.properties['headerRequestId'],
|
||||
copilot_trackingId: telemetryBlob.properties['copilot_trackingId'],
|
||||
};
|
||||
// copy certain properties if present
|
||||
if (telemetryBlob.properties['sku'] !== undefined) {
|
||||
result.sku = telemetryBlob.properties['sku'];
|
||||
}
|
||||
if (telemetryBlob.properties['opportunityId'] !== undefined) {
|
||||
result.opportunityId = telemetryBlob.properties['opportunityId'];
|
||||
}
|
||||
if (telemetryBlob.properties['organizations_list'] !== undefined) {
|
||||
result.organizations_list = telemetryBlob.properties['organizations_list'];
|
||||
}
|
||||
if (telemetryBlob.properties['enterprise_list'] !== undefined) {
|
||||
result.enterprise_list = telemetryBlob.properties['enterprise_list'];
|
||||
}
|
||||
if (telemetryBlob.properties['clientCompletionId'] !== undefined) {
|
||||
result.clientCompletionId = telemetryBlob.properties['clientCompletionId'];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a ghost text result, send the appropriate "result" telemetry, if any, and return the
|
||||
* result value if one was produced.
|
||||
* @param start Milliseconds (since process start) when the completion request was by the editor.
|
||||
*/
|
||||
export function handleGhostTextResultTelemetry<T>(
|
||||
accessor: ServicesAccessor,
|
||||
result: GhostTextResultWithTelemetry<T>
|
||||
): T | undefined {
|
||||
const logTarget = accessor.get(ICompletionsLogTargetService);
|
||||
// testing/debugging only case, no telemetry
|
||||
if (result.type === 'promptOnly') { return; }
|
||||
|
||||
if (result.type === 'success') {
|
||||
const timeToProduceMs = now() - result.telemetryBlob.issuedTime;
|
||||
const reason = resultTypeToString(result.resultType);
|
||||
const performanceMetrics = JSON.stringify(result.performanceMetrics);
|
||||
const properties = { ...result.telemetryData, reason, performanceMetrics };
|
||||
const { foundOffset } = result.telemetryBlob.measurements;
|
||||
const perf = result.performanceMetrics?.map(([key, dur]) => `\n${dur.toFixed(2)}\t${key}`).join('') ?? '';
|
||||
logger.debug(
|
||||
logTarget,
|
||||
`ghostText produced from ${reason} in ${Math.round(timeToProduceMs)}ms with foundOffset ${foundOffset}${perf}`
|
||||
);
|
||||
telemetryRaw(accessor, 'ghostText.produced', properties, { timeToProduceMs, foundOffset });
|
||||
return result.value;
|
||||
}
|
||||
|
||||
logger.debug(logTarget, 'No ghostText produced -- ' + result.type + ': ' + result.reason);
|
||||
if (result.type === 'canceled') {
|
||||
// For backwards compatibility, we send a "fat" telemetry message in this case.
|
||||
telemetry(
|
||||
accessor,
|
||||
`ghostText.canceled`,
|
||||
result.telemetryData.telemetryBlob.extendedBy({
|
||||
reason: result.reason,
|
||||
cancelledNetworkRequest: result.telemetryData.cancelledNetworkRequest ? 'true' : 'false',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
telemetryRaw(accessor, `ghostText.${result.type}`, { ...result.telemetryData, reason: result.reason }, {});
|
||||
}
|
||||
|
||||
export function resultTypeToString(resultType: ResultType): string {
|
||||
switch (resultType) {
|
||||
case ResultType.Network:
|
||||
return 'network';
|
||||
case ResultType.Cache:
|
||||
return 'cache';
|
||||
case ResultType.Cycling:
|
||||
return 'cycling';
|
||||
case ResultType.TypingAsSuggested:
|
||||
return 'typingAsSuggested';
|
||||
case ResultType.Async:
|
||||
return 'async';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as assert from 'node:assert';
|
||||
import sinon from 'sinon';
|
||||
import { generateUuid } from '../../../../../../../util/vs/base/common/uuid';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CancellationTokenSource } from '../../../../types/src';
|
||||
import { ICompletionsFeaturesService } from '../../experiments/featuresService';
|
||||
import { fakeAPIChoice } from '../../openai/fetch.fake';
|
||||
import { APIChoice } from '../../openai/openai';
|
||||
import { Prompt } from '../../prompt/prompt';
|
||||
import { TelemetryWithExp } from '../../telemetry';
|
||||
import { createLibTestingContext } from '../../test/context';
|
||||
import { delay } from '../../util/async';
|
||||
import { ResultType } from '../ghostText';
|
||||
import { AsyncCompletionManager } from './../asyncCompletions';
|
||||
import { GhostTextResultWithTelemetry, mkBasicResultTelemetry } from './../telemetry';
|
||||
|
||||
suite('AsyncCompletionManager', function () {
|
||||
let accessor: ServicesAccessor;
|
||||
let manager: AsyncCompletionManager;
|
||||
let clock: sinon.SinonFakeTimers;
|
||||
|
||||
setup(function () {
|
||||
accessor = createLibTestingContext().createTestingAccessor();
|
||||
manager = accessor.get(IInstantiationService).createInstance(AsyncCompletionManager);
|
||||
clock = sinon.useFakeTimers();
|
||||
});
|
||||
|
||||
teardown(function () {
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
suite('shouldWaitForAsyncCompletions', function () {
|
||||
test('is false when there are no requests', function () {
|
||||
const prefix = 'func main() {\n';
|
||||
const prompt = createPrompt(prefix, '}\n');
|
||||
const shouldQueue = manager.shouldWaitForAsyncCompletions(prefix, prompt);
|
||||
assert.strictEqual(shouldQueue, false);
|
||||
});
|
||||
|
||||
test('is false when there are no matching requests', async function () {
|
||||
void manager.queueCompletionRequest('0', 'import (', createPrompt(), CTS(), pendingResult()); // Prefix doesn't match
|
||||
void manager.queueCompletionRequest('1', 'func main() {\n', createPrompt('', '\t'), CTS(), pendingResult()); // Suffix doesn't match
|
||||
await manager.queueCompletionRequest('2', 'package ', createPrompt(), CTS(), fakeResult('main')); // Prefix doesn't match completed
|
||||
await manager.queueCompletionRequest('3', 'func ', createPrompt(), CTS(), fakeResult('test')); // Completion doesn't match prefix
|
||||
void manager.queueCompletionRequest('4', 'func ', createPrompt(), CTS(), pendingResult()); // Partial completion doesn't match prefix
|
||||
manager.updateCompletion('4', 'func test');
|
||||
|
||||
assert.strictEqual(manager.shouldWaitForAsyncCompletions('func main() {\n', createPrompt()), false);
|
||||
});
|
||||
|
||||
test('is true when there is a matching pending request', function () {
|
||||
const prefix = 'func main() {\n';
|
||||
const prompt = createPrompt(prefix, '}\n');
|
||||
void manager.queueCompletionRequest('0', prefix, prompt, CTS(), pendingResult());
|
||||
|
||||
assert.strictEqual(manager.shouldWaitForAsyncCompletions(prefix, prompt), true);
|
||||
});
|
||||
|
||||
test('is true when there is a matching completed request', async function () {
|
||||
const prefix = 'func main() {\n';
|
||||
const prompt = createPrompt(prefix, '}\n');
|
||||
const promise = fakeResult('\tfmt.Println("Hello, world!")');
|
||||
await manager.queueCompletionRequest('0', prefix, prompt, CTS(), promise);
|
||||
|
||||
assert.strictEqual(manager.shouldWaitForAsyncCompletions(prefix, prompt), true);
|
||||
});
|
||||
|
||||
test('is true when there is a completed request with a prefixing prompt and matching completion', async function () {
|
||||
const earlierPrefix = 'func main() {\n';
|
||||
const earlierPrompt = createPrompt(earlierPrefix, '}\n');
|
||||
const promise = fakeResult('\tfmt.Println("Hello, world!")');
|
||||
await manager.queueCompletionRequest('0', earlierPrefix, earlierPrompt, CTS(), promise);
|
||||
|
||||
const prefix = 'func main() {\n\tfmt.';
|
||||
const prompt = createPrompt(prefix, '}\n');
|
||||
assert.strictEqual(manager.shouldWaitForAsyncCompletions(prefix, prompt), true);
|
||||
});
|
||||
|
||||
test('is true when there is a pending request with a prefixing prompt and matching partial result', function () {
|
||||
const earlierPrefix = 'func main() {\n';
|
||||
const earlierPrompt = createPrompt(earlierPrefix, '}\n');
|
||||
void manager.queueCompletionRequest('0', earlierPrefix, earlierPrompt, CTS(), pendingResult());
|
||||
manager.updateCompletion('0', '\tfmt.Println');
|
||||
|
||||
const prefix = 'func main() {\n\tfmt.';
|
||||
const prompt = createPrompt(prefix, '}\n');
|
||||
assert.strictEqual(manager.shouldWaitForAsyncCompletions(prefix, prompt), true);
|
||||
});
|
||||
});
|
||||
|
||||
suite('getFirstMatchingRequest', function () {
|
||||
test('returns undefined when there are no matching choices', async function () {
|
||||
void manager.queueCompletionRequest('0', 'import (', createPrompt(), CTS(), pendingResult()); // Prefix doesn't match
|
||||
void manager.queueCompletionRequest('1', 'func main() {\n', createPrompt('', '\t'), CTS(), pendingResult()); // Suffix doesn't match
|
||||
void manager.queueCompletionRequest('2', 'func ', createPrompt(), CTS(), fakeResult('test')); // Completion doesn't match prefix
|
||||
|
||||
const choice = await manager.getFirstMatchingRequest('3', 'func main() {\n', createPrompt(), false);
|
||||
|
||||
assert.strictEqual(choice, undefined);
|
||||
});
|
||||
|
||||
test('does not return an empty choice', async function () {
|
||||
void manager.queueCompletionRequest('0', 'func ', createPrompt(), CTS(), fakeResult('main() {\n'));
|
||||
|
||||
const choice = await manager.getFirstMatchingRequest('1', 'func mai(){ \n', createPrompt(), false);
|
||||
|
||||
assert.strictEqual(choice, undefined);
|
||||
});
|
||||
|
||||
test('returns the first resolved choice that matches', async function () {
|
||||
void manager.queueCompletionRequest(
|
||||
'0',
|
||||
'func ',
|
||||
createPrompt(),
|
||||
CTS(),
|
||||
fakeResult('main() {\n', r => delay(1, r))
|
||||
);
|
||||
void manager.queueCompletionRequest(
|
||||
'1',
|
||||
'func ',
|
||||
createPrompt(),
|
||||
CTS(),
|
||||
fakeResult('main() {\n\terr :=', r => delay(2000, r))
|
||||
);
|
||||
void manager.queueCompletionRequest(
|
||||
'2',
|
||||
'func ',
|
||||
createPrompt(),
|
||||
CTS(),
|
||||
fakeResult('main() {\n\tfmt.Println', r => delay(20, r))
|
||||
);
|
||||
|
||||
const choicePromise = manager.getFirstMatchingRequest('3', 'func main() {\n', createPrompt(), false);
|
||||
await clock.runAllAsync();
|
||||
const choice = await choicePromise;
|
||||
|
||||
assert.ok(choice);
|
||||
assert.strictEqual(choice[0].completionText, '\tfmt.Println');
|
||||
assert.strictEqual(choice[0].telemetryData.measurements.foundOffset, 9);
|
||||
});
|
||||
});
|
||||
|
||||
suite('getFirstMatchingRequestWithTimeout', function () {
|
||||
test('returns result before timeout', async function () {
|
||||
void manager.queueCompletionRequest(
|
||||
'0',
|
||||
'fmt.',
|
||||
createPrompt(),
|
||||
CTS(),
|
||||
fakeResult('Println("Hi")', r => delay(1, r))
|
||||
);
|
||||
const featuresService = accessor.get(ICompletionsFeaturesService);
|
||||
featuresService.asyncCompletionsTimeout = () => 1000;
|
||||
|
||||
const choicePromise = manager.getFirstMatchingRequestWithTimeout(
|
||||
'1',
|
||||
'fmt.',
|
||||
createPrompt(),
|
||||
false,
|
||||
TelemetryWithExp.createEmptyConfigForTesting()
|
||||
);
|
||||
await clock.runAllAsync();
|
||||
const choice = await choicePromise;
|
||||
|
||||
assert.ok(choice);
|
||||
assert.strictEqual(choice[0].completionText, 'Println("Hi")');
|
||||
});
|
||||
|
||||
test('returns undefined after timeout', async function () {
|
||||
void manager.queueCompletionRequest(
|
||||
'0',
|
||||
'fmt.',
|
||||
createPrompt(),
|
||||
CTS(),
|
||||
fakeResult('Println("Hello")', r => delay(2000, r))
|
||||
);
|
||||
const featuresService = accessor.get(ICompletionsFeaturesService);
|
||||
featuresService.asyncCompletionsTimeout = () => 10;
|
||||
|
||||
const choicePromise = manager.getFirstMatchingRequestWithTimeout(
|
||||
'1',
|
||||
'fmt.',
|
||||
createPrompt(),
|
||||
false,
|
||||
TelemetryWithExp.createEmptyConfigForTesting()
|
||||
);
|
||||
await clock.runAllAsync();
|
||||
const choice = await choicePromise;
|
||||
|
||||
assert.strictEqual(choice, undefined);
|
||||
});
|
||||
|
||||
test('does not timeout if timeout is set to -1', async function () {
|
||||
void manager.queueCompletionRequest(
|
||||
'0',
|
||||
'fmt.',
|
||||
createPrompt(),
|
||||
CTS(),
|
||||
fakeResult('Println("Hi")', r => delay(100, r))
|
||||
);
|
||||
const featuresService = accessor.get(ICompletionsFeaturesService);
|
||||
featuresService.asyncCompletionsTimeout = () => -1;
|
||||
|
||||
const choicePromise = manager.getFirstMatchingRequestWithTimeout(
|
||||
'1',
|
||||
'fmt.',
|
||||
createPrompt(),
|
||||
false,
|
||||
TelemetryWithExp.createEmptyConfigForTesting()
|
||||
);
|
||||
await clock.runAllAsync();
|
||||
const choice = await choicePromise;
|
||||
|
||||
assert.ok(choice);
|
||||
assert.strictEqual(choice[0].completionText, 'Println("Hi")');
|
||||
});
|
||||
});
|
||||
|
||||
suite('cancels', function () {
|
||||
test('pending requests that are no longer candidates for the most recent', function () {
|
||||
const firstToken = CTS();
|
||||
const secondToken = CTS();
|
||||
void manager.queueCompletionRequest('0', 'import (', createPrompt(), firstToken, pendingResult()); // Prefix doesn't match
|
||||
void manager.queueCompletionRequest('1', 'func ', createPrompt(), secondToken, pendingResult());
|
||||
manager.updateCompletion('1', 'test()'); // Partial completion doesn't match prefix
|
||||
|
||||
void manager.getFirstMatchingRequest('2', 'func main() {\n', createPrompt(), false);
|
||||
|
||||
assert.strictEqual(firstToken.token.isCancellationRequested, true);
|
||||
assert.strictEqual(secondToken.token.isCancellationRequested, true);
|
||||
});
|
||||
|
||||
test('pending request after updating to no longer match', function () {
|
||||
const cts = CTS();
|
||||
void manager.queueCompletionRequest('1', 'func ', createPrompt(), cts, pendingResult());
|
||||
|
||||
void manager.getFirstMatchingRequest('2', 'func main() {\n', createPrompt(), false);
|
||||
manager.updateCompletion('1', 'test()');
|
||||
|
||||
assert.strictEqual(cts.token.isCancellationRequested, true);
|
||||
});
|
||||
|
||||
test('only requests that do not match the most recent request', function () {
|
||||
const cts = CTS();
|
||||
void manager.queueCompletionRequest('1', 'func ', createPrompt(), cts, pendingResult());
|
||||
void manager.getFirstMatchingRequest('2', 'func main', createPrompt(), false);
|
||||
void manager.getFirstMatchingRequest('3', 'func test', createPrompt(), false);
|
||||
manager.updateCompletion('1', 'test()');
|
||||
|
||||
assert.strictEqual(cts.token.isCancellationRequested, false);
|
||||
});
|
||||
|
||||
test('only requests that do not match the most recent request excluding speculative requests', function () {
|
||||
const cts = CTS();
|
||||
void manager.queueCompletionRequest('1', 'func ', createPrompt(), cts, pendingResult());
|
||||
void manager.getFirstMatchingRequest('2', 'func main', createPrompt(), false);
|
||||
void manager.getFirstMatchingRequest('3', 'func test', createPrompt(), false);
|
||||
void manager.getFirstMatchingRequest('4', 'func main() {\nvar i;', createPrompt(), true);
|
||||
manager.updateCompletion('1', 'test()');
|
||||
|
||||
assert.strictEqual(cts.token.isCancellationRequested, false);
|
||||
});
|
||||
|
||||
test('all requests that do not match the most recent request', function () {
|
||||
const firstCTS = CTS();
|
||||
const secondCTS = CTS();
|
||||
const thirdCTS = CTS();
|
||||
void manager.queueCompletionRequest('0', 'func ', createPrompt(), firstCTS, pendingResult());
|
||||
void manager.queueCompletionRequest('1', 'func mai', createPrompt(), secondCTS, pendingResult());
|
||||
void manager.getFirstMatchingRequest('2', 'func main', createPrompt(), false);
|
||||
manager.updateCompletion('0', 'main');
|
||||
void manager.queueCompletionRequest('3', 'func t', createPrompt(), thirdCTS, pendingResult());
|
||||
void manager.getFirstMatchingRequest('4', 'func test', createPrompt(), false);
|
||||
manager.updateCompletion('3', 'rigger');
|
||||
|
||||
assert.strictEqual(firstCTS.token.isCancellationRequested, true);
|
||||
assert.strictEqual(secondCTS.token.isCancellationRequested, true);
|
||||
assert.strictEqual(thirdCTS.token.isCancellationRequested, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createPrompt(prefix = '', suffix = ''): Prompt {
|
||||
return { prefix, suffix, isFimEnabled: true };
|
||||
}
|
||||
|
||||
type Result = GhostTextResultWithTelemetry<[APIChoice, Promise<void>]>;
|
||||
|
||||
function fakeResult(completionText: string, resolver = (r: Result) => Promise.resolve(r)): Promise<Result> {
|
||||
const telemetryBlob = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
return resolver({
|
||||
type: 'success',
|
||||
value: [fakeAPIChoice(generateUuid(), 0, completionText), new Promise(() => { })],
|
||||
telemetryData: mkBasicResultTelemetry(telemetryBlob),
|
||||
telemetryBlob,
|
||||
resultType: ResultType.Async,
|
||||
});
|
||||
}
|
||||
|
||||
function pendingResult(): Promise<Result> {
|
||||
return new Promise(() => { });
|
||||
}
|
||||
|
||||
function CTS() {
|
||||
return new CancellationTokenSource();
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import assert from 'assert';
|
||||
import dedent from 'ts-dedent';
|
||||
import { createTextDocument } from '../../test/textDocument';
|
||||
import { TextDocumentManager } from '../../textDocumentManager';
|
||||
import {
|
||||
BlockPositionType,
|
||||
BlockTrimmer,
|
||||
getBlockPositionType,
|
||||
TerseBlockTrimmer,
|
||||
VerboseBlockTrimmer,
|
||||
} from '../blockTrimmer';
|
||||
const x = TextDocumentManager;
|
||||
console.log(x);
|
||||
|
||||
suite('VerboseBlockTrimmer', function () {
|
||||
test('.getCompletionTrimOffset() returns undefined when it is under the line limit', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function twoWayMerge<T>(sortedList1: T[], sortedList2: T[]): T[] {
|
||||
let mergedList: T[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
âšwhile (i < sortedList1.length && j < sortedList2.length) {
|
||||
if (compareNumbers(sortedList1[i], sortedList2[j]) <= 0) {
|
||||
mergedList.push(sortedList1[i]);
|
||||
i++;
|
||||
} else {
|
||||
mergedList.push(sortedList2[j]);
|
||||
j++;
|
||||
}
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() does not trim trailing newlines', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
function twoWayMerge<T>(sortedList1: T[], sortedList2: T[]): T[] {
|
||||
let mergedList: T[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
âšwhile (i < sortedList1.length && j < sortedList2.length) {
|
||||
if (compareNumbers(sortedList1[i], sortedList2[j]) <= 0) {
|
||||
mergedList.push(sortedList1[i]);
|
||||
i++;
|
||||
} else {
|
||||
mergedList.push(sortedList2[j]);
|
||||
j++;
|
||||
}
|
||||
}
|
||||
` + '\n'
|
||||
);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims to the containing block even if under the limit', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function twoWayMerge<T>(sortedList1: T[], sortedList2: T[]): T[] {
|
||||
âšlet mergedList: T[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
}✂ï¸
|
||||
const merged = twoWayMerge([1, 2, 3], [4, 5, 6]);
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims at a blank line when one is found', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function twoWayMerge<T>(sortedList1: T[], sortedList2: T[]): T[] {
|
||||
âšlet mergedList: T[] = [];
|
||||
let i = 0;
|
||||
let j = 0;✂ï¸
|
||||
|
||||
while (i < sortedList1.length && j < sortedList2.length) {
|
||||
if (compareNumbers(sortedList1[i], sortedList2[j]) <= 0) {
|
||||
mergedList.push(sortedList1[i]);
|
||||
i++;
|
||||
} else {
|
||||
mergedList.push(sortedList2[j]);
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < sortedList1.length) {
|
||||
mergedList.push(sortedList1[i]);
|
||||
i++;
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims at a statement when no blank lines are present', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function twoWayMerge<T>(sortedList1: T[], sortedList2: T[]): T[] {
|
||||
âšlet mergedList: T[] = [];
|
||||
let i = 0;
|
||||
let j = 0;✂ï¸
|
||||
while (i < sortedList1.length && j < sortedList2.length) {
|
||||
if (compareNumbers(sortedList1[i], sortedList2[j]) <= 0) {
|
||||
mergedList.push(sortedList1[i]);
|
||||
i++;
|
||||
} else {
|
||||
mergedList.push(sortedList2[j]);
|
||||
j++;
|
||||
}
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims at a child statement when the first statement is over the limit', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function twoWayMerge<T>(sortedList1: T[], sortedList2: T[]): T[] {
|
||||
let mergedList: T[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
âšwhile (i < sortedList1.length && j < sortedList2.length) {
|
||||
// compareNumbers has the following return values:
|
||||
// -1 if sortedList1[i] < sortedList2[j]
|
||||
// 0 if sortedList1[i] === sortedList2[j]
|
||||
// 1 if sortedList1[i] > sortedList2[j]
|
||||
if (compareNumbers(sortedList1[i], sortedList2[j]) <= 0) {
|
||||
// sortedList1[i] is less than or equal to sortedList2[j]
|
||||
mergedList.push(sortedList1[i]);
|
||||
i++;
|
||||
}âœ‚ï¸ else {
|
||||
// sortedList1[i] is greater than sortedList2[j]
|
||||
mergedList.push(sortedList2[j]);
|
||||
j++;
|
||||
}
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims at a top-level statement when no containing block is present', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
const a = 1;
|
||||
âšconst b = 2;
|
||||
const c = 3;
|
||||
const d = 4;
|
||||
const e = 5;
|
||||
const f = 6;
|
||||
const g = 7;
|
||||
const h = 8;
|
||||
const i = 9;
|
||||
const j = 10;
|
||||
const k = 11;✂ï¸
|
||||
const l = 12;
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims before a statement that begins past the line limit', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
const a = 1;
|
||||
âšconst b = 2;
|
||||
const c = 3;
|
||||
const d = 4;
|
||||
const e = 5;
|
||||
const f = 6;
|
||||
const g = 7;✂ï¸
|
||||
// comment 1
|
||||
// comment 2
|
||||
// comment 3
|
||||
// comment 4
|
||||
// comment 5
|
||||
const h = 8;
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims trailing, non-statement text that goes over the line limit', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
const a = 1;
|
||||
âšconst b = 2;
|
||||
const c = 3;
|
||||
const d = 4;✂ï¸
|
||||
// comment 1
|
||||
// comment 2
|
||||
// comment 3
|
||||
// comment 4
|
||||
// comment 5
|
||||
// comment 6
|
||||
// comment 7
|
||||
// comment 8
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims to the first statement when it is unsplittable even if over the limit', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function foo(arg: boolean) {
|
||||
âšif (arg) {
|
||||
// comment 1
|
||||
// comment 2
|
||||
// comment 3
|
||||
// comment 4
|
||||
// comment 5
|
||||
// comment 6
|
||||
// comment 7
|
||||
// comment 8
|
||||
// comment 9
|
||||
// comment 10
|
||||
}✂ï¸
|
||||
return;
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims to the first statement when it begins past the limit', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function foo(arg: boolean) {
|
||||
âš// comment 1
|
||||
// comment 2
|
||||
// comment 3
|
||||
// comment 4
|
||||
// comment 5
|
||||
// comment 6
|
||||
// comment 7
|
||||
// comment 8
|
||||
// comment 9
|
||||
// comment 10
|
||||
// comment 11
|
||||
const str = arg ? 'true' : 'false';✂ï¸
|
||||
console.log(str);
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims to the first statement when it begins past the limit even if unsplittable', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function foo(arg: boolean) {
|
||||
âš// comment 1
|
||||
// comment 2
|
||||
// comment 3
|
||||
// comment 4
|
||||
// comment 5
|
||||
// comment 6
|
||||
// comment 7
|
||||
// comment 8
|
||||
// comment 9
|
||||
// comment 10
|
||||
// comment 11
|
||||
if (arg) {
|
||||
// comment 12
|
||||
}✂ï¸
|
||||
return;
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims to the first statement before non-statement content', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function exâšample(flag) {
|
||||
flag = !flag;✂ï¸
|
||||
if (flag) {
|
||||
flag = !flag;
|
||||
if (!flag) {
|
||||
flag = !flag;
|
||||
if (flag) {
|
||||
flag = !flag;
|
||||
if (!flag) {
|
||||
flag = !flag;
|
||||
if (flag) {
|
||||
flag = !flag;
|
||||
`);
|
||||
});
|
||||
|
||||
async function testCompletionTrimming(textWithCompletion: string): Promise<void> {
|
||||
await testCompletionTrimmingWithTrimmer(textWithCompletion, VerboseBlockTrimmer);
|
||||
}
|
||||
});
|
||||
|
||||
suite('TerseBlockTrimmer', function () {
|
||||
test('.getCompletionTrimOffset() returns undefined for a single statement under the line limit', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function example() {
|
||||
âšlet result = [];
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims to the containing block', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function example() {
|
||||
âšreturn;
|
||||
}✂ï¸
|
||||
function example2() {
|
||||
return;
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims at a blank line', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function example() {
|
||||
âšlet result = [];✂ï¸
|
||||
|
||||
let i = 0;
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims at non-statement content between statements', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function example() {
|
||||
âšlet result = [];✂ï¸
|
||||
// comment
|
||||
let i = 0;
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims at the start of a new compound statement', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function example() {
|
||||
âšlet result = [];
|
||||
let i = 0;✂ï¸
|
||||
for (i = 0; i < 10; i++) {
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims after a single compound statement', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function reverseFind(haystack, needle) {
|
||||
âšfor (let i = haystack.length - 1; i >= 0; i--) {
|
||||
if (haystack[i] === needle) return i;
|
||||
}✂ï¸
|
||||
return -1;
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims to the line limit once the look-ahead size is exceeded', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function example() {
|
||||
âš// line 1
|
||||
// line 2
|
||||
// line 3✂ï¸
|
||||
// line 4
|
||||
// line 5
|
||||
// line 6
|
||||
// line 7
|
||||
// line 8
|
||||
// line 9
|
||||
// line 10
|
||||
// line 11
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() allows a single section to fill up to the look-ahead size if it is complete', async function () {
|
||||
await testCompletionTrimming(dedent`
|
||||
function example() {
|
||||
âšconst a = 1;
|
||||
const b = 2;
|
||||
const c = 3;
|
||||
const d = 4;
|
||||
const e = 5;
|
||||
const f = 6;✂ï¸
|
||||
while (true) {
|
||||
`);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() supports Python', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
def reverse_find(haystack, needle):
|
||||
âšresult = []
|
||||
i = 0✂ï¸
|
||||
while i < len(haystack):
|
||||
`,
|
||||
'python'
|
||||
);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() trims to a containing block in Python', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
def example(a, b):
|
||||
if a > b:
|
||||
âšc = a - b
|
||||
return c✂ï¸
|
||||
else:
|
||||
`,
|
||||
'python'
|
||||
);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() supports Go', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
package main
|
||||
|
||||
func reverseFind(haystack []int, needle int) int {
|
||||
âšresult := []int{}
|
||||
i := 0✂ï¸
|
||||
for i < len(haystack) {
|
||||
if haystack[i] == needle {
|
||||
return i
|
||||
`,
|
||||
'go'
|
||||
);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() supports PHP', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
<?php
|
||||
function reverse_find($haystack, $needle) {
|
||||
âš$search = array_reverse($haystack, true);✂ï¸
|
||||
foreach ($search as $index => $item) {
|
||||
if ($item === $needle) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
`,
|
||||
'php'
|
||||
);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() supports Ruby', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
def reverse_find(haystack, needle)
|
||||
âšlen = haystack.length
|
||||
i = len - 1✂ï¸
|
||||
while i >= 0 do
|
||||
return i if haystack[i] == needle
|
||||
i -= 1
|
||||
end
|
||||
`,
|
||||
'ruby'
|
||||
);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() supports Java', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
public class Main {
|
||||
public static int reverseFind(int[] haystack, int needle) {
|
||||
âšint end = haystack.length - 1;✂ï¸
|
||||
for (int i = end; i >= 0; i--) {
|
||||
if (haystack[i] == needle) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
`,
|
||||
'java'
|
||||
);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() supports C#', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
class Program {
|
||||
static int ReverseFind(int[] haystack, int needle) {
|
||||
âšint end = haystack.Length - 1;✂ï¸
|
||||
for (int i = end; i >= 0; i--) {
|
||||
if (haystack[i] == needle) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
`,
|
||||
'csharp'
|
||||
);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() supports C', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
#include <stdio.h>
|
||||
|
||||
int reverse_find(int haystack[], int needle, int size) {
|
||||
âšint i = size - 1;✂ï¸
|
||||
while (i >= 0) {
|
||||
if (haystack[i] == needle) {
|
||||
return i;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
`,
|
||||
'c'
|
||||
);
|
||||
});
|
||||
|
||||
test('.getCompletionTrimOffset() supports C++', async function () {
|
||||
await testCompletionTrimming(
|
||||
dedent`
|
||||
#include <iostream>
|
||||
using namespace std;
|
||||
|
||||
template <typename T>
|
||||
class ReverseFind {
|
||||
public:
|
||||
static int find(T haystack[], T needle, int size) {
|
||||
âšint i = size - 1;✂ï¸
|
||||
while (i >= 0) {
|
||||
if (haystack[i] == needle) {
|
||||
return i;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
}
|
||||
};
|
||||
`,
|
||||
'cpp'
|
||||
);
|
||||
});
|
||||
|
||||
async function testCompletionTrimming(textWithCompletion: string, languageId = 'typescript'): Promise<void> {
|
||||
await testCompletionTrimmingWithTrimmer(textWithCompletion, TerseBlockTrimmer, languageId);
|
||||
}
|
||||
});
|
||||
|
||||
interface BlockTrimmerConstructor {
|
||||
new(languageId: string, prefix: string, completion: string): BlockTrimmer;
|
||||
}
|
||||
|
||||
async function testCompletionTrimmingWithTrimmer(
|
||||
textWithCompletion: string,
|
||||
blockTrimmerType: BlockTrimmerConstructor,
|
||||
languageId = 'typescript'
|
||||
): Promise<void> {
|
||||
const cursorMarker = 'âš';
|
||||
const trimMarker = '✂ï¸';
|
||||
const cursorPos = textWithCompletion.indexOf(cursorMarker);
|
||||
const trimPos = textWithCompletion.indexOf(trimMarker);
|
||||
const prefix = textWithCompletion.substring(0, cursorPos);
|
||||
const trimmed = textWithCompletion.substring(cursorPos + cursorMarker.length, trimPos === -1 ? undefined : trimPos);
|
||||
const completion = trimmed + (trimPos === -1 ? '' : textWithCompletion.substring(trimPos + trimMarker.length));
|
||||
const expectedOffset = trimPos === -1 ? undefined : trimmed.length;
|
||||
const trimmer = new blockTrimmerType(languageId, prefix, completion);
|
||||
|
||||
const actualOffset = await trimmer.getCompletionTrimOffset();
|
||||
|
||||
assert.strictEqual(
|
||||
actualOffset,
|
||||
expectedOffset,
|
||||
dedent`
|
||||
Expected an offset of ${expectedOffset} but got ${actualOffset}
|
||||
${trimmed === completion.substring(0, actualOffset) ? 'true' : 'false'}
|
||||
|
||||
expected completion:
|
||||
${JSON.stringify(completion.substring(0, expectedOffset))}
|
||||
|
||||
actual completion:
|
||||
${JSON.stringify(completion.substring(0, actualOffset))}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
suite('getBlockPositionType()', function () {
|
||||
test('empty document returns NonBlock', async function () {
|
||||
await testPositionType(BlockPositionType.NonBlock, 'âš');
|
||||
});
|
||||
|
||||
test('on a simple expression returns NonBlock', async function () {
|
||||
await testPositionType(BlockPositionType.NonBlock, 'âšconst x = 1;');
|
||||
});
|
||||
|
||||
test('with an empty block returns EmptyBlock', async function () {
|
||||
await testPositionType(BlockPositionType.EmptyBlock, 'while (true) { âš }');
|
||||
await testPositionType(BlockPositionType.EmptyBlock, 'function example() { âš }');
|
||||
});
|
||||
|
||||
test('at the end of a non-empty block returns BlockEnd', async function () {
|
||||
await testPositionType(BlockPositionType.BlockEnd, 'while (true) { x += 1; âš }');
|
||||
});
|
||||
|
||||
test('mid-statement at the end of a non-empty block returns BlockEnd', async function () {
|
||||
await testPositionType(BlockPositionType.BlockEnd, 'while (true) { x += 1âš; }');
|
||||
});
|
||||
|
||||
test('between statements within a block returns MidBlock', async function () {
|
||||
await testPositionType(BlockPositionType.MidBlock, 'while (true) { last = x; âš x += 1; }');
|
||||
});
|
||||
|
||||
test('on a statement before the last within a block returns MidBlock', async function () {
|
||||
await testPositionType(BlockPositionType.MidBlock, 'while (true) { last = xâš; x += 1; }');
|
||||
});
|
||||
|
||||
test('on a multi-line simple statement within a block before the last line returns MidBlock', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.MidBlock,
|
||||
dedent`
|
||||
if (true) {
|
||||
someFunction(
|
||||
arg1,
|
||||
arg2,
|
||||
âš
|
||||
arg3
|
||||
);
|
||||
}
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
test('on a multi-line simple statement within a block on the last line returns BlockEnd', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.BlockEnd,
|
||||
dedent`
|
||||
if (true) {
|
||||
someFunction(
|
||||
arg1,
|
||||
arg2,
|
||||
arg3
|
||||
âš);
|
||||
}
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
// confirm single-line if statement behavior in JS given the special treatment by StatementTree:
|
||||
test('inside an empty block of a single-line if statement in JS returns EmptyBlock', async function () {
|
||||
await testPositionType(BlockPositionType.EmptyBlock, 'if (true) { âš }');
|
||||
});
|
||||
|
||||
test('supports Python', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.MidBlock,
|
||||
dedent`
|
||||
def example():
|
||||
âš
|
||||
pass
|
||||
`,
|
||||
'python'
|
||||
);
|
||||
});
|
||||
|
||||
test('supports Go', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.EmptyBlock,
|
||||
dedent`
|
||||
package main
|
||||
|
||||
func main() {
|
||||
âš
|
||||
}
|
||||
`,
|
||||
'go'
|
||||
);
|
||||
});
|
||||
|
||||
test('supports PHP', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.EmptyBlock,
|
||||
dedent`
|
||||
<?php
|
||||
function main() {
|
||||
âš
|
||||
}
|
||||
`,
|
||||
'php'
|
||||
);
|
||||
});
|
||||
|
||||
test('supports Ruby', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.EmptyBlock,
|
||||
dedent`
|
||||
def main
|
||||
âš
|
||||
end
|
||||
`,
|
||||
'ruby'
|
||||
);
|
||||
});
|
||||
|
||||
test('supports Java', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.EmptyBlock,
|
||||
dedent`
|
||||
public class Main {
|
||||
public static void main(String[] args) {
|
||||
âš
|
||||
}
|
||||
}
|
||||
`,
|
||||
'java'
|
||||
);
|
||||
});
|
||||
|
||||
test('supports C#', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.EmptyBlock,
|
||||
dedent`
|
||||
class Program
|
||||
{
|
||||
static void Main(string[] args) {
|
||||
âš
|
||||
}
|
||||
}
|
||||
`,
|
||||
'csharp'
|
||||
);
|
||||
});
|
||||
|
||||
test('supports C', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.EmptyBlock,
|
||||
dedent`
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
âš
|
||||
}
|
||||
`,
|
||||
'cpp'
|
||||
);
|
||||
});
|
||||
|
||||
test('supports C++', async function () {
|
||||
await testPositionType(
|
||||
BlockPositionType.EmptyBlock,
|
||||
dedent`
|
||||
#include <iostream>
|
||||
|
||||
class Main {
|
||||
âš
|
||||
}
|
||||
`,
|
||||
'cpp'
|
||||
);
|
||||
});
|
||||
|
||||
async function testPositionType(
|
||||
expectedType: BlockPositionType,
|
||||
textWithCursor: string,
|
||||
languageId = 'typescript'
|
||||
): Promise<void> {
|
||||
const cursorMarker = 'âš';
|
||||
const cursorPos = textWithCursor.indexOf(cursorMarker);
|
||||
const prefix = textWithCursor.substring(0, cursorPos);
|
||||
const suffix = textWithCursor.substring(cursorPos + cursorMarker.length);
|
||||
const doc = createTextDocument('file:///test.ts', languageId, 0, prefix + suffix);
|
||||
const pos = doc.positionAt(cursorPos);
|
||||
|
||||
const actualType = await getBlockPositionType(doc, pos);
|
||||
|
||||
assert.strictEqual(actualType, expectedType);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { CurrentGhostText } from '../current';
|
||||
import { ResultType } from '../ghostText';
|
||||
import { fakeAPIChoice } from '../../openai/fetch.fake';
|
||||
import { APIChoice } from '../../openai/openai';
|
||||
import * as assert from 'assert';
|
||||
import { generateUuid } from '../../../../../../../util/vs/base/common/uuid';
|
||||
|
||||
suite('CurrentGhostText', function () {
|
||||
let current: CurrentGhostText;
|
||||
|
||||
setup(function () {
|
||||
current = new CurrentGhostText();
|
||||
});
|
||||
|
||||
suite('getCompletionsForUserTyping', function () {
|
||||
test('returns undefined if there is no current completion', function () {
|
||||
const result = current.getCompletionsForUserTyping('func main() {\n', '');
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('returns the current completion for an exact match', function () {
|
||||
const choice = fakeChoice();
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
const result = current.getCompletionsForUserTyping('func main() {\n', '');
|
||||
|
||||
assert.deepStrictEqual(result, [choice]);
|
||||
});
|
||||
|
||||
test('returns the current completion for a prefix match', function () {
|
||||
const choice = fakeChoice();
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
const result = current.getCompletionsForUserTyping('func main() {\nfmt.Print', '');
|
||||
|
||||
assert.deepStrictEqual(result, [{ ...choice, completionText: 'ln("Hello, World!")' }]);
|
||||
});
|
||||
|
||||
test('returns undefined when the prefix does not match', function () {
|
||||
const choice = fakeChoice();
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
const result = current.getCompletionsForUserTyping('func test() {\n', '}');
|
||||
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('returns undefined when the suffix does not match', function () {
|
||||
const choice = fakeChoice();
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
const result = current.getCompletionsForUserTyping('func main() {\n', '}');
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('returns undefined when the completion does not match', function () {
|
||||
const choice = fakeChoice();
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
const result = current.getCompletionsForUserTyping('func main() {\nerr', '}');
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('returns undefined when the completion is exhausted', function () {
|
||||
const choice = fakeChoice();
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
const result = current.getCompletionsForUserTyping('func main() {\nfmt.Println("Hello, World!")', '');
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('does not change the current completion when TypingAsSuggested', function () {
|
||||
const choice = fakeChoice();
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
current.setGhostText(
|
||||
'func main() {\nfmt.',
|
||||
'',
|
||||
[fakeChoice('Println("Hello, World!")')],
|
||||
ResultType.TypingAsSuggested
|
||||
);
|
||||
const result = current.getCompletionsForUserTyping('func main() {\n', '');
|
||||
|
||||
assert.deepStrictEqual(result![0].requestId, choice.requestId);
|
||||
});
|
||||
|
||||
test('only returns cycling completions that match', function () {
|
||||
const choice = fakeChoice();
|
||||
const choice2 = fakeChoice('err := nil', 1);
|
||||
const choice3 = fakeChoice('fmt.Println("hi")', 2);
|
||||
current.setGhostText('func main() {\n', '', [choice, choice2, choice3], ResultType.Network);
|
||||
|
||||
const result = current.getCompletionsForUserTyping('func main() {\nfmt', '');
|
||||
|
||||
assert.deepStrictEqual(result, [
|
||||
{ ...choice, completionText: '.Println("Hello, World!")' },
|
||||
{ ...choice3, completionText: '.Println("hi")' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
suite('hasAcceptedCurrentCompletion', function () {
|
||||
test('returns false if there is no current completion', function () {
|
||||
assert.ok(!current.hasAcceptedCurrentCompletion('func main() {\n', ''));
|
||||
});
|
||||
|
||||
test('returns false for uncompleted completions', function () {
|
||||
current.setGhostText('func main() {\n', '', [fakeChoice()], ResultType.Network);
|
||||
|
||||
assert.ok(!current.hasAcceptedCurrentCompletion('func main() {\n', ''));
|
||||
assert.ok(!current.hasAcceptedCurrentCompletion('func main() {\nfmt.Println', ''));
|
||||
assert.ok(!current.hasAcceptedCurrentCompletion('func main() {\nfmt.Println("hi")', ''));
|
||||
});
|
||||
|
||||
test('returns true for completed completion', function () {
|
||||
current.setGhostText('func main() {\n', '', [fakeChoice()], ResultType.Network);
|
||||
|
||||
assert.ok(current.hasAcceptedCurrentCompletion('func main() {\nfmt.Println("Hello, World!")', ''));
|
||||
});
|
||||
|
||||
test('returns false for completed completion with content_filter finish reason', function () {
|
||||
const choice = fakeChoice();
|
||||
choice.finishReason = 'content_filter';
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
assert.ok(!current.hasAcceptedCurrentCompletion('func main() {\nfmt.Println("Hello, World!")', ''));
|
||||
});
|
||||
|
||||
test('returns false for completed completion with snippy finish reason', function () {
|
||||
const choice = fakeChoice();
|
||||
choice.finishReason = 'snippy';
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
assert.ok(!current.hasAcceptedCurrentCompletion('func main() {\nfmt.Println("Hello, World!")', ''));
|
||||
});
|
||||
});
|
||||
|
||||
test('clientCompletionId returns the current completion id', function () {
|
||||
const choice = fakeChoice();
|
||||
current.setGhostText('func main() {\n', '', [choice], ResultType.Network);
|
||||
|
||||
assert.strictEqual(current.clientCompletionId, choice.clientCompletionId);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeChoice(completionText = 'fmt.Println("Hello, World!")', choice = 0): APIChoice {
|
||||
return fakeAPIChoice(generateUuid(), choice, completionText);
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import assert from 'assert';
|
||||
import { Position } from 'shiki/core';
|
||||
import dedent from 'ts-dedent';
|
||||
import type { CancellationToken } from 'vscode';
|
||||
import { CancellationTokenSource } from 'vscode-languageserver-protocol';
|
||||
import { generateUuid } from '../../../../../../../util/vs/base/common/uuid';
|
||||
import { SyncDescriptor } from '../../../../../../../util/vs/platform/instantiation/common/descriptors';
|
||||
import { ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { initializeTokenizers } from '../../../../prompt/src/tokenization';
|
||||
import { CompletionState, createCompletionState } from '../../completionState';
|
||||
import { ConfigKey, ICompletionsConfigProvider, InMemoryConfigProvider } from '../../config';
|
||||
import { ICompletionsFetcherService, Response } from '../../networking';
|
||||
import { ICompletionsOpenAIFetcherService, LiveOpenAIFetcher } from '../../openai/fetch';
|
||||
import { fakeAPIChoice, fakeAPIChoiceFromCompletion } from '../../openai/fetch.fake';
|
||||
import { APIChoice } from '../../openai/openai';
|
||||
import { extractPrompt, PromptResponsePresent, trimLastLine } from '../../prompt/prompt';
|
||||
import { getGhostTextInternal } from '../../prompt/test/prompt';
|
||||
import { TelemetryWithExp } from '../../telemetry';
|
||||
import { createLibTestingContext } from '../../test/context';
|
||||
import { createFakeCompletionResponse, fakeCodeReference, NoFetchFetcher, StaticFetcher } from '../../test/fetcher';
|
||||
import { withInMemoryTelemetry } from '../../test/telemetry';
|
||||
import { createTextDocument } from '../../test/textDocument';
|
||||
import { ITextDocument, LocationFactory } from '../../textDocument';
|
||||
import { Deferred } from '../../util/async';
|
||||
import { ICompletionsAsyncManagerService } from '../asyncCompletions';
|
||||
import { ICompletionsCacheService } from '../completionsCache';
|
||||
import { ICompletionsCurrentGhostText } from '../current';
|
||||
import { getGhostText, GetNetworkCompletionsType, GhostCompletion, ResultType } from '../ghostText';
|
||||
import { mkBasicResultTelemetry } from '../telemetry';
|
||||
|
||||
// Unit tests for ghostText that do not require network connectivity. For other
|
||||
// tests, see lib/e2e/src/ghostText.test.ts.
|
||||
|
||||
suite('Isolated GhostText tests', function () {
|
||||
function getPrefix(completionState: CompletionState): string {
|
||||
return trimLastLine(
|
||||
completionState.textDocument.getText(
|
||||
LocationFactory.range(LocationFactory.position(0, 0), completionState.position)
|
||||
)
|
||||
)[0];
|
||||
}
|
||||
|
||||
function setupCompletion(
|
||||
fetcher: ICompletionsFetcherService,
|
||||
docText = 'import "fmt"\n\nfunc fizzbuzz(n int) {\n\n}\n',
|
||||
position = LocationFactory.position(3, 0),
|
||||
languageId = 'go',
|
||||
token?: CancellationToken
|
||||
) {
|
||||
const serviceCollection = createLibTestingContext();
|
||||
serviceCollection.define(ICompletionsFetcherService, fetcher);
|
||||
serviceCollection.define(ICompletionsOpenAIFetcherService, new SyncDescriptor(LiveOpenAIFetcher)); // gets results from static fetcher
|
||||
const accessor = serviceCollection.createTestingAccessor();
|
||||
|
||||
const doc = createTextDocument('file:///fizzbuzz.go', languageId, 1, docText);
|
||||
const state = createCompletionState(doc, position);
|
||||
const prefix = getPrefix(state);
|
||||
|
||||
// Setup closures with the state as default
|
||||
function requestGhostText(completionState = state) {
|
||||
return getGhostText(accessor, completionState, token, {});
|
||||
}
|
||||
async function requestPrompt(completionState = state) {
|
||||
const telemExp = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
const result = await extractPrompt(accessor, 'COMPLETION_ID', completionState, telemExp);
|
||||
return (result as PromptResponsePresent).prompt;
|
||||
}
|
||||
|
||||
// Note, that we return a copy of the state to avoid side effects
|
||||
return {
|
||||
accessor,
|
||||
doc,
|
||||
position,
|
||||
prefix,
|
||||
state: createCompletionState(doc, position),
|
||||
requestGhostText,
|
||||
requestPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function addToCache(accessor: ServicesAccessor, prefix: string, suffix: string, completion: string | APIChoice) {
|
||||
let choice: APIChoice;
|
||||
if (typeof completion === 'string') {
|
||||
choice = fakeAPIChoiceFromCompletion(completion);
|
||||
} else {
|
||||
choice = completion;
|
||||
}
|
||||
const cache = accessor.get(ICompletionsCacheService);
|
||||
cache.append(prefix, suffix, choice);
|
||||
}
|
||||
|
||||
async function acceptAndRequestNextCompletion(
|
||||
accessor: ServicesAccessor,
|
||||
origDoc: ITextDocument,
|
||||
origPosition: Position,
|
||||
completion: GhostCompletion
|
||||
) {
|
||||
const doc = createTextDocument(
|
||||
origDoc.uri,
|
||||
origDoc.clientLanguageId,
|
||||
origDoc.version + 1,
|
||||
origDoc.getText(LocationFactory.range(LocationFactory.position(0, 0), origPosition)) +
|
||||
completion.completionText +
|
||||
origDoc.getText(LocationFactory.range(origPosition, origDoc.positionAt(origDoc.getText().length)))
|
||||
);
|
||||
const position = doc.positionAt(doc.offsetAt(origPosition) + completion.completionText.length);
|
||||
const result = await getGhostTextInternal(accessor, doc, position);
|
||||
return { doc, position, result };
|
||||
}
|
||||
|
||||
suiteSetup(async function () {
|
||||
await initializeTokenizers;
|
||||
});
|
||||
|
||||
test('returns annotations in the result', async function () {
|
||||
const { requestGhostText } = setupCompletion(
|
||||
new StaticFetcher(() =>
|
||||
createFakeCompletionResponse('\tfor i := 1; i<= n; i++ {\n', {
|
||||
annotations: fakeCodeReference(-18, 26, 'NOASSERTION', 'https://github.com/github/example'),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
assert.deepStrictEqual(responseWithTelemetry.value[0][0].copilotAnnotations?.ip_code_citations, [
|
||||
{
|
||||
id: 5,
|
||||
start_offset: -18,
|
||||
stop_offset: 26,
|
||||
details: { citations: [{ url: 'https://github.com/github/example', license: 'NOASSERTION' }] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns cached completion', async function () {
|
||||
const { accessor, requestGhostText, prefix, requestPrompt } = setupCompletion(new NoFetchFetcher());
|
||||
const completionText = '\tfor i := 1; i<= n; i++ {';
|
||||
const { suffix } = await requestPrompt();
|
||||
addToCache(accessor, prefix, suffix, completionText);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
assert.strictEqual(responseWithTelemetry.value[0][0].completion.completionText, completionText);
|
||||
assert.strictEqual(responseWithTelemetry.value[1], ResultType.Cache, 'result type should be cache');
|
||||
});
|
||||
|
||||
test('returns empty response when cached completion is filtered by post-processing', async function () {
|
||||
const completionText = '\tvar i int';
|
||||
const { accessor, requestGhostText, prefix, requestPrompt } = setupCompletion(
|
||||
new StaticFetcher(() => createFakeCompletionResponse(completionText))
|
||||
);
|
||||
const { suffix } = await requestPrompt();
|
||||
addToCache(accessor, prefix, suffix, '}'); // Completion matches next line of document
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'empty');
|
||||
assert.strictEqual(responseWithTelemetry.reason, 'cached results empty after post-processing');
|
||||
});
|
||||
|
||||
test('returns typing as suggested', async function () {
|
||||
const { accessor, requestGhostText, requestPrompt, prefix } = setupCompletion(new NoFetchFetcher());
|
||||
const { suffix } = await requestPrompt();
|
||||
addToCache(accessor, prefix, suffix, '\tfor i := 1; i<= n; i++ {');
|
||||
await requestGhostText();
|
||||
|
||||
const secondText = 'import "fmt"\n\nfunc fizzbuzz(n int) {\n\tfor\n}\n';
|
||||
const second = createCompletionState(
|
||||
createTextDocument('file:///fizzbuzz.go', 'go', 1, secondText),
|
||||
LocationFactory.position(3, 4)
|
||||
);
|
||||
const responseWithTelemetry = await requestGhostText(second);
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
assert.strictEqual(responseWithTelemetry.value[0][0].completion.completionText, ' i := 1; i<= n; i++ {');
|
||||
assert.strictEqual(
|
||||
responseWithTelemetry.value[1],
|
||||
ResultType.TypingAsSuggested,
|
||||
'result type should be typing as suggested'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns multiline typing as suggested when typing into single line context', async function () {
|
||||
const { accessor, requestGhostText, requestPrompt, prefix } = setupCompletion(new NoFetchFetcher());
|
||||
const currentGhostText = accessor.get(ICompletionsCurrentGhostText);
|
||||
currentGhostText.hasAcceptedCurrentCompletion = () => true;
|
||||
const { suffix } = await requestPrompt();
|
||||
const completionText = '\tfmt.Println("hi")\n\tfmt.Print("hello")';
|
||||
addToCache(accessor, prefix, suffix, completionText);
|
||||
const firstRes = await requestGhostText();
|
||||
assert.strictEqual(firstRes.type, 'success');
|
||||
assert.strictEqual(firstRes.value[0][0].completion.completionText, completionText);
|
||||
|
||||
// Request a second completion typing into a non-multiline context:
|
||||
// the addition of `\tfmt.` to the current line changes the completion
|
||||
// context (via the `isEmptyBlockStart` computed in prompt/) from
|
||||
// multiline to single line.
|
||||
const secondText = 'import "fmt"\n\nfunc fizzbuzz(n int) {\n\tfmt.\n}\n';
|
||||
const second = createCompletionState(
|
||||
createTextDocument('file:///fizzbuzz.go', 'go', 1, secondText),
|
||||
LocationFactory.position(3, 9)
|
||||
);
|
||||
const secondRes = await requestGhostText(second);
|
||||
|
||||
assert.strictEqual(secondRes.type, 'success');
|
||||
assert.strictEqual(secondRes.value[0][0].completion.completionText, 'Println("hi")\n\tfmt.Print("hello")');
|
||||
assert.strictEqual(secondRes.value[1], ResultType.TypingAsSuggested);
|
||||
});
|
||||
|
||||
test('trims multiline async completion into single line context', async function () {
|
||||
const { accessor, doc, position, requestGhostText, requestPrompt } = setupCompletion(new NoFetchFetcher());
|
||||
const asyncManager = accessor.get(ICompletionsAsyncManagerService);
|
||||
const prompt = await requestPrompt();
|
||||
const [prefix] = trimLastLine(doc.getText(LocationFactory.range(LocationFactory.position(0, 0), position)));
|
||||
const response = fakeResult('\tfmt.Println("hi")\n\tfmt.Print("hello")');
|
||||
void asyncManager.queueCompletionRequest('0', prefix, prompt, new CancellationTokenSource(), response);
|
||||
|
||||
// Request a single completion by typing into a non-multiline context:
|
||||
// the addition of `\tfmt.` to the current line changes the completion
|
||||
// context (via the `isEmptyBlockStart` computed in prompt/) from
|
||||
// multiline to single line.
|
||||
const secondText = 'import "fmt"\n\nfunc fizzbuzz(n int) {\n\tfmt.\n}\n';
|
||||
const second = createCompletionState(
|
||||
createTextDocument('file:///fizzbuzz.go', 'go', 1, secondText),
|
||||
LocationFactory.position(3, 9)
|
||||
);
|
||||
const secondRes = await requestGhostText(second);
|
||||
|
||||
assert.strictEqual(secondRes.type, 'success');
|
||||
assert.strictEqual(secondRes.value[0][0].completion.completionText, 'Println("hi")');
|
||||
assert.strictEqual(secondRes.value[1], ResultType.Async);
|
||||
});
|
||||
|
||||
test('returns cached single-line completion that starts with newline', async function () {
|
||||
const { accessor, requestGhostText, requestPrompt, prefix } = setupCompletion(
|
||||
new NoFetchFetcher(),
|
||||
'import "fmt"\n\nfunc fizzbuzz(n int) {\n\ti := 0\n}\n',
|
||||
LocationFactory.position(3, '\ti := 0'.length)
|
||||
);
|
||||
const { suffix } = await requestPrompt();
|
||||
const completionText = '\n\tj := 0';
|
||||
addToCache(accessor, prefix, suffix, completionText);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
assert.strictEqual(responseWithTelemetry.value[0][0].completion.completionText, completionText);
|
||||
assert.strictEqual(responseWithTelemetry.value[1], ResultType.Cache, 'result type should be cache');
|
||||
});
|
||||
|
||||
test('returns prefixed cached completion', async function () {
|
||||
const { accessor, requestGhostText, requestPrompt, prefix } = setupCompletion(new NoFetchFetcher());
|
||||
const { suffix } = await requestPrompt();
|
||||
const earlierPrefix = prefix.substring(0, prefix.length - 3);
|
||||
const remainingPrefix = prefix.substring(prefix.length - 3);
|
||||
const completionText = '\tfor i := 1; i<= n; i++ {';
|
||||
addToCache(accessor, earlierPrefix, suffix, remainingPrefix + completionText);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
assert.strictEqual(responseWithTelemetry.value[0][0].completion.completionText, completionText);
|
||||
assert.strictEqual(responseWithTelemetry.value[1], ResultType.Cache, 'result type should be cache');
|
||||
assert.strictEqual(responseWithTelemetry.telemetryBlob.measurements.foundOffset, 3);
|
||||
});
|
||||
|
||||
test('does not return cached completion when exhausted', async function () {
|
||||
const networkCompletionText = '\tfor i := 1; i<= n; i++ {';
|
||||
const { accessor, requestGhostText, requestPrompt, prefix } = setupCompletion(
|
||||
new StaticFetcher(() => {
|
||||
return createFakeCompletionResponse(networkCompletionText);
|
||||
})
|
||||
);
|
||||
const { suffix } = await requestPrompt();
|
||||
const earlierPrefix = prefix.substring(0, prefix.length - 3);
|
||||
const remainingPrefix = prefix.substring(prefix.length - 3);
|
||||
addToCache(accessor, earlierPrefix, suffix, remainingPrefix);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
assert.strictEqual(responseWithTelemetry.value[0][0].completion.completionText, networkCompletionText);
|
||||
assert.strictEqual(responseWithTelemetry.value[1], ResultType.Async, 'result type should be async');
|
||||
});
|
||||
|
||||
test('Multiline requests return multiple completions on second invocation', async function () {
|
||||
const firstCompletionText = '\tfirstVar := 1\n';
|
||||
const secondCompletionText = '\tfirstVar := 2\t';
|
||||
const completions = [firstCompletionText, secondCompletionText];
|
||||
let serverSentResponse = false;
|
||||
const { requestGhostText } = setupCompletion(
|
||||
new StaticFetcher((url, options) => {
|
||||
if (serverSentResponse) {
|
||||
throw new Error('Unexpected second request');
|
||||
}
|
||||
serverSentResponse = true;
|
||||
return createFakeCompletionResponse(completions);
|
||||
})
|
||||
);
|
||||
// Get the completion from the server, do the processing of the responses
|
||||
// this is a multiline request, so it'll request multiple completions, but whatever our cycling specification, it'll not _wait_ for those, c.f isCyclingRequest in getGhostTextStrategy.
|
||||
const firstResponse = await requestGhostText();
|
||||
assert.strictEqual(firstResponse.type, 'success');
|
||||
assert.strictEqual(firstResponse.value[0].length, 1);
|
||||
assert.strictEqual(firstResponse.value[0][0].completion.completionText, firstCompletionText.trimEnd());
|
||||
// therefore, request the same prompt again, this time with cycling specified, to get all completions from the cache
|
||||
const secondResponse = await requestGhostText();
|
||||
assert.strictEqual(secondResponse.type, 'success');
|
||||
// two completion results returned
|
||||
assert.strictEqual(secondResponse.value[0].length, 2);
|
||||
// the second one is the second completion, but with whitespace trimmed
|
||||
assert.strictEqual(secondResponse.value[0][0].completion.completionText, firstCompletionText.trimEnd());
|
||||
assert.strictEqual(secondResponse.value[0][1].completion.completionText, secondCompletionText.trimEnd());
|
||||
});
|
||||
|
||||
test('Responses with duplicate content (modulo whitespace) are deduplicated', async function () {
|
||||
const firstCompletionText = '\tfirstVar := 1\n';
|
||||
const secondCompletionText = '\tfirstVar := 1\t';
|
||||
const completions = [firstCompletionText, secondCompletionText];
|
||||
let serverSentResponse = false;
|
||||
const { requestGhostText } = setupCompletion(
|
||||
new StaticFetcher((url, options) => {
|
||||
if (serverSentResponse) {
|
||||
throw new Error('Unexpected second request');
|
||||
}
|
||||
serverSentResponse = true;
|
||||
return createFakeCompletionResponse(completions);
|
||||
})
|
||||
);
|
||||
// Get the completion from the server, do the processing of the responses
|
||||
// this is a multiline request, so it'll request multiple completions, but whatever our cycling specification, it'll not _wait_ for those, c.f isCyclingRequest in getGhostTextStrategy.
|
||||
const firstResponse = await requestGhostText();
|
||||
assert.strictEqual(firstResponse.type, 'success');
|
||||
assert.strictEqual(firstResponse.value[0].length, 1);
|
||||
assert.strictEqual(firstResponse.value[0][0].completion.completionText, firstCompletionText.trimEnd());
|
||||
// therefore, request the same prompt again, this time with cycling specified, to get all completions from the cache
|
||||
const secondResponse = await requestGhostText();
|
||||
assert.strictEqual(secondResponse.type, 'success');
|
||||
// still only one completion result returned
|
||||
assert.strictEqual(secondResponse.value[0].length, 1);
|
||||
assert.strictEqual(secondResponse.value[0][0].completion.completionText, firstCompletionText.trimEnd());
|
||||
});
|
||||
|
||||
test('adds prompt metadata to telemetry', async function () {
|
||||
const networkCompletionText = '\tfor i := 1; i<= n; i++ {';
|
||||
const { accessor, requestGhostText } = setupCompletion(
|
||||
new StaticFetcher(() => {
|
||||
return createFakeCompletionResponse(networkCompletionText);
|
||||
})
|
||||
);
|
||||
|
||||
const { result, reporter } = await withInMemoryTelemetry(accessor, async () => {
|
||||
return await requestGhostText();
|
||||
});
|
||||
|
||||
// The returned object (used for all other telemetry events) does not have the prompt metadata
|
||||
assert.deepStrictEqual(result.type, 'success');
|
||||
assert.ok(!result.telemetryBlob.properties.promptMetadata);
|
||||
|
||||
// Only the issued event has it
|
||||
const issuedTelemetry = reporter.eventByName('ghostText.issued');
|
||||
assert.ok(issuedTelemetry.properties.promptMetadata);
|
||||
|
||||
// Double check that the other events don't have it
|
||||
const events = reporter.events.filter(e => e.name !== 'ghostText.issued');
|
||||
assert.ok(events.length > 0);
|
||||
for (const event of events) {
|
||||
assert.ok(!event.properties.promptMetadata);
|
||||
}
|
||||
});
|
||||
|
||||
test('cache hits use issuedTime in telemetry from current request, not cache', async function () {
|
||||
const { accessor, requestGhostText, requestPrompt, prefix } = setupCompletion(new NoFetchFetcher());
|
||||
const { suffix } = await requestPrompt();
|
||||
const completionText = '\tfor i := 1; i<= n; i++ {';
|
||||
const choice = fakeAPIChoiceFromCompletion(completionText);
|
||||
choice.telemetryData.issuedTime -= 100;
|
||||
addToCache(accessor, prefix, suffix, completionText);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(
|
||||
responseWithTelemetry.value[0][0].telemetry.issuedTime,
|
||||
responseWithTelemetry.telemetryBlob.issuedTime
|
||||
);
|
||||
});
|
||||
|
||||
test('sends ghostText.issued telemetry event', async function () {
|
||||
const networkCompletionText = '\tfor i := 1; i<= n; i++ {';
|
||||
const { accessor, requestGhostText } = setupCompletion(
|
||||
new StaticFetcher(() => {
|
||||
return createFakeCompletionResponse(networkCompletionText);
|
||||
})
|
||||
);
|
||||
|
||||
const { result, reporter } = await withInMemoryTelemetry(accessor, async () => {
|
||||
return await requestGhostText();
|
||||
});
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
const issuedTelemetry = reporter.eventByName('ghostText.issued');
|
||||
[
|
||||
'languageId',
|
||||
'beforeCursorWhitespace',
|
||||
'afterCursorWhitespace',
|
||||
'neighborSource',
|
||||
'gitRepoInformation',
|
||||
'engineName',
|
||||
'isMultiline',
|
||||
'blockMode',
|
||||
'isCycling',
|
||||
].forEach(prop => {
|
||||
assert.strictEqual(
|
||||
typeof issuedTelemetry.properties[prop],
|
||||
'string',
|
||||
`Expected telemetry property ${prop}`
|
||||
);
|
||||
});
|
||||
[
|
||||
'promptCharLen',
|
||||
'promptSuffixCharLen',
|
||||
'promptEndPos',
|
||||
'documentLength',
|
||||
'documentLineCount',
|
||||
'promptComputeTimeMs',
|
||||
].forEach(prop => {
|
||||
assert.strictEqual(
|
||||
typeof issuedTelemetry.measurements[prop],
|
||||
'number',
|
||||
`Expected telemetry measurement ${prop}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('excludes ghostText.issued-specific propeties in returned telemetry', async function () {
|
||||
const networkCompletionText = '\tfor i := 1; i<= n; i++ {';
|
||||
const { requestGhostText } = setupCompletion(
|
||||
new StaticFetcher(() => {
|
||||
return createFakeCompletionResponse(networkCompletionText);
|
||||
})
|
||||
);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
[
|
||||
'beforeCursorWhitespace',
|
||||
'afterCursorWhitespace',
|
||||
'promptChoices',
|
||||
'promptBackground',
|
||||
'neighborSource',
|
||||
'blockMode',
|
||||
].forEach(prop => {
|
||||
assert.strictEqual(
|
||||
responseWithTelemetry.value[0][0].telemetry.properties[prop],
|
||||
undefined,
|
||||
`Did not expect telemetry property ${prop}`
|
||||
);
|
||||
assert.strictEqual(
|
||||
responseWithTelemetry.telemetryBlob.properties[prop],
|
||||
undefined,
|
||||
`Did not expect telemetry property ${prop}`
|
||||
);
|
||||
});
|
||||
['promptCharLen', 'promptSuffixCharLen', 'promptCharLen', 'promptEndPos', 'promptComputeTimeMs'].forEach(
|
||||
prop => {
|
||||
assert.strictEqual(
|
||||
responseWithTelemetry.value[0][0].telemetry.measurements[prop],
|
||||
undefined,
|
||||
`Did not expect telemetry measurement ${prop}`
|
||||
);
|
||||
assert.strictEqual(
|
||||
responseWithTelemetry.telemetryBlob.measurements[prop],
|
||||
undefined,
|
||||
`Did not expect telemetry measurement ${prop}`
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('includes document information in returned telemetry', async function () {
|
||||
const networkCompletionText = '\tfor i := 1; i<= n; i++ {';
|
||||
const { requestGhostText } = setupCompletion(
|
||||
new StaticFetcher(() => {
|
||||
return createFakeCompletionResponse(networkCompletionText);
|
||||
})
|
||||
);
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
['languageId', 'gitRepoInformation', 'engineName', 'isMultiline', 'isCycling'].forEach(prop => {
|
||||
assert.strictEqual(
|
||||
typeof responseWithTelemetry.value[0][0].telemetry.properties[prop],
|
||||
'string',
|
||||
`Expected telemetry property ${prop}`
|
||||
);
|
||||
assert.strictEqual(
|
||||
typeof responseWithTelemetry.telemetryBlob.properties[prop],
|
||||
'string',
|
||||
`Expected telemetry property ${prop}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('updates transient document information in telemetry of cached choices', async function () {
|
||||
const { accessor, requestGhostText, requestPrompt, prefix } = setupCompletion(new NoFetchFetcher());
|
||||
const { suffix } = await requestPrompt();
|
||||
const completionText = '\tfor i := 1; i<= n; i++ {';
|
||||
addToCache(accessor, prefix, suffix, completionText);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
['documentLength', 'documentLineCount'].forEach(prop => {
|
||||
assert.strictEqual(
|
||||
typeof responseWithTelemetry.telemetryBlob.measurements[prop],
|
||||
'number',
|
||||
`Expected telemetry measurement ${prop}`
|
||||
);
|
||||
assert.strictEqual(
|
||||
responseWithTelemetry.value[0][0].telemetry.measurements[prop],
|
||||
responseWithTelemetry.telemetryBlob.measurements[prop],
|
||||
`Expected telemetry measurement ${prop} to be ${responseWithTelemetry.telemetryBlob.measurements[prop]}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('cancels if token is canceled', async function () {
|
||||
const tokenSource = new CancellationTokenSource();
|
||||
const deferredResponse = new Deferred<Response>();
|
||||
const { requestGhostText } = setupCompletion(
|
||||
new StaticFetcher(() => deferredResponse.promise),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
tokenSource.token
|
||||
);
|
||||
|
||||
const requestPromise = requestGhostText();
|
||||
tokenSource.cancel();
|
||||
deferredResponse.resolve(createFakeCompletionResponse('var i int'));
|
||||
const result = await requestPromise;
|
||||
|
||||
assert.strictEqual(result.type, 'abortedBeforeIssued');
|
||||
assert.strictEqual(result.reason, 'cancelled before extractPrompt');
|
||||
});
|
||||
|
||||
test('cancels if a newer completion request is made', async function () {
|
||||
const firstResponseDeferred = new Deferred<Response>();
|
||||
const secondResponseDeferred = new Deferred<Response>();
|
||||
const deferreds = [firstResponseDeferred, secondResponseDeferred];
|
||||
const { requestGhostText } = setupCompletion(new StaticFetcher(() => deferreds.shift()!.promise));
|
||||
|
||||
const firstResponsePromise = requestGhostText();
|
||||
const secondResponsePromise = requestGhostText();
|
||||
firstResponseDeferred.resolve(createFakeCompletionResponse('var i int'));
|
||||
secondResponseDeferred.resolve(createFakeCompletionResponse('var j int'));
|
||||
const firstResponse = await firstResponsePromise;
|
||||
const secondResponse = await secondResponsePromise;
|
||||
|
||||
assert.strictEqual(firstResponse.type, 'abortedBeforeIssued');
|
||||
assert.strictEqual(firstResponse.reason, 'cancelled before extractPrompt');
|
||||
assert.strictEqual(secondResponse.type, 'success');
|
||||
});
|
||||
|
||||
test('can close an unclosed brace (when using progressive reveal)', async function () {
|
||||
const { accessor, requestGhostText } = setupCompletion(
|
||||
new StaticFetcher(() => createFakeCompletionResponse(' }\n')),
|
||||
dedent`
|
||||
function hello(n: number) {
|
||||
for (let i = 1; i<= n; i++) {
|
||||
console.log("hello")
|
||||
|
||||
}
|
||||
`,
|
||||
LocationFactory.position(3, 0),
|
||||
'typescript'
|
||||
);
|
||||
const configProvider = accessor.get(ICompletionsConfigProvider) as InMemoryConfigProvider;
|
||||
configProvider.setConfig(ConfigKey.AlwaysRequestMultiline, true);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 1);
|
||||
assert.strictEqual(responseWithTelemetry.value[0][0].completion.completionText, ' }');
|
||||
});
|
||||
|
||||
test('filters out a duplicate brace (when using progressive reveal)', async function () {
|
||||
const { accessor, requestGhostText } = setupCompletion(
|
||||
new StaticFetcher(() => createFakeCompletionResponse('}\n')),
|
||||
dedent`
|
||||
function hello(n: number) {
|
||||
for (let i = 1; i<= n; i++) {
|
||||
console.log("hello")
|
||||
}
|
||||
|
||||
}
|
||||
`,
|
||||
LocationFactory.position(4, 0),
|
||||
'typescript'
|
||||
);
|
||||
const configProvider = accessor.get(ICompletionsConfigProvider) as InMemoryConfigProvider;
|
||||
configProvider.setConfig(ConfigKey.AlwaysRequestMultiline, true);
|
||||
|
||||
const responseWithTelemetry = await requestGhostText();
|
||||
|
||||
assert.strictEqual(responseWithTelemetry.type, 'success');
|
||||
assert.strictEqual(responseWithTelemetry.value[0].length, 0);
|
||||
});
|
||||
|
||||
test('progressive reveal uses a speculative request for multiline completions and caches further completions', async function () {
|
||||
const raw = dedent`
|
||||
switch {
|
||||
case n%3 == 0:
|
||||
output += "Fizz"
|
||||
fallthrough
|
||||
case n%5 == 0:
|
||||
output += "Buzz"
|
||||
default:
|
||||
output = fmt.Sprintf("%d", n)
|
||||
}
|
||||
fmt.Println(output)
|
||||
`;
|
||||
const lines = raw.split('\n').map(line => ` ${line}`);
|
||||
const multilineCompletion = lines.join('\n');
|
||||
const { accessor, doc, position, state } = setupCompletion(
|
||||
new StaticFetcher(() => createFakeCompletionResponse(multilineCompletion))
|
||||
);
|
||||
const configProvider = accessor.get(ICompletionsConfigProvider) as InMemoryConfigProvider;
|
||||
const currentGhostText = accessor.get(ICompletionsCurrentGhostText);
|
||||
configProvider.setConfig(ConfigKey.AlwaysRequestMultiline, true);
|
||||
currentGhostText.hasAcceptedCurrentCompletion = () => true;
|
||||
|
||||
const response = await getGhostText(accessor, state, undefined, { isSpeculative: true });
|
||||
|
||||
assert.strictEqual(response.type, 'success');
|
||||
assert.strictEqual(response.value[0].length, 1);
|
||||
assert.strictEqual(response.value[0][0].completion.completionText, lines.slice(0, 9).join('\n'));
|
||||
|
||||
const { result } = await acceptAndRequestNextCompletion(accessor, doc, position, response.value[0][0].completion);
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
assert.strictEqual(result.value[0].length, 1);
|
||||
assert.strictEqual(result.value[0][0].completion.completionText, '\n' + lines.slice(9).join('\n'));
|
||||
assert.strictEqual(result.resultType, ResultType.Cache);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeResult(completionText: string): Promise<GetNetworkCompletionsType> {
|
||||
const telemetryBlob = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
return Promise.resolve({
|
||||
type: 'success',
|
||||
value: [fakeAPIChoice(generateUuid(), 0, completionText), Promise.resolve()],
|
||||
telemetryData: mkBasicResultTelemetry(telemetryBlob),
|
||||
telemetryBlob,
|
||||
resultType: ResultType.Async,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import assert from 'assert';
|
||||
import { ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { TelemetryWithExp } from '../../telemetry';
|
||||
import { createLibTestingContext } from '../../test/context';
|
||||
import { withInMemoryTelemetry } from '../../test/telemetry';
|
||||
import { createTextDocument } from '../../test/textDocument';
|
||||
import { CopilotCompletion } from '../copilotCompletion';
|
||||
import { ResultType } from '../ghostText';
|
||||
import {
|
||||
ICompletionsLastGhostText, handleGhostTextPostInsert,
|
||||
handleGhostTextShown,
|
||||
handlePartialGhostTextPostInsert,
|
||||
rejectLastShown,
|
||||
setLastShown
|
||||
} from '../last';
|
||||
|
||||
suite('Isolated LastGhostText tests', function () {
|
||||
let accessor: ServicesAccessor;
|
||||
let last: ICompletionsLastGhostText;
|
||||
setup(function () {
|
||||
accessor = createLibTestingContext().createTestingAccessor();
|
||||
last = accessor.get(ICompletionsLastGhostText);
|
||||
});
|
||||
|
||||
function makeCompletion(index = 0, text = 'foo', offset = 0): CopilotCompletion {
|
||||
return {
|
||||
uuid: 'uuid-' + index,
|
||||
insertText: text,
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: text.length } },
|
||||
index,
|
||||
displayText: text,
|
||||
offset,
|
||||
uri: 'file:///test',
|
||||
position: { line: 0, character: 0 },
|
||||
telemetry: TelemetryWithExp.createEmptyConfigForTesting(),
|
||||
resultType: ResultType.Network,
|
||||
} as CopilotCompletion;
|
||||
}
|
||||
|
||||
test('full completion flow: show, accept, reset', function () {
|
||||
last.setState({ uri: 'file:///test' }, { line: 0, character: 0 });
|
||||
const cmp = makeCompletion(1, 'full completion', 0);
|
||||
handleGhostTextShown(accessor, cmp);
|
||||
assert.strictEqual(last.shownCompletions.length, 1);
|
||||
handleGhostTextPostInsert(accessor, cmp);
|
||||
assert.strictEqual(last.shownCompletions.length, 0);
|
||||
assert.strictEqual(last.position, undefined);
|
||||
assert.strictEqual(last.uri, undefined);
|
||||
});
|
||||
|
||||
test('partial completion flow: show, partial accept, state', function () {
|
||||
last.setState({ uri: 'file:///test' }, { line: 0, character: 0 });
|
||||
const cmp = makeCompletion(2, 'partial completion', 0);
|
||||
handleGhostTextShown(accessor, cmp);
|
||||
assert.strictEqual(last.shownCompletions.length, 1);
|
||||
handlePartialGhostTextPostInsert(accessor, cmp, 7); // accept first 7 chars
|
||||
assert.strictEqual(last.partiallyAcceptedLength, 7);
|
||||
// State is not reset by partial accept
|
||||
assert.strictEqual(last.shownCompletions.length, 1);
|
||||
});
|
||||
|
||||
test('reject after show clears completions', function () {
|
||||
last.setState({ uri: 'file:///test' }, { line: 0, character: 0 });
|
||||
const cmp = makeCompletion(3, 'reject me', 0);
|
||||
handleGhostTextShown(accessor, cmp);
|
||||
assert.strictEqual(last.shownCompletions.length, 1);
|
||||
rejectLastShown(accessor, 0);
|
||||
assert.strictEqual(last.shownCompletions.length, 0);
|
||||
});
|
||||
|
||||
test('setLastShown resets completions if position/uri changes', function () {
|
||||
last.setState({ uri: 'file:///test' }, { line: 0, character: 0 });
|
||||
last.shownCompletions.push(makeCompletion(4, 'baz', 0));
|
||||
const doc = createTextDocument('file:///other', 'plaintext', 1, '');
|
||||
setLastShown(accessor, doc, { line: 1, character: 1 }, ResultType.Network);
|
||||
assert.strictEqual(last.shownCompletions.length, 0);
|
||||
});
|
||||
|
||||
test('full acceptance sends total number of lines with telemetry', async function () {
|
||||
last.setState({ uri: 'file:///test' }, { line: 0, character: 0 });
|
||||
const cmp = makeCompletion(0, 'line1\nline2\nline3', 0);
|
||||
handleGhostTextShown(accessor, cmp);
|
||||
|
||||
const { reporter } = await withInMemoryTelemetry(accessor, () => {
|
||||
handleGhostTextPostInsert(accessor, cmp);
|
||||
});
|
||||
|
||||
const event = reporter.events.find(e => e.name === 'ghostText.accepted');
|
||||
assert.ok(event);
|
||||
assert.strictEqual(event.measurements.numLines, 3);
|
||||
});
|
||||
|
||||
test('partial acceptance for VS Code sends total number of lines accepted with telemetry', async function () {
|
||||
last.setState({ uri: 'file:///test' }, { line: 0, character: 0 });
|
||||
const cmp = makeCompletion(0, 'line1\nline2\nline3', 0);
|
||||
handleGhostTextShown(accessor, cmp);
|
||||
|
||||
const { reporter } = await withInMemoryTelemetry(accessor, () => {
|
||||
handlePartialGhostTextPostInsert(accessor, cmp, 'line1'.length);
|
||||
});
|
||||
|
||||
const event = reporter.events.find(e => e.name === 'ghostText.accepted');
|
||||
assert.ok(event);
|
||||
assert.strictEqual(event.measurements.numLines, 1);
|
||||
});
|
||||
|
||||
test('additional partial acceptance for VS Code sends total number of lines accepted with telemetry', async function () {
|
||||
last.setState({ uri: 'file:///test' }, { line: 0, character: 0 });
|
||||
const cmp = makeCompletion(0, 'line1\nline2\nline3', 0);
|
||||
handleGhostTextShown(accessor, cmp);
|
||||
handlePartialGhostTextPostInsert(accessor, cmp, 'line1'.length);
|
||||
cmp.displayText = 'line2\nline3'; // Simulate the display text being updated after accepting the first line
|
||||
|
||||
const { reporter } = await withInMemoryTelemetry(accessor, () => {
|
||||
handlePartialGhostTextPostInsert(accessor, cmp, 'line2'.length);
|
||||
});
|
||||
|
||||
const event = reporter.events.reverse().find(e => e.name === 'ghostText.accepted');
|
||||
assert.ok(event);
|
||||
assert.strictEqual(event.measurements.numLines, 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { MultilineModelFeatures, PromptFeatures, hasComment, requestMultilineScore } from '../multilineModel';
|
||||
|
||||
suite('multilineModel tests', function () {
|
||||
this.timeout(10000);
|
||||
|
||||
test('hasComment correctly identifies presence of comment for a given string, line, and language', function () {
|
||||
const testCases = [
|
||||
{
|
||||
string: 'def test_fn(x):\n # Print x\n print(x)',
|
||||
language: 'python',
|
||||
lineNumber: 0,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
string: 'def test_fn(x):\n # Print x\n print(x)',
|
||||
language: 'python',
|
||||
lineNumber: 1,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
string: 'def test_fn(x):\n # Print x\n print(x)',
|
||||
language: 'python',
|
||||
lineNumber: -2,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
string: '',
|
||||
language: 'python',
|
||||
lineNumber: 0,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
string: '// Comment\nconst x = 1;',
|
||||
language: 'javascript',
|
||||
lineNumber: 0,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
string: '// Comment\nconst x = 1;',
|
||||
language: 'javascript',
|
||||
lineNumber: 1,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
string: '// Comment\nconst x = 1;',
|
||||
language: 'javascript',
|
||||
lineNumber: 2,
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const { string, language, lineNumber, expected } = testCase;
|
||||
assert.strictEqual(hasComment(string, lineNumber, language), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('PromptFeatures correctly parses prompt text', function () {
|
||||
const testCases = [
|
||||
{
|
||||
string: 'def test_fn(x):\n # Print x\n print(x)',
|
||||
language: 'python',
|
||||
length: 42,
|
||||
firstLineLength: 15,
|
||||
lastLineLength: 12,
|
||||
lastLineRstripLength: 12,
|
||||
lastLineStripLength: 8,
|
||||
rstripLength: 42,
|
||||
stripLength: 42,
|
||||
rstripLastLineLength: 12,
|
||||
rstripLastLineStripLength: 8,
|
||||
secondToLastLineHasComment: true,
|
||||
rstripSecondToLastLineHasComment: true,
|
||||
prefixEndsWithNewline: false,
|
||||
lastChar: ')',
|
||||
rstripLastChar: ')',
|
||||
firstChar: 'd',
|
||||
lstripFirstChar: 'd',
|
||||
},
|
||||
{
|
||||
string: ' ',
|
||||
language: 'python',
|
||||
length: 1,
|
||||
firstLineLength: 1,
|
||||
lastLineLength: 1,
|
||||
lastLineRstripLength: 0,
|
||||
lastLineStripLength: 0,
|
||||
rstripLength: 0,
|
||||
stripLength: 0,
|
||||
rstripLastLineLength: 0,
|
||||
rstripLastLineStripLength: 0,
|
||||
secondToLastLineHasComment: false,
|
||||
rstripSecondToLastLineHasComment: false,
|
||||
prefixEndsWithNewline: false,
|
||||
lastChar: ' ',
|
||||
rstripLastChar: '',
|
||||
firstChar: ' ',
|
||||
lstripFirstChar: '',
|
||||
},
|
||||
{
|
||||
string: '// Comment\nconst x = 1;\n',
|
||||
language: 'javascript',
|
||||
length: 24,
|
||||
firstLineLength: 10,
|
||||
lastLineLength: 12,
|
||||
lastLineRstripLength: 12,
|
||||
lastLineStripLength: 12,
|
||||
rstripLength: 23,
|
||||
stripLength: 23,
|
||||
rstripLastLineLength: 12,
|
||||
rstripLastLineStripLength: 12,
|
||||
secondToLastLineHasComment: true,
|
||||
rstripSecondToLastLineHasComment: true,
|
||||
prefixEndsWithNewline: true,
|
||||
lastChar: '\n',
|
||||
rstripLastChar: ';',
|
||||
firstChar: '/',
|
||||
lstripFirstChar: '/',
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const {
|
||||
string,
|
||||
language,
|
||||
length,
|
||||
firstLineLength,
|
||||
lastLineLength,
|
||||
lastLineRstripLength,
|
||||
lastLineStripLength,
|
||||
rstripLength,
|
||||
stripLength,
|
||||
rstripLastLineLength,
|
||||
rstripLastLineStripLength,
|
||||
secondToLastLineHasComment,
|
||||
rstripSecondToLastLineHasComment,
|
||||
prefixEndsWithNewline,
|
||||
lastChar,
|
||||
rstripLastChar,
|
||||
firstChar,
|
||||
lstripFirstChar,
|
||||
} = testCase;
|
||||
const promptFeatures = new PromptFeatures(string, language);
|
||||
assert.strictEqual(promptFeatures.length, length);
|
||||
assert.strictEqual(promptFeatures.firstLineLength, firstLineLength);
|
||||
assert.strictEqual(promptFeatures.lastLineLength, lastLineLength);
|
||||
assert.strictEqual(promptFeatures.lastLineRstripLength, lastLineRstripLength);
|
||||
assert.strictEqual(promptFeatures.lastLineStripLength, lastLineStripLength);
|
||||
assert.strictEqual(promptFeatures.rstripLength, rstripLength);
|
||||
assert.strictEqual(promptFeatures.stripLength, stripLength);
|
||||
assert.strictEqual(promptFeatures.rstripLastLineLength, rstripLastLineLength);
|
||||
assert.strictEqual(promptFeatures.rstripLastLineStripLength, rstripLastLineStripLength);
|
||||
assert.strictEqual(promptFeatures.secondToLastLineHasComment, secondToLastLineHasComment);
|
||||
assert.strictEqual(promptFeatures.rstripSecondToLastLineHasComment, rstripSecondToLastLineHasComment);
|
||||
assert.strictEqual(promptFeatures.prefixEndsWithNewline, prefixEndsWithNewline);
|
||||
assert.strictEqual(promptFeatures.lastChar, lastChar);
|
||||
assert.strictEqual(promptFeatures.rstripLastChar, rstripLastChar);
|
||||
assert.strictEqual(promptFeatures.firstChar, firstChar);
|
||||
assert.strictEqual(promptFeatures.lstripFirstChar, lstripFirstChar);
|
||||
}
|
||||
});
|
||||
|
||||
test('MultilineModelFeatures has expected prefix and suffix features', function () {
|
||||
const prefix = 'def test_fn(x):\n # Print x\n print(x)';
|
||||
const suffix = ' ';
|
||||
const language = 'python';
|
||||
const prefixFeatures = {
|
||||
string: 'def test_fn(x):\n # Print x\n print(x)',
|
||||
language: 'python',
|
||||
length: 42,
|
||||
firstLineLength: 15,
|
||||
lastLineLength: 12,
|
||||
lastLineRstripLength: 12,
|
||||
lastLineStripLength: 8,
|
||||
rstripLength: 42,
|
||||
stripLength: 42,
|
||||
rstripLastLineLength: 12,
|
||||
rstripLastLineStripLength: 8,
|
||||
secondToLastLineHasComment: true,
|
||||
rstripSecondToLastLineHasComment: true,
|
||||
prefixEndsWithNewline: false,
|
||||
lastChar: ')',
|
||||
rstripLastChar: ')',
|
||||
firstChar: 'd',
|
||||
lstripFirstChar: 'd',
|
||||
};
|
||||
const suffixFeatures = {
|
||||
string: ' ',
|
||||
language: 'python',
|
||||
length: 1,
|
||||
firstLineLength: 1,
|
||||
lastLineLength: 1,
|
||||
lastLineRstripLength: 0,
|
||||
lastLineStripLength: 0,
|
||||
rstripLength: 0,
|
||||
stripLength: 0,
|
||||
rstripLastLineLength: 0,
|
||||
rstripLastLineStripLength: 0,
|
||||
secondToLastLineHasComment: false,
|
||||
rstripSecondToLastLineHasComment: false,
|
||||
prefixEndsWithNewline: false,
|
||||
lastChar: ' ',
|
||||
rstripLastChar: '',
|
||||
firstChar: ' ',
|
||||
lstripFirstChar: '',
|
||||
};
|
||||
const multilineFeatures = new MultilineModelFeatures(prefix, suffix, language);
|
||||
assert.strictEqual(multilineFeatures.language, language);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.firstLineLength, prefixFeatures.firstLineLength);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.lastLineLength, prefixFeatures.lastLineLength);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.lastLineRstripLength, prefixFeatures.lastLineRstripLength);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.lastLineStripLength, prefixFeatures.lastLineStripLength);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.rstripLength, prefixFeatures.rstripLength);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.stripLength, prefixFeatures.stripLength);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.rstripLastLineLength, prefixFeatures.rstripLastLineLength);
|
||||
assert.strictEqual(
|
||||
multilineFeatures.prefixFeatures.rstripLastLineStripLength,
|
||||
prefixFeatures.rstripLastLineStripLength
|
||||
);
|
||||
assert.strictEqual(
|
||||
multilineFeatures.prefixFeatures.secondToLastLineHasComment,
|
||||
prefixFeatures.secondToLastLineHasComment
|
||||
);
|
||||
assert.strictEqual(
|
||||
multilineFeatures.prefixFeatures.rstripSecondToLastLineHasComment,
|
||||
prefixFeatures.rstripSecondToLastLineHasComment
|
||||
);
|
||||
assert.strictEqual(
|
||||
multilineFeatures.prefixFeatures.prefixEndsWithNewline,
|
||||
prefixFeatures.prefixEndsWithNewline
|
||||
);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.lastChar, prefixFeatures.lastChar);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.rstripLastChar, prefixFeatures.rstripLastChar);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.firstChar, prefixFeatures.firstChar);
|
||||
assert.strictEqual(multilineFeatures.prefixFeatures.lstripFirstChar, prefixFeatures.lstripFirstChar);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.firstLineLength, suffixFeatures.firstLineLength);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.lastLineLength, suffixFeatures.lastLineLength);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.lastLineRstripLength, suffixFeatures.lastLineRstripLength);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.lastLineStripLength, suffixFeatures.lastLineStripLength);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.rstripLength, suffixFeatures.rstripLength);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.stripLength, suffixFeatures.stripLength);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.rstripLastLineLength, suffixFeatures.rstripLastLineLength);
|
||||
assert.strictEqual(
|
||||
multilineFeatures.suffixFeatures.rstripLastLineStripLength,
|
||||
suffixFeatures.rstripLastLineStripLength
|
||||
);
|
||||
assert.strictEqual(
|
||||
multilineFeatures.suffixFeatures.secondToLastLineHasComment,
|
||||
suffixFeatures.secondToLastLineHasComment
|
||||
);
|
||||
assert.strictEqual(
|
||||
multilineFeatures.suffixFeatures.rstripSecondToLastLineHasComment,
|
||||
suffixFeatures.rstripSecondToLastLineHasComment
|
||||
);
|
||||
assert.strictEqual(
|
||||
multilineFeatures.suffixFeatures.prefixEndsWithNewline,
|
||||
suffixFeatures.prefixEndsWithNewline
|
||||
);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.lastChar, suffixFeatures.lastChar);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.rstripLastChar, suffixFeatures.rstripLastChar);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.firstChar, suffixFeatures.firstChar);
|
||||
assert.strictEqual(multilineFeatures.suffixFeatures.lstripFirstChar, suffixFeatures.lstripFirstChar);
|
||||
});
|
||||
|
||||
test('MultilineModelFeatures.constructFeatures() returns correct feature array', function () {
|
||||
const prefix = 'def test_fn(x):\n # Print x\n print(x)';
|
||||
const suffix = ' ';
|
||||
const language = 'python';
|
||||
const prefixFeatures = {
|
||||
string: 'def test_fn(x):\n # Print x\n print(x)',
|
||||
language: 'python',
|
||||
length: 42,
|
||||
firstLineLength: 15,
|
||||
lastLineLength: 12,
|
||||
lastLineRstripLength: 12,
|
||||
lastLineStripLength: 8,
|
||||
rstripLength: 42,
|
||||
stripLength: 42,
|
||||
rstripLastLineLength: 12,
|
||||
rstripLastLineStripLength: 8,
|
||||
secondToLastLineHasComment: true,
|
||||
rstripSecondToLastLineHasComment: true,
|
||||
prefixEndsWithNewline: false,
|
||||
lastChar: ')',
|
||||
rstripLastChar: ')',
|
||||
firstChar: 'd',
|
||||
lstripFirstChar: 'd',
|
||||
};
|
||||
const suffixFeatures = {
|
||||
string: ' ',
|
||||
language: 'python',
|
||||
length: 1,
|
||||
firstLineLength: 1,
|
||||
lastLineLength: 1,
|
||||
lastLineRstripLength: 0,
|
||||
lastLineStripLength: 0,
|
||||
rstripLength: 0,
|
||||
stripLength: 0,
|
||||
rstripLastLineLength: 0,
|
||||
rstripLastLineStripLength: 0,
|
||||
secondToLastLineHasComment: false,
|
||||
rstripSecondToLastLineHasComment: false,
|
||||
prefixEndsWithNewline: false,
|
||||
lastChar: ' ',
|
||||
rstripLastChar: '',
|
||||
firstChar: ' ',
|
||||
lstripFirstChar: '',
|
||||
};
|
||||
const expectedNumericFeatures = [
|
||||
prefixFeatures.length,
|
||||
prefixFeatures.firstLineLength,
|
||||
prefixFeatures.lastLineLength,
|
||||
prefixFeatures.lastLineRstripLength,
|
||||
prefixFeatures.lastLineStripLength,
|
||||
prefixFeatures.rstripLength,
|
||||
prefixFeatures.rstripLastLineLength,
|
||||
prefixFeatures.rstripLastLineStripLength,
|
||||
suffixFeatures.length,
|
||||
suffixFeatures.firstLineLength,
|
||||
suffixFeatures.lastLineLength,
|
||||
prefixFeatures.secondToLastLineHasComment ? 1 : 0,
|
||||
prefixFeatures.rstripSecondToLastLineHasComment ? 1 : 0,
|
||||
prefixFeatures.prefixEndsWithNewline ? 1 : 0,
|
||||
];
|
||||
const expectedLangFeatures: number[] = new Array<number>(8).fill(0);
|
||||
expectedLangFeatures[5] = 1;
|
||||
const expectedPrefixLastCharFeatures: number[] = new Array<number>(96).fill(0);
|
||||
expectedPrefixLastCharFeatures[10] = 1;
|
||||
const expectedPrefiRstripLastCharFeatures: number[] = new Array<number>(96).fill(0);
|
||||
expectedPrefiRstripLastCharFeatures[10] = 1;
|
||||
const expectedSuffixFirstCharFeatures: number[] = new Array<number>(96).fill(0);
|
||||
expectedSuffixFirstCharFeatures[1] = 1;
|
||||
const expectedSuffixLstripFirstCharFeatures: number[] = new Array<number>(96).fill(0);
|
||||
expectedSuffixLstripFirstCharFeatures[0] = 1;
|
||||
|
||||
const multilineFeatures = new MultilineModelFeatures(prefix, suffix, language);
|
||||
const multilineFeatureArray = multilineFeatures.constructFeatures();
|
||||
// Numeric features match
|
||||
assert.deepStrictEqual(multilineFeatureArray.slice(0, expectedNumericFeatures.length), expectedNumericFeatures);
|
||||
// Language features match
|
||||
assert.deepStrictEqual(
|
||||
multilineFeatureArray.slice(expectedNumericFeatures.length, expectedNumericFeatures.length + 8),
|
||||
expectedLangFeatures
|
||||
);
|
||||
// Prefix last char features match
|
||||
assert.deepStrictEqual(
|
||||
multilineFeatureArray.slice(expectedNumericFeatures.length + 8, expectedNumericFeatures.length + 8 + 96),
|
||||
expectedPrefixLastCharFeatures
|
||||
);
|
||||
// Prefix rstrip last char features match
|
||||
assert.deepStrictEqual(
|
||||
multilineFeatureArray.slice(
|
||||
expectedNumericFeatures.length + 8 + 96,
|
||||
expectedNumericFeatures.length + 8 + 96 * 2
|
||||
),
|
||||
expectedPrefiRstripLastCharFeatures
|
||||
);
|
||||
// Suffix first char features match
|
||||
assert.deepStrictEqual(
|
||||
multilineFeatureArray.slice(
|
||||
expectedNumericFeatures.length + 8 + 96 * 2,
|
||||
expectedNumericFeatures.length + 8 + 96 * 3
|
||||
),
|
||||
expectedSuffixFirstCharFeatures
|
||||
);
|
||||
// Suffix lstrip first char features match
|
||||
assert.deepStrictEqual(
|
||||
multilineFeatureArray.slice(
|
||||
expectedNumericFeatures.length + 8 + 96 * 3,
|
||||
expectedNumericFeatures.length + 8 + 96 * 4
|
||||
),
|
||||
expectedSuffixLstripFirstCharFeatures
|
||||
);
|
||||
// All features match
|
||||
assert.deepStrictEqual(
|
||||
multilineFeatureArray,
|
||||
expectedNumericFeatures.concat(
|
||||
expectedLangFeatures,
|
||||
expectedPrefixLastCharFeatures,
|
||||
expectedPrefiRstripLastCharFeatures,
|
||||
expectedSuffixFirstCharFeatures,
|
||||
expectedSuffixLstripFirstCharFeatures
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('requestMultilineScore() returns expected score', function () {
|
||||
const testCases = [
|
||||
{
|
||||
prompt: {
|
||||
prefix: '// Language: javascript\nexport function(x) {',
|
||||
suffix: '',
|
||||
isFimEnabled: true,
|
||||
},
|
||||
language: 'javascript',
|
||||
score: 0.32191348,
|
||||
},
|
||||
{
|
||||
prompt: {
|
||||
prefix: '#!/usr/bin/env python3\n# Function that adds two numbers\n',
|
||||
suffix: '',
|
||||
isFimEnabled: true,
|
||||
},
|
||||
language: 'python',
|
||||
score: 0.45744361,
|
||||
},
|
||||
{
|
||||
prompt: {
|
||||
prefix: '#!/usr/bin/env python3\nclass Test:\n # Function that adds two numbers\n',
|
||||
suffix: '',
|
||||
isFimEnabled: true,
|
||||
},
|
||||
language: 'python',
|
||||
score: 0.40182054,
|
||||
},
|
||||
{
|
||||
prompt: {
|
||||
prefix: '// Language: typescript\nconst testConst = ',
|
||||
suffix: 'const testConst2 = 2',
|
||||
isFimEnabled: true,
|
||||
},
|
||||
language: 'typescript',
|
||||
score: 0.45507183,
|
||||
},
|
||||
{
|
||||
prompt: {
|
||||
prefix: '// Language: typescript\nconst testConst = ',
|
||||
suffix: 'const testConst2 = 2\nconst testConst3 = 3',
|
||||
isFimEnabled: true,
|
||||
},
|
||||
language: 'typescript',
|
||||
score: 0.45507183,
|
||||
},
|
||||
{
|
||||
prompt: {
|
||||
prefix: '// Language: typescript\nconst testConst = \nconst testConst2 = 2\nconst testConst3 = 3 ',
|
||||
suffix: '',
|
||||
isFimEnabled: true,
|
||||
},
|
||||
language: 'typescript',
|
||||
score: 0.30417124,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const { prompt, language, score } = testCase;
|
||||
assert.strictEqual(requestMultilineScore(prompt, language).toFixed(4), score.toFixed(4));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { GhostCompletion } from '../ghostText';
|
||||
import { ITextEditorOptions, normalizeIndentCharacter } from '../normalizeIndent';
|
||||
import * as assert from 'assert';
|
||||
|
||||
suite('Leading whitespace normalization tests', function () {
|
||||
test('Leading spaces are replaces with tabs', function () {
|
||||
const teo: ITextEditorOptions = {
|
||||
tabSize: 4,
|
||||
insertSpaces: false,
|
||||
};
|
||||
|
||||
const completion: GhostCompletion = {
|
||||
completionIndex: 0,
|
||||
completionText: ' fun()\n yeet()',
|
||||
displayText: ' fun()\n yeet()',
|
||||
displayNeedsWsOffset: false,
|
||||
};
|
||||
|
||||
const output = '\tfun()\n\tyeet()';
|
||||
const result = normalizeIndentCharacter(teo, completion, false);
|
||||
|
||||
assert.ok(result.completionText === output, 'Leading whitespace normalization failed');
|
||||
assert.ok(result.displayText === output, 'Leading whitespace normalization failed');
|
||||
});
|
||||
|
||||
test('Leading tabs are replaces with spaces', function () {
|
||||
const teo: ITextEditorOptions = {
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
};
|
||||
|
||||
const completion: GhostCompletion = {
|
||||
completionIndex: 0,
|
||||
completionText: '\tfun()\n\tyeet()',
|
||||
displayText: '\tfun()\n\tyeet()',
|
||||
displayNeedsWsOffset: false,
|
||||
};
|
||||
|
||||
const output = ' fun()\n yeet()';
|
||||
|
||||
const result = normalizeIndentCharacter(teo, completion, false);
|
||||
assert.ok(result.completionText === output, 'Leading whitespace normalization failed');
|
||||
assert.ok(result.displayText === output, 'Leading whitespace normalization failed');
|
||||
});
|
||||
|
||||
test('Leading tabs are replaces with spaces - multiple level of indents', function () {
|
||||
const teo: ITextEditorOptions = {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
};
|
||||
|
||||
const completion: GhostCompletion = {
|
||||
completionIndex: 0,
|
||||
completionText: '\tfun()\n\t\tyeet()\n\tboo()',
|
||||
displayText: '\tfun()\n\t\tyeet()\n\tboo()',
|
||||
displayNeedsWsOffset: false,
|
||||
};
|
||||
|
||||
const output = ' fun()\n yeet()\n boo()';
|
||||
const result = normalizeIndentCharacter(teo, completion, false);
|
||||
|
||||
assert.ok(result.completionText === output, 'Leading whitespace normalization failed');
|
||||
assert.ok(result.displayText === output, 'Leading whitespace normalization failed');
|
||||
});
|
||||
|
||||
test('Leading spaces are replaces with tabs - multiple level of indents', function () {
|
||||
const teo: ITextEditorOptions = {
|
||||
tabSize: 2,
|
||||
insertSpaces: false,
|
||||
};
|
||||
|
||||
const completion: GhostCompletion = {
|
||||
completionIndex: 0,
|
||||
completionText: ' fun()\n yeet()\n boo()',
|
||||
displayText: ' fun()\n yeet()\n boo()',
|
||||
displayNeedsWsOffset: false,
|
||||
};
|
||||
|
||||
const output = '\tfun()\n\t\tyeet()\n\tboo()';
|
||||
const result = normalizeIndentCharacter(teo, completion, false);
|
||||
|
||||
assert.ok(result.completionText === output, 'Leading whitespace normalization failed');
|
||||
assert.ok(result.displayText === output, 'Leading whitespace normalization failed');
|
||||
});
|
||||
|
||||
test('Extra spaces are not dropped when replacing spaces with tabs', function () {
|
||||
const teo: ITextEditorOptions = {
|
||||
tabSize: 4,
|
||||
insertSpaces: false,
|
||||
};
|
||||
|
||||
const input = ' '.repeat(6) + 'fun()\n' + ' '.repeat(6) + ' yeet()\n' + ' '.repeat(6) + 'boo()';
|
||||
const completion: GhostCompletion = {
|
||||
completionIndex: 0,
|
||||
completionText: input,
|
||||
displayText: input,
|
||||
displayNeedsWsOffset: false,
|
||||
};
|
||||
|
||||
const output = '\t fun()\n' + '\t\tyeet()\n' + '\t boo()';
|
||||
const result = normalizeIndentCharacter(teo, completion, false);
|
||||
|
||||
assert.strictEqual(result.completionText, output, 'Leading whitespace normalization failed');
|
||||
assert.strictEqual(result.displayText, output, 'Leading whitespace normalization failed');
|
||||
});
|
||||
|
||||
test('Leading spaces are normalized to the tab size expected in editor in case of empty line suggestion', function () {
|
||||
const teo: ITextEditorOptions = {
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
};
|
||||
|
||||
const completion: GhostCompletion = {
|
||||
completionIndex: 0,
|
||||
completionText: ' fun()\n yeet()\n boo()',
|
||||
displayText: ' fun()\n yeet()\n boo()',
|
||||
displayNeedsWsOffset: false,
|
||||
};
|
||||
|
||||
const output = ' fun()\n yeet()\n boo()';
|
||||
const result = normalizeIndentCharacter(teo, completion, true);
|
||||
|
||||
assert.ok(result.completionText === output, 'Leading whitespace normalization failed');
|
||||
assert.ok(result.displayText === output, 'Leading whitespace normalization failed');
|
||||
});
|
||||
|
||||
test('Leading spaces are normalized to the tab size expected in editor in case of empty line suggestion, lot of indentation case', function () {
|
||||
const teo: ITextEditorOptions = {
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
};
|
||||
|
||||
const completion: GhostCompletion = {
|
||||
completionIndex: 0,
|
||||
completionText: ' fun()\n yeet()\n boo()',
|
||||
displayText: ' fun()\n yeet()\n boo()',
|
||||
displayNeedsWsOffset: false,
|
||||
};
|
||||
|
||||
const output = ' fun()\n yeet()\n boo()';
|
||||
const result = normalizeIndentCharacter(teo, completion, true);
|
||||
|
||||
assert.ok(result.completionText === output, 'Leading whitespace normalization failed');
|
||||
assert.ok(result.displayText === output, 'Leading whitespace normalization failed');
|
||||
});
|
||||
|
||||
test('Leading spaces are not normalized if ident size is same as tab size', function () {
|
||||
const teo: ITextEditorOptions = {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
};
|
||||
|
||||
const completion: GhostCompletion = {
|
||||
completionIndex: 0,
|
||||
completionText: ' fun()\n yeet()\n boo()',
|
||||
displayText: ' fun()\n yeet()\n boo()',
|
||||
displayNeedsWsOffset: false,
|
||||
};
|
||||
|
||||
const output = ' fun()\n yeet()\n boo()';
|
||||
const result = normalizeIndentCharacter(teo, completion, true);
|
||||
|
||||
assert.ok(result.completionText === output, 'Leading whitespace normalization failed');
|
||||
assert.ok(result.displayText === output, 'Leading whitespace normalization failed');
|
||||
});
|
||||
|
||||
test('Leading newlines do not trigger spurious extra indentation', function () {
|
||||
const teo: ITextEditorOptions = {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
};
|
||||
|
||||
const completion: GhostCompletion = {
|
||||
completionIndex: 0,
|
||||
completionText: '\n fun()\n yeet()\n boo()',
|
||||
displayText: '\n fun()\n yeet()\n boo()',
|
||||
displayNeedsWsOffset: false,
|
||||
};
|
||||
|
||||
const output = '\n fun()\n yeet()\n boo()';
|
||||
const result = normalizeIndentCharacter(teo, completion, true);
|
||||
|
||||
assert.ok(result.completionText === output, 'Leading whitespace normalization failed');
|
||||
assert.ok(result.displayText === output, 'Leading whitespace normalization failed');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+297
@@ -0,0 +1,297 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import assert from 'assert';
|
||||
import Sinon from 'sinon';
|
||||
import dedent from 'ts-dedent';
|
||||
import { SyncDescriptor } from '../../../../../../../util/vs/platform/instantiation/common/descriptors';
|
||||
import { IInstantiationService } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsFetcherService } from '../../networking';
|
||||
import { CompletionResults, CopilotUiKind, ICompletionsOpenAIFetcherService, LiveOpenAIFetcher } from '../../openai/fetch';
|
||||
import { APIChoice } from '../../openai/openai';
|
||||
import { TelemetryWithExp } from '../../telemetry';
|
||||
import { createLibTestingContext } from '../../test/context';
|
||||
import { createFakeCompletionResponse, fakeCodeReference, StaticFetcher } from '../../test/fetcher';
|
||||
import { StreamedCompletionSplitter } from '../streamedCompletionSplitter';
|
||||
|
||||
suite('StreamedCompletionSplitter', function () {
|
||||
function setupSplitter(fetcher: ICompletionsFetcherService, docPrefix = 'function example(arg) {\n', languageId = 'javascript') {
|
||||
const serviceCollection = createLibTestingContext();
|
||||
serviceCollection.define(ICompletionsFetcherService, fetcher);
|
||||
serviceCollection.define(ICompletionsOpenAIFetcherService, new SyncDescriptor(LiveOpenAIFetcher)); // gets results from static fetcher
|
||||
const accessor = serviceCollection.createTestingAccessor();
|
||||
|
||||
const fetcherService = accessor.get(ICompletionsOpenAIFetcherService);
|
||||
const telemetry = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
const params = {
|
||||
prompt: {
|
||||
prefix: docPrefix,
|
||||
suffix: '',
|
||||
isFimEnabled: false,
|
||||
promptElementRanges: [],
|
||||
},
|
||||
languageId: languageId,
|
||||
repoInfo: undefined,
|
||||
ourRequestId: 'test-request-id',
|
||||
engineModelId: 'test-model-id',
|
||||
count: 1,
|
||||
uiKind: CopilotUiKind.GhostText,
|
||||
extra: {},
|
||||
};
|
||||
const cacheFunction = Sinon.stub<[string, APIChoice], void>();
|
||||
const splitter = accessor.get(IInstantiationService).createInstance(StreamedCompletionSplitter, docPrefix, languageId, true, 7, cacheFunction);
|
||||
const fetchAndStreamCompletions = async function () {
|
||||
return await fetcherService.fetchAndStreamCompletions(params, telemetry, splitter.getFinishedCallback());
|
||||
};
|
||||
return { splitter, cacheFunction, fetchAndStreamCompletions };
|
||||
}
|
||||
|
||||
async function readChoices(result: CompletionResults): Promise<APIChoice[]> {
|
||||
const choices = [];
|
||||
for await (const choice of result.choices) {
|
||||
choices.push(choice);
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
test('yields the first line of the completion', async function () {
|
||||
const { fetchAndStreamCompletions } = setupSplitter(
|
||||
new StaticFetcher(() =>
|
||||
createFakeCompletionResponse(
|
||||
dedent`
|
||||
const result = [];
|
||||
for (let i = 0; i < arg; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
return result.join(', ');
|
||||
`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
const completions = await readChoices(result);
|
||||
assert.strictEqual(completions.length, 1);
|
||||
assert.strictEqual(completions[0].completionText, 'const result = [];');
|
||||
});
|
||||
|
||||
test('caches the remaining sections of the completion', async function () {
|
||||
const { fetchAndStreamCompletions, cacheFunction } = setupSplitter(
|
||||
new StaticFetcher(() =>
|
||||
createFakeCompletionResponse(
|
||||
dedent`
|
||||
const result = [];
|
||||
for (let i = 0; i < arg; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
return result.join(', ');
|
||||
`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
await readChoices(result);
|
||||
Sinon.assert.calledTwice(cacheFunction);
|
||||
Sinon.assert.calledWith(
|
||||
cacheFunction,
|
||||
'const result = [];',
|
||||
Sinon.match({
|
||||
completionText: '\nfor (let i = 0; i < arg; i++) {\n\tresult.push(i);\n}',
|
||||
})
|
||||
);
|
||||
Sinon.assert.calledWith(
|
||||
cacheFunction,
|
||||
'const result = [];\nfor (let i = 0; i < arg; i++) {\n\tresult.push(i);\n}',
|
||||
Sinon.match({ completionText: `\nreturn result.join(', ');` })
|
||||
);
|
||||
});
|
||||
|
||||
test('trims trailing whitespace from cached completions', async function () {
|
||||
const { fetchAndStreamCompletions, cacheFunction } = setupSplitter(
|
||||
new StaticFetcher(() => createFakeCompletionResponse('// one\n\n// two '))
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
await readChoices(result);
|
||||
Sinon.assert.calledWith(cacheFunction, '// one', Sinon.match({ completionText: '\n\n// two' }));
|
||||
});
|
||||
|
||||
test('allows single line completions that begin with a newline', async function () {
|
||||
const { fetchAndStreamCompletions } = setupSplitter(
|
||||
new StaticFetcher(() => createFakeCompletionResponse('\n// one\n// two'))
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
const completions = await readChoices(result);
|
||||
assert.strictEqual(completions.length, 1);
|
||||
assert.strictEqual(completions[0].completionText, '\n// one');
|
||||
});
|
||||
|
||||
test('allows single line completions that begin with a CRLF pair', async function () {
|
||||
const { fetchAndStreamCompletions } = setupSplitter(
|
||||
new StaticFetcher(() => createFakeCompletionResponse('\r\n// one\r\n// two'))
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
const completions = await readChoices(result);
|
||||
assert.strictEqual(completions.length, 1);
|
||||
assert.strictEqual(completions[0].completionText, '\r\n// one');
|
||||
});
|
||||
|
||||
test('sets generatedChoiceIndex on cached completions', async function () {
|
||||
const { fetchAndStreamCompletions, cacheFunction } = setupSplitter(
|
||||
new StaticFetcher(() =>
|
||||
createFakeCompletionResponse(
|
||||
dedent`
|
||||
const result = [];
|
||||
for (let i = 0; i < arg; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
return result.join(', ');
|
||||
`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
await readChoices(result);
|
||||
Sinon.assert.calledWith(cacheFunction, Sinon.match.string, Sinon.match({ generatedChoiceIndex: 1 }));
|
||||
Sinon.assert.calledWith(cacheFunction, Sinon.match.string, Sinon.match({ generatedChoiceIndex: 2 }));
|
||||
});
|
||||
|
||||
test('adjusts start_offset in any annotations present in cached split choices', async function () {
|
||||
const parts = ['x=1;', '\n\ny=2;', '\n\nz=3;\n'];
|
||||
const completion = parts.join('');
|
||||
const { fetchAndStreamCompletions, cacheFunction } = setupSplitter(
|
||||
new StaticFetcher(() =>
|
||||
createFakeCompletionResponse(completion, { annotations: fakeCodeReference(-1, completion.length + 1) })
|
||||
)
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
await readChoices(result);
|
||||
Sinon.assert.calledTwice(cacheFunction);
|
||||
Sinon.assert.calledWith(
|
||||
cacheFunction,
|
||||
Sinon.match.string,
|
||||
Sinon.match({
|
||||
copilotAnnotations: Sinon.match({
|
||||
ip_code_citations: [Sinon.match({ start_offset: -parts[0].length - 1 })],
|
||||
}),
|
||||
})
|
||||
);
|
||||
Sinon.assert.calledWith(
|
||||
cacheFunction,
|
||||
Sinon.match.string,
|
||||
Sinon.match({
|
||||
copilotAnnotations: Sinon.match({
|
||||
ip_code_citations: [Sinon.match({ start_offset: -parts[0].length - parts[1].length - 1 })],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('adjusts stop_offset in any annotations present in cached split choices', async function () {
|
||||
const parts = ['x=1;', '\n\ny=2;', '\n\nz=3;'];
|
||||
const completion = parts.join('');
|
||||
const { fetchAndStreamCompletions, cacheFunction } = setupSplitter(
|
||||
new StaticFetcher(() =>
|
||||
createFakeCompletionResponse(completion, { annotations: fakeCodeReference(-1, completion.length + 1) })
|
||||
)
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
await readChoices(result);
|
||||
Sinon.assert.calledTwice(cacheFunction);
|
||||
Sinon.assert.calledWith(
|
||||
cacheFunction,
|
||||
Sinon.match.string,
|
||||
Sinon.match({
|
||||
copilotAnnotations: Sinon.match({
|
||||
ip_code_citations: [Sinon.match({ stop_offset: parts[1].length })],
|
||||
}),
|
||||
})
|
||||
);
|
||||
Sinon.assert.calledWith(
|
||||
cacheFunction,
|
||||
Sinon.match.string,
|
||||
Sinon.match({
|
||||
copilotAnnotations: Sinon.match({
|
||||
ip_code_citations: [Sinon.match({ stop_offset: parts[2].length + 1 })],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('omits any annotation from split choices where start_offset does not intersect the choice', async function () {
|
||||
const parts = ['x=1;', '\n\ny=2;', '\n\nz=3;\n'];
|
||||
const completion = parts.join('');
|
||||
const { fetchAndStreamCompletions, cacheFunction } = setupSplitter(
|
||||
new StaticFetcher(() =>
|
||||
createFakeCompletionResponse(completion, {
|
||||
annotations: fakeCodeReference(parts[0].length + parts[1].length + 3, completion.length + 1),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
await readChoices(result);
|
||||
Sinon.assert.calledTwice(cacheFunction);
|
||||
Sinon.assert.calledWith(cacheFunction, Sinon.match.string, Sinon.match({ copilotAnnotations: undefined }));
|
||||
Sinon.assert.calledWith(
|
||||
cacheFunction,
|
||||
Sinon.match.string,
|
||||
Sinon.match({
|
||||
copilotAnnotations: Sinon.match({
|
||||
ip_code_citations: [Sinon.match({ start_offset: 3 })],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('omits any annotation from split choices where stop_offset does not intersect the choice', async function () {
|
||||
const parts = ['x=1;', '\n\ny=2;', '\n\nz=3;\n'];
|
||||
const completion = parts.join('');
|
||||
const { fetchAndStreamCompletions, cacheFunction } = setupSplitter(
|
||||
new StaticFetcher(() =>
|
||||
createFakeCompletionResponse(completion, { annotations: fakeCodeReference(-1, parts[0].length + 3) })
|
||||
)
|
||||
);
|
||||
|
||||
const result = await fetchAndStreamCompletions();
|
||||
|
||||
assert.strictEqual(result.type, 'success');
|
||||
await readChoices(result);
|
||||
Sinon.assert.calledTwice(cacheFunction);
|
||||
Sinon.assert.calledWith(
|
||||
cacheFunction,
|
||||
Sinon.match.string,
|
||||
Sinon.match({
|
||||
copilotAnnotations: Sinon.match({
|
||||
ip_code_citations: [Sinon.match({ stop_offset: 3 })],
|
||||
}),
|
||||
})
|
||||
);
|
||||
Sinon.assert.calledWith(cacheFunction, Sinon.match.string, Sinon.match({ copilotAnnotations: undefined }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
/**
|
||||
* This implements the Map interface. Note that in all methods that iterate or return an iterator, a copy of the underlying data is
|
||||
* returned so that if you call `get`, `set`, or `delete` while iterating, the iterator will not be invalidated.
|
||||
*/
|
||||
export class LRUCacheMap<K, T> implements Map<K, T> {
|
||||
private valueMap = new Map<K, T>();
|
||||
private sizeLimit: number;
|
||||
|
||||
// constructor
|
||||
constructor(size = 10) {
|
||||
if (size < 1) {
|
||||
throw new Error('Size limit must be at least 1');
|
||||
}
|
||||
this.sizeLimit = size;
|
||||
}
|
||||
|
||||
set(key: K, value: T): this {
|
||||
if (this.has(key)) {
|
||||
// If key already exists, delete it
|
||||
// from the valueMap only so we can re-insert it at the end
|
||||
this.valueMap.delete(key);
|
||||
} else if (this.valueMap.size >= this.sizeLimit) {
|
||||
// least-recently used cache eviction strategy
|
||||
// Maps iterate in insertion order
|
||||
const oldest = this.valueMap.keys().next().value!;
|
||||
this.delete(oldest);
|
||||
}
|
||||
|
||||
this.valueMap.set(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning this method makes the key the most recently used. To avoid this, use `peek` instead.
|
||||
* @param key
|
||||
* @returns
|
||||
*/
|
||||
get(key: K): T | undefined {
|
||||
if (this.valueMap.has(key)) {
|
||||
const entry = this.valueMap.get(key);
|
||||
// Move to the end by deleting and re-inserting
|
||||
this.valueMap.delete(key);
|
||||
this.valueMap.set(key, entry!);
|
||||
return entry!;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
delete(key: K): boolean {
|
||||
return this.valueMap.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.valueMap.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.valueMap.size;
|
||||
}
|
||||
|
||||
keys(): IterableIterator<K> {
|
||||
return new Map(this.valueMap).keys();
|
||||
}
|
||||
|
||||
values(): IterableIterator<T> {
|
||||
return new Map(this.valueMap).values();
|
||||
}
|
||||
|
||||
entries(): IterableIterator<[K, T]> {
|
||||
return new Map(this.valueMap).entries();
|
||||
}
|
||||
|
||||
[Symbol.iterator](): IterableIterator<[K, T]> {
|
||||
return this.entries();
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.valueMap.has(key);
|
||||
}
|
||||
|
||||
forEach(callbackfn: (value: T, key: K, map: Map<K, T>) => void, thisArg?: unknown): void {
|
||||
new Map(this.valueMap).forEach(callbackfn, thisArg);
|
||||
}
|
||||
|
||||
get [Symbol.toStringTag](): string {
|
||||
return 'LRUCacheMap';
|
||||
}
|
||||
|
||||
peek(key: K): T | undefined {
|
||||
return this.valueMap.get(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export async function* asyncIterableMap<TSource, TDest>(
|
||||
source: AsyncIterable<TSource>,
|
||||
selector: (x: TSource) => Promise<TDest> | TDest
|
||||
): AsyncIterable<TDest> {
|
||||
for await (const item of source) {
|
||||
yield selector(item);
|
||||
}
|
||||
}
|
||||
|
||||
export async function* asyncIterableFilter<TSource>(
|
||||
source: AsyncIterable<TSource>,
|
||||
predicate: (x: TSource) => Promise<boolean> | boolean
|
||||
): AsyncIterable<TSource> {
|
||||
for await (const item of source) {
|
||||
if (await predicate(item)) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function* asyncIterableMapFilter<TSource, TDest>(
|
||||
source: AsyncIterable<TSource>,
|
||||
selector: (x: TSource) => Promise<TDest | undefined> | TDest | undefined
|
||||
): AsyncIterable<TDest> {
|
||||
for await (const item of source) {
|
||||
const result = await selector(item);
|
||||
if (result !== undefined) {
|
||||
yield result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function* asyncIterableFromArray<TSource>(source: TSource[]): AsyncIterable<TSource, void, unknown> {
|
||||
for (const item of source) {
|
||||
yield Promise.resolve(item);
|
||||
}
|
||||
}
|
||||
|
||||
export async function asyncIterableToArray<TSource>(source: AsyncIterable<TSource>): Promise<TSource[]> {
|
||||
const result: TSource[] = [];
|
||||
for await (const item of source) {
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function* asyncIterableConcat<TSource>(...sources: AsyncIterable<TSource>[]): AsyncIterable<TSource> {
|
||||
for (const source of sources) {
|
||||
yield* source;
|
||||
}
|
||||
}
|
||||
|
||||
export async function asyncIterableCount<TSource>(source: AsyncIterable<TSource>): Promise<number> {
|
||||
let count = 0;
|
||||
for await (const _ of source) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function* iterableMap<TSource, TDest>(
|
||||
source: Iterable<TSource>,
|
||||
selector: (x: TSource) => TDest
|
||||
): Iterable<TDest> {
|
||||
for (const item of source) {
|
||||
yield selector(item);
|
||||
}
|
||||
}
|
||||
|
||||
export function* iterableMapFilter<TSource, TDest>(
|
||||
source: Iterable<TSource>,
|
||||
selector: (x: TSource) => TDest | undefined
|
||||
): Iterable<TDest> {
|
||||
for (const item of source) {
|
||||
const result = selector(item);
|
||||
if (result !== undefined) {
|
||||
yield result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/** A data structure for efficiently finding all values that are indexed by a key
|
||||
* that is a prefix of a given key, using a radix trie representation.
|
||||
*
|
||||
* An overarching goal of the implementation is to minimize storing and handling
|
||||
* the full keys since in the case of completions, the keys are the full text of
|
||||
* the document before the cursor which can be large.
|
||||
*/
|
||||
export class LRURadixTrie<T> {
|
||||
/** Singular, empty root node for the the trie. */
|
||||
private readonly root = new LRURadixNode<T>();
|
||||
|
||||
/** Set of all leaf nodes with values, tracked for evicting LRU values. */
|
||||
private readonly leafNodes: Set<LRURadixNode<T>> = new Set();
|
||||
|
||||
constructor(private readonly maxSize: number) { }
|
||||
|
||||
/**
|
||||
* Traverses the trie to insert a new value. If an existing exact match is
|
||||
* found the value is added to a list of values at that node. Otherwise a
|
||||
* new node is created.
|
||||
*
|
||||
* As a side effect, the least recently used node is evicted if the max size
|
||||
* is exceeded.
|
||||
*/
|
||||
set(key: string, value: T): void {
|
||||
let { node, remainingKey } = this.findClosestNode(key);
|
||||
// If no exact match, add a new node under the closest node.
|
||||
if (remainingKey.length > 0) {
|
||||
// Check if there is a child node with an edge that is a prefix of
|
||||
// the remaining key.
|
||||
for (const [edge, child] of node.children) {
|
||||
if (edge.startsWith(remainingKey)) {
|
||||
// Split the edge by adding a new intermediate node.
|
||||
const commonPrefix = edge.slice(0, remainingKey.length);
|
||||
const intermediate = new LRURadixNode<T>();
|
||||
node.removeChild(edge);
|
||||
node.addChild(commonPrefix, intermediate);
|
||||
intermediate.addChild(edge.slice(commonPrefix.length), child);
|
||||
node = intermediate;
|
||||
remainingKey = remainingKey.slice(commonPrefix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (remainingKey.length > 0) {
|
||||
// Add a new node with the remaining key.
|
||||
const newNode = new LRURadixNode<T>();
|
||||
node.addChild(remainingKey, newNode);
|
||||
node = newNode;
|
||||
}
|
||||
}
|
||||
// Set value on the node
|
||||
node.value = value;
|
||||
// Ensure the node which may be new or newly with a value is in the
|
||||
// leafNode set.
|
||||
this.leafNodes.add(node);
|
||||
// Evict least recently used node if max size is exceeded.
|
||||
if (this.leafNodes.size > this.maxSize) {
|
||||
this.evictLeastRecentlyUsed();
|
||||
}
|
||||
}
|
||||
|
||||
/** Traverses the trie and returns all values whose keys are a prefix of the
|
||||
* given key. Returns them in order of longest prefix first.
|
||||
*/
|
||||
findAll(key: string): Array<{ remainingKey: string; value: T }> {
|
||||
return this.findClosestNode(key)
|
||||
.stack.map(({ node, remainingKey }) =>
|
||||
node.value !== undefined ? { remainingKey, value: node.value } : undefined
|
||||
)
|
||||
.filter(x => x !== undefined);
|
||||
}
|
||||
|
||||
/** Removes the value at a given key if any from the trie. */
|
||||
delete(key: string): void {
|
||||
const { node, remainingKey } = this.findClosestNode(key);
|
||||
// If no exact match is found, do nothing.
|
||||
if (remainingKey.length > 0) { return; }
|
||||
// Exact match found, remove the value.
|
||||
this.deleteNode(node);
|
||||
}
|
||||
|
||||
/** Traverses the trie to find the node with the closest prefix to a given key. */
|
||||
private findClosestNode(key: string) {
|
||||
let hasNext = true;
|
||||
let node: LRURadixNode<T> = this.root;
|
||||
const stack: { node: LRURadixNode<T>; remainingKey: string }[] = [{ node, remainingKey: key }];
|
||||
while (key.length > 0 && hasNext) {
|
||||
hasNext = false;
|
||||
for (const [edge, child] of node.children) {
|
||||
if (key.startsWith(edge)) {
|
||||
key = key.slice(edge.length);
|
||||
stack.unshift({ node: child, remainingKey: key });
|
||||
node = child;
|
||||
hasNext = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { node, remainingKey: key, stack };
|
||||
}
|
||||
|
||||
/** Deletes a node from the trie and resolves relationships with surrounding nodes.
|
||||
* - If the node has no children, remove it from its parent.
|
||||
* - If the node has one child, replace it with its child in the parent,
|
||||
* concatenating the edges together.
|
||||
* - If the node has multiple children, the node is left in place as an
|
||||
* intermediary node.
|
||||
* - In all cases, the value at the node is removed and the node is removed
|
||||
* from the flatNodes set of leaf nodes.
|
||||
*/
|
||||
private deleteNode(node: LRURadixNode<T>): void {
|
||||
node.value = undefined;
|
||||
this.leafNodes.delete(node);
|
||||
// If the node has no parent, it is the root. Done.
|
||||
if (node.parent === undefined) { return; }
|
||||
// If more than one child, keep the node as an intermediary node. Done.
|
||||
if (node.childCount > 1) { return; }
|
||||
const { node: parent, edge } = node.parent;
|
||||
// If exactly one child, replace the node with the child in the parent.
|
||||
if (node.childCount === 1) {
|
||||
const [childEdge, childNode] = Array.from(node.children)[0];
|
||||
node.removeChild(childEdge);
|
||||
parent.removeChild(edge);
|
||||
parent.addChild(edge + childEdge, childNode);
|
||||
return;
|
||||
}
|
||||
// If the node has no children, remove it from the parent.
|
||||
parent.removeChild(edge);
|
||||
// If the parent node is the root, no further action is needed.
|
||||
if (parent.parent === undefined) { return; }
|
||||
const grandparent = parent.parent;
|
||||
// If the parent node has only one child remaining and no value, merge
|
||||
// the parent and remaining child together.
|
||||
if (parent.value === undefined && parent.childCount === 1) {
|
||||
const [childEdge, childNode] = Array.from(parent.children)[0];
|
||||
const newEdge = grandparent.edge + childEdge;
|
||||
parent.removeChild(childEdge);
|
||||
grandparent.node.removeChild(grandparent.edge);
|
||||
grandparent.node.addChild(newEdge, childNode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Walks the trie to find and evict the least recently used node. This is
|
||||
* intentionally optimized for read performance over write performance.
|
||||
*/
|
||||
private evictLeastRecentlyUsed(): void {
|
||||
const node = this.findLeastRecentlyUsed();
|
||||
if (node) { this.deleteNode(node); }
|
||||
}
|
||||
|
||||
/** Iterate through the set of leaf nodes to find the least recently used.
|
||||
*
|
||||
* Note, this could be done more efficiently with a heap or even just
|
||||
* keeping the list sorted. Currently, this is mirroring the LRUCacheMap
|
||||
* implementation to optimize for read performance over write performance.
|
||||
* Though this may be worth revisiting since both reading and writing are on
|
||||
* the critical path for completions.
|
||||
*/
|
||||
private findLeastRecentlyUsed(): LRURadixNode<T> | undefined {
|
||||
let least: LRURadixNode<T> | undefined;
|
||||
for (const node of this.leafNodes) {
|
||||
if (least === undefined || node.touched < least.touched) {
|
||||
least = node;
|
||||
}
|
||||
}
|
||||
return least;
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal node representation in a LRURadixTrie.
|
||||
* - Optionally has a value to represent a leaf node.
|
||||
* - Contains a list of child nodes, not mutually exclusive with having value.
|
||||
* - If not a root, has a parent edge for traversal up the trie.
|
||||
* - Maintains state on most recent access time for LRU eviction.
|
||||
*/
|
||||
class LRURadixNode<T> {
|
||||
private readonly _children: Map<string, LRURadixNode<T>> = new Map();
|
||||
private _touched = performance.now();
|
||||
private _value: T | undefined;
|
||||
|
||||
/** Reference to the parent node and edge to this node for backtracking. */
|
||||
parent: { node: LRURadixNode<T>; edge: string } | undefined;
|
||||
|
||||
/** Iterator for the children of this node. */
|
||||
get children() {
|
||||
return this._children.entries();
|
||||
}
|
||||
|
||||
/** The number of children of this node. */
|
||||
get childCount() {
|
||||
return this._children.size;
|
||||
}
|
||||
|
||||
/** Adds a child node to this node and sets its parent reference. */
|
||||
addChild(edge: string, child: LRURadixNode<T>): void {
|
||||
this._children.set(edge, child);
|
||||
child.parent = { node: this, edge };
|
||||
}
|
||||
|
||||
/** Removes a child node from this node and clears its parent reference. */
|
||||
removeChild(edge: string): void {
|
||||
const child = this._children.get(edge);
|
||||
if (child) { child.parent = undefined; }
|
||||
this._children.delete(edge);
|
||||
}
|
||||
|
||||
/** Reads the value and updates the touched timestamp. */
|
||||
get value(): T | undefined {
|
||||
this.touch();
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/** Sets value and updates the touched timestamp. */
|
||||
set value(value: T | undefined) {
|
||||
this.touch();
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
/** The last time (ms from process start) this node's value was accessed. */
|
||||
get touched(): number {
|
||||
return this._touched;
|
||||
}
|
||||
|
||||
private touch(): void {
|
||||
this._touched = performance.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { LRUCacheMap } from '../cache';
|
||||
import * as assert from 'assert';
|
||||
|
||||
suite('LRUCacheMap', function () {
|
||||
test('should add and retrieve entries using set and get methods', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
cache.set('c', 3);
|
||||
assert.equal(cache.get('b'), 2);
|
||||
assert.equal(cache.get('c'), 3);
|
||||
assert.equal(cache.get('a'), undefined, 'a should have been removed from the cache');
|
||||
assert.equal(cache.size, 2);
|
||||
});
|
||||
|
||||
test('should not increase size if the same object is added twice', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('a', 1);
|
||||
assert.equal(cache.size, 1);
|
||||
});
|
||||
|
||||
test('should maintain the order of the values consistent with the order that the items were added or retrieved', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
assert.equal(cache.get('a'), 1); // this should make 'b' the most recently used
|
||||
assert.equal(cache.peek('b'), 2); // this should not change the order
|
||||
assert.ok(cache.has('b')); // b should still be in the cache
|
||||
cache.set('c', 3);
|
||||
assert.deepEqual([...cache.keys()], ['a', 'c']);
|
||||
assert.deepEqual([...cache.values()], [1, 3]);
|
||||
assert.ok(!cache.has('b')); // b should have been removed from the cache
|
||||
assert.equal(cache.get('b'), undefined, 'b should have been removed from the cache');
|
||||
assert.equal(cache.get('z'), undefined, 'z was never added to the cache');
|
||||
assert.equal(cache.size, 2);
|
||||
});
|
||||
|
||||
test('should delete entries using the delete method and decrease size', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
cache.delete('a');
|
||||
assert.equal(cache.get('a'), undefined);
|
||||
assert.equal(cache.size, 1);
|
||||
});
|
||||
|
||||
test('clear works', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
cache.clear();
|
||||
assert.equal(cache.get('a'), undefined);
|
||||
assert.equal(cache.get('b'), undefined);
|
||||
assert.equal(cache.size, 0);
|
||||
});
|
||||
|
||||
test('should iterate over all entries using a for...of loop', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
const entries: [string, number][] = [];
|
||||
for (const [key, value] of cache) {
|
||||
entries.push([key, value]);
|
||||
// touch a should not change for loop contents even though it becomes most recently used in the LRU
|
||||
cache.get('a');
|
||||
cache.set('c', 3); // similarly, adding a new entry should not change the for loop contents
|
||||
}
|
||||
assert.deepEqual(entries, [
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should iterate over all entries using the entries method', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
const entries: [string, number][] = [];
|
||||
for (const [key, value] of cache.entries()) {
|
||||
entries.push([key, value]);
|
||||
// touch a should not change for loop contents even though it becomes most recently used in the LRU
|
||||
cache.get('a');
|
||||
cache.set('c', 3); // similarly, adding a new entry should not change the for loop contents
|
||||
}
|
||||
assert.deepEqual(entries, [
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should iterate over all entries using the forEach method', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
const entries: [string, number][] = [];
|
||||
cache.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
cache.clear(); // shouldn't affect contents of forEach loop
|
||||
});
|
||||
assert.deepEqual(entries, [
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should iterate over all values using the values method', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
const values: number[] = [];
|
||||
for (const value of cache.values()) {
|
||||
values.push(value);
|
||||
// touch a should not change for loop contents even though it becomes most recently used in the LRU
|
||||
cache.get('a');
|
||||
cache.set('c', 3); // similarly, adding a new entry should not change the for loop contents
|
||||
}
|
||||
assert.deepEqual(values, [1, 2]);
|
||||
});
|
||||
|
||||
test('should iterate over all keys using the keys method', function () {
|
||||
const cache = new LRUCacheMap<string, number>(2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
const keys: string[] = [];
|
||||
for (const key of cache.keys()) {
|
||||
keys.push(key);
|
||||
cache.clear(); // shouldn't affect contents of forEach loop
|
||||
}
|
||||
assert.deepEqual(keys, ['a', 'b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import {
|
||||
asyncIterableConcat,
|
||||
asyncIterableCount,
|
||||
asyncIterableFilter,
|
||||
asyncIterableFromArray,
|
||||
asyncIterableMap,
|
||||
asyncIterableMapFilter,
|
||||
asyncIterableToArray,
|
||||
iterableMap,
|
||||
iterableMapFilter,
|
||||
} from '../iterableHelpers';
|
||||
|
||||
class AsyncIterableTestHelper {
|
||||
state = 0; // this is used to check that operations are suitably lazy
|
||||
async *[Symbol.asyncIterator](): AsyncIterator<number> {
|
||||
this.state = 1;
|
||||
yield Promise.resolve(1);
|
||||
this.state = 2;
|
||||
yield Promise.resolve(2);
|
||||
this.state = 3;
|
||||
yield Promise.resolve(3);
|
||||
this.state = 4;
|
||||
}
|
||||
constructor() { }
|
||||
}
|
||||
|
||||
suite('Async Iterable utilities', function () {
|
||||
// Sanity check that the generator itself behaves as expected
|
||||
test('generator', async function () {
|
||||
const asyncIterableIn = new AsyncIterableTestHelper();
|
||||
const asyncIterable = asyncIterableIn;
|
||||
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 0);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 1, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 1);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 2, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 2);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 3, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 3);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: undefined, done: true });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 4);
|
||||
});
|
||||
|
||||
test('map', async function () {
|
||||
const asyncIterableIn = new AsyncIterableTestHelper();
|
||||
const asyncIterable = asyncIterableMap(asyncIterableIn, v => Promise.resolve(v * 2));
|
||||
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 0);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 2, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 1);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 4, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 2);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 6, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 3);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: undefined, done: true });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 4);
|
||||
});
|
||||
|
||||
test('filter', async function () {
|
||||
const asyncIterableIn = new AsyncIterableTestHelper();
|
||||
const asyncIterable = asyncIterableFilter(asyncIterableIn, v => Promise.resolve(v % 2 === 0));
|
||||
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 0);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 2, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 2);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: undefined, done: true });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 4);
|
||||
});
|
||||
|
||||
test('mapFilter', async function () {
|
||||
const asyncIterableIn = new AsyncIterableTestHelper();
|
||||
const asyncIterable = asyncIterableMapFilter(asyncIterableIn, v =>
|
||||
Promise.resolve(v % 2 === 0 ? v / 2 : undefined)
|
||||
);
|
||||
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 0);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 1, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 2);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: undefined, done: true });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 4);
|
||||
});
|
||||
|
||||
test('mapFilter keeps non-undefined falsy values', async function () {
|
||||
const asyncIterableIn = new AsyncIterableTestHelper();
|
||||
const asyncIterable = asyncIterableMapFilter(asyncIterableIn, v => Promise.resolve(v % 2 === 0 ? v / 2 : 0));
|
||||
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 0);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 0, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 1);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 1, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 2);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 0, done: false });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 3);
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: undefined, done: true });
|
||||
assert.deepStrictEqual(asyncIterableIn.state, 4);
|
||||
});
|
||||
|
||||
test('fromArray', async function () {
|
||||
const asyncIterable = asyncIterableFromArray([1, 2]);
|
||||
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 1, done: false });
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 2, done: false });
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: undefined, done: true });
|
||||
});
|
||||
|
||||
test('toArray', async function () {
|
||||
const expected = [1, 2, 3];
|
||||
const asyncIterable = asyncIterableFromArray(expected);
|
||||
const actual = await asyncIterableToArray(asyncIterable);
|
||||
assert.deepStrictEqual(actual, expected);
|
||||
});
|
||||
|
||||
test('concat', async function () {
|
||||
const asyncIterable1 = asyncIterableFromArray([1, 2]);
|
||||
const asyncIterable2 = asyncIterableFromArray([3, 4]);
|
||||
const asyncIterable = asyncIterableConcat(asyncIterable1, asyncIterable2);
|
||||
const asyncIterator = asyncIterable[Symbol.asyncIterator]();
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 1, done: false });
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 2, done: false });
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 3, done: false });
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: 4, done: false });
|
||||
assert.deepStrictEqual(await asyncIterator.next(), { value: undefined, done: true });
|
||||
});
|
||||
|
||||
test('count', async function () {
|
||||
const asyncIterable = asyncIterableFromArray([1, 2]);
|
||||
assert.deepStrictEqual(await asyncIterableCount(asyncIterable), 2);
|
||||
});
|
||||
|
||||
test('iterableMap', function () {
|
||||
const source = [1, 2, 3][Symbol.iterator]();
|
||||
const actual = iterableMap(source, v => v * 2);
|
||||
assert.deepStrictEqual(Array.from(actual), [2, 4, 6]);
|
||||
});
|
||||
|
||||
test('iterableMapFilter', function () {
|
||||
const source = [1, 2, 3][Symbol.iterator]();
|
||||
const actual = iterableMapFilter(source, v => (v % 2 !== 0 ? v * 2 : undefined));
|
||||
assert.deepStrictEqual(Array.from(actual), [2, 6]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
import { LRURadixTrie } from '../radix';
|
||||
import * as assert from 'assert';
|
||||
|
||||
suite('LRURadixTrie', function () {
|
||||
let trie: LRURadixTrie<string>;
|
||||
|
||||
setup(function () {
|
||||
trie = new LRURadixTrie<string>(20);
|
||||
});
|
||||
|
||||
suite('set', function () {
|
||||
test('stores a single value', function () {
|
||||
trie.set('test', 'value');
|
||||
assert.deepStrictEqual(trie.findAll('test'), [{ remainingKey: '', value: 'value' }]);
|
||||
});
|
||||
|
||||
test('splits edges when inserting', function () {
|
||||
trie.set('test', 'first');
|
||||
trie.set('testing', 'second');
|
||||
assert.deepStrictEqual(trie.findAll('testing'), [
|
||||
{ remainingKey: '', value: 'second' },
|
||||
{ remainingKey: 'ing', value: 'first' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('evicts least recently used when exceeding max size', function () {
|
||||
trie = new LRURadixTrie<string>(3);
|
||||
trie.set('a', 'first');
|
||||
trie.set('b', 'second');
|
||||
trie.set('c', 'third');
|
||||
trie.set('d', 'fourth');
|
||||
|
||||
assert.deepStrictEqual(trie.findAll('a'), []);
|
||||
assert.deepStrictEqual(trie.findAll('b'), [{ remainingKey: '', value: 'second' }]);
|
||||
assert.deepStrictEqual(trie.findAll('c'), [{ remainingKey: '', value: 'third' }]);
|
||||
assert.deepStrictEqual(trie.findAll('d'), [{ remainingKey: '', value: 'fourth' }]);
|
||||
});
|
||||
|
||||
test('shorter key as prefix of longer key', function () {
|
||||
const trie = new LRURadixTrie<string>(20);
|
||||
trie.set('test', '1');
|
||||
trie.set('t', '2');
|
||||
assert.deepStrictEqual(trie.findAll('test'), [
|
||||
{ remainingKey: '', value: '1' },
|
||||
{ remainingKey: 'est', value: '2' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('insertion order does not matter', function () {
|
||||
const trie1 = new LRURadixTrie<string>(20);
|
||||
const trie2 = new LRURadixTrie<string>(20);
|
||||
trie1.set('t', '2');
|
||||
trie1.set('test', '1');
|
||||
trie2.set('test', '1');
|
||||
trie2.set('t', '2');
|
||||
assert.deepStrictEqual(trie1.findAll('test'), [
|
||||
{ remainingKey: '', value: '1' },
|
||||
{ remainingKey: 'est', value: '2' },
|
||||
]);
|
||||
assert.deepStrictEqual(trie2.findAll('test'), [
|
||||
{ remainingKey: '', value: '1' },
|
||||
{ remainingKey: 'est', value: '2' },
|
||||
]);
|
||||
assert.deepStrictEqual(trie1.findAll('test'), trie2.findAll('test'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('findAll', function () {
|
||||
test('returns all matching prefixes', function () {
|
||||
trie.set('t', 'first');
|
||||
trie.set('te', 'second');
|
||||
trie.set('test', 'third');
|
||||
trie.set('test2', 'not expected');
|
||||
trie.set('team', 'not expected');
|
||||
trie.set('the', 'not expected');
|
||||
|
||||
assert.deepStrictEqual(trie.findAll('test'), [
|
||||
{ remainingKey: '', value: 'third' },
|
||||
{ remainingKey: 'st', value: 'second' },
|
||||
{ remainingKey: 'est', value: 'first' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns empty array when no matches found', function () {
|
||||
trie.set('abc', 'value');
|
||||
trie.set('xyz1', 'value');
|
||||
trie.set('xyz2', 'value');
|
||||
assert.deepStrictEqual(trie.findAll('xyz'), []);
|
||||
});
|
||||
|
||||
test('updates the least recently used when accessed', function () {
|
||||
trie = new LRURadixTrie<string>(3);
|
||||
trie.set('a', 'first');
|
||||
trie.set('b', 'second');
|
||||
trie.set('c', 'third');
|
||||
trie.findAll('a');
|
||||
trie.set('d', 'fourth');
|
||||
assert.deepStrictEqual(trie.findAll('b'), []);
|
||||
assert.deepStrictEqual(trie.findAll('c'), [{ remainingKey: '', value: 'third' }]);
|
||||
assert.deepStrictEqual(trie.findAll('d'), [{ remainingKey: '', value: 'fourth' }]);
|
||||
assert.deepStrictEqual(trie.findAll('a'), [{ remainingKey: '', value: 'first' }]);
|
||||
});
|
||||
});
|
||||
|
||||
suite('delete', function () {
|
||||
test('removes a value', function () {
|
||||
trie.set('test', 'value');
|
||||
trie.delete('test');
|
||||
assert.deepStrictEqual(trie.findAll('test'), []);
|
||||
});
|
||||
|
||||
test('handles merging child node after delete', function () {
|
||||
trie.set('test', 'first');
|
||||
trie.set('testing', 'second');
|
||||
|
||||
trie.delete('test');
|
||||
|
||||
assert.deepStrictEqual(trie.findAll('test'), []);
|
||||
assert.deepStrictEqual(trie.findAll('testing'), [{ remainingKey: '', value: 'second' }]);
|
||||
});
|
||||
|
||||
test('handles merging sibling node after delete', function () {
|
||||
trie.set('test', 'first');
|
||||
trie.set('testing', 'second');
|
||||
trie.set('testy', 'third');
|
||||
|
||||
trie.delete('test');
|
||||
trie.delete('testing');
|
||||
|
||||
assert.deepStrictEqual(trie.findAll('test'), []);
|
||||
assert.deepStrictEqual(trie.findAll('testing'), []);
|
||||
assert.deepStrictEqual(trie.findAll('testy'), [{ remainingKey: '', value: 'third' }]);
|
||||
});
|
||||
|
||||
test('does nothing when key not found', function () {
|
||||
trie.set('test', 'value');
|
||||
trie.delete('other');
|
||||
assert.deepStrictEqual(trie.findAll('test'), [{ remainingKey: '', value: 'value' }]);
|
||||
});
|
||||
});
|
||||
|
||||
test('handles unicode characters with multiple code points', function () {
|
||||
/* Note: this behavior is arguably incorrect. Ideally, unicode characters
|
||||
* comprising multiple code points would be treated as single characters.
|
||||
* However, to do so would require converting all strings to arrays with
|
||||
* Array.from and no longer using native string methods such as
|
||||
* startsWith. This performance hit from that is likely not worth fixing
|
||||
* this behavior. */
|
||||
trie.set('🤦', 'no modifiers');
|
||||
trie.set('🤦ðŸ½', 'type 3');
|
||||
trie.set('🤦ðŸ½â€â™‚', 'man type 3');
|
||||
assert.deepStrictEqual(trie.findAll('🤦ðŸ½â€â™‚ï¸'), [
|
||||
{ remainingKey: 'ï¸', value: 'man type 3' },
|
||||
{ remainingKey: 'â€â™‚ï¸', value: 'type 3' },
|
||||
{ remainingKey: 'ðŸ½â€â™‚ï¸', value: 'no modifiers' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { CancellationToken, Position, Range } from 'vscode-languageserver-protocol';
|
||||
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CompletionState, createCompletionState } from './completionState';
|
||||
import { completionsFromGhostTextResults, CopilotCompletion } from './ghostText/copilotCompletion';
|
||||
import { getGhostText, GetGhostTextOptions, ResultType } from './ghostText/ghostText';
|
||||
import { setLastShown } from './ghostText/last';
|
||||
import { ITextEditorOptions } from './ghostText/normalizeIndent';
|
||||
import { ICompletionsSpeculativeRequestCache } from './ghostText/speculativeRequestCache';
|
||||
import { GhostTextResultWithTelemetry, handleGhostTextResultTelemetry, logger } from './ghostText/telemetry';
|
||||
import { ICompletionsLogTargetService } from './logger';
|
||||
import { ITextDocument, TextDocumentContents } from './textDocument';
|
||||
|
||||
type GetInlineCompletionsOptions = Partial<GetGhostTextOptions> & {
|
||||
formattingOptions?: ITextEditorOptions;
|
||||
};
|
||||
|
||||
export class GhostText {
|
||||
|
||||
constructor(
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ICompletionsLogTargetService private readonly logTargetService: ICompletionsLogTargetService,
|
||||
@ICompletionsSpeculativeRequestCache private readonly speculativeRequestCache: ICompletionsSpeculativeRequestCache,
|
||||
) { }
|
||||
|
||||
public async getInlineCompletions(
|
||||
textDocument: ITextDocument,
|
||||
position: Position,
|
||||
token?: CancellationToken,
|
||||
options: Exclude<Partial<GetInlineCompletionsOptions>, 'promptOnly'> = {}
|
||||
): Promise<CopilotCompletion[] | undefined> {
|
||||
logCompletionLocation(this.logTargetService, textDocument, position);
|
||||
|
||||
const result = await this.getInlineCompletionsResult(createCompletionState(textDocument, position), token, options);
|
||||
return this.instantiationService.invokeFunction(handleGhostTextResultTelemetry, result);
|
||||
}
|
||||
|
||||
private async getInlineCompletionsResult(
|
||||
completionState: CompletionState,
|
||||
token?: CancellationToken,
|
||||
options: GetInlineCompletionsOptions = {}
|
||||
): Promise<GhostTextResultWithTelemetry<CopilotCompletion[]>> {
|
||||
let lineLengthIncrease = 0;
|
||||
// The golang.go extension (and quite possibly others) uses snippets for function completions, which collapse down
|
||||
// to look like empty function calls (e.g., `foo()`) in selectedCompletionInfo.text. Injecting that directly into
|
||||
// the prompt produces low quality completions, so don't.
|
||||
if (options.selectedCompletionInfo?.text && !options.selectedCompletionInfo.text.includes(')')) {
|
||||
completionState = completionState.addSelectedCompletionInfo(options.selectedCompletionInfo);
|
||||
lineLengthIncrease = completionState.position.character - options.selectedCompletionInfo.range.end.character;
|
||||
}
|
||||
|
||||
const result = await this.instantiationService.invokeFunction(getGhostText, completionState, token, options);
|
||||
if (result.type !== 'success') { return result; }
|
||||
const [resultArray, resultType] = result.value;
|
||||
|
||||
if (token?.isCancellationRequested) {
|
||||
return {
|
||||
type: 'canceled',
|
||||
reason: 'after getGhostText',
|
||||
telemetryData: { telemetryBlob: result.telemetryBlob },
|
||||
};
|
||||
}
|
||||
|
||||
const index = this.instantiationService.invokeFunction(setLastShown, completionState.textDocument, completionState.position, resultType);
|
||||
|
||||
const completions = completionsFromGhostTextResults(
|
||||
resultArray,
|
||||
resultType,
|
||||
completionState.textDocument,
|
||||
completionState.position,
|
||||
options.formattingOptions,
|
||||
index
|
||||
);
|
||||
if (completions.length === 0) {
|
||||
// This is a backstop, most/all cases of an empty completions list should be caught earlier
|
||||
// TODO: figure out how this accounts for 7% of ghostText.empty when it looks unreachable
|
||||
return { type: 'empty', reason: 'no completions in final result', telemetryData: result.telemetryData };
|
||||
}
|
||||
|
||||
// Speculatively request a new completion including the newly returned completion in the document
|
||||
if (resultType !== ResultType.TypingAsSuggested) {
|
||||
completionState = completionState.applyEdits([
|
||||
{
|
||||
newText: completions[0].insertText,
|
||||
range: completions[0].range,
|
||||
},
|
||||
]);
|
||||
|
||||
// Cache speculative request to be triggered when telemetryShown is called
|
||||
const specOpts = { isSpeculative: true, opportunityId: options.opportunityId };
|
||||
const fn = () => this.instantiationService.invokeFunction(getGhostText, completionState, undefined, specOpts);
|
||||
this.speculativeRequestCache.set(completions[0].clientCompletionId, fn);
|
||||
}
|
||||
|
||||
const value = completions.map(completion => {
|
||||
const { start, end } = completion.range;
|
||||
const range = Range.create(start, Position.create(end.line, end.character - lineLengthIncrease));
|
||||
return { ...completion, range };
|
||||
});
|
||||
return { ...result, value };
|
||||
}
|
||||
}
|
||||
|
||||
function logCompletionLocation(logTarget: ICompletionsLogTargetService, textDocument: TextDocumentContents, position: Position) {
|
||||
const prefix = textDocument.getText({
|
||||
start: { line: Math.max(position.line - 1, 0), character: 0 },
|
||||
end: position,
|
||||
});
|
||||
const suffix = textDocument.getText({
|
||||
start: position,
|
||||
end: {
|
||||
line: Math.min(position.line + 2, textDocument.lineCount - 1),
|
||||
character: textDocument.lineCount - 1 > position.line ? 0 : position.character,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
logTarget,
|
||||
`Requesting for ${textDocument.uri} at ${position.line}:${position.character}`,
|
||||
`between ${JSON.stringify(prefix)} and ${JSON.stringify(suffix)}.`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// This file is generated by running 'npm run generate_languages'
|
||||
// a map of all known languages (see languageMarkers) with their extensions and filenames as they are defined in linguist
|
||||
export const knownLanguages: { [language: string]: { extensions: string[]; filenames?: string[] } } = {
|
||||
abap: {
|
||||
extensions: ['.abap'],
|
||||
},
|
||||
aspdotnet: {
|
||||
extensions: ['.asax', '.ascx', '.ashx', '.asmx', '.aspx', '.axd'],
|
||||
},
|
||||
bat: {
|
||||
extensions: ['.bat', '.cmd'],
|
||||
},
|
||||
bibtex: {
|
||||
extensions: ['.bib', '.bibtex'],
|
||||
},
|
||||
blade: {
|
||||
extensions: ['.blade', '.blade.php'],
|
||||
},
|
||||
BluespecSystemVerilog: {
|
||||
extensions: ['.bsv'],
|
||||
},
|
||||
c: {
|
||||
extensions: ['.c', '.cats', '.h', '.h.in', '.idc'],
|
||||
},
|
||||
csharp: {
|
||||
extensions: ['.cake', '.cs', '.cs.pp', '.csx', '.linq'],
|
||||
},
|
||||
cpp: {
|
||||
extensions: [
|
||||
'.c++',
|
||||
'.cc',
|
||||
'.cp',
|
||||
'.cpp',
|
||||
'.cppm',
|
||||
'.cxx',
|
||||
'.h',
|
||||
'.h++',
|
||||
'.hh',
|
||||
'.hpp',
|
||||
'.hxx',
|
||||
'.idl',
|
||||
'.inc',
|
||||
'.inl',
|
||||
'.ino',
|
||||
'.ipp',
|
||||
'.ixx',
|
||||
'.rc',
|
||||
'.re',
|
||||
'.tcc',
|
||||
'.tpp',
|
||||
'.txx',
|
||||
'.i',
|
||||
],
|
||||
},
|
||||
cobol: {
|
||||
extensions: ['.cbl', '.ccp', '.cob', '.cobol', '.cpy'],
|
||||
},
|
||||
css: {
|
||||
extensions: ['.css', '.wxss'],
|
||||
},
|
||||
clojure: {
|
||||
extensions: ['.bb', '.boot', '.cl2', '.clj', '.cljc', '.cljs', '.cljs.hl', '.cljscm', '.cljx', '.edn', '.hic'],
|
||||
filenames: ['riemann.config'],
|
||||
},
|
||||
ql: {
|
||||
extensions: ['.ql', '.qll'],
|
||||
},
|
||||
coffeescript: {
|
||||
extensions: ['._coffee', '.cake', '.cjsx', '.coffee', '.iced'],
|
||||
filenames: ['Cakefile'],
|
||||
},
|
||||
cuda: {
|
||||
extensions: ['.cu', '.cuh'],
|
||||
},
|
||||
dart: {
|
||||
extensions: ['.dart'],
|
||||
},
|
||||
dockerfile: {
|
||||
extensions: ['.containerfile', '.dockerfile'],
|
||||
filenames: ['Containerfile', 'Dockerfile'],
|
||||
},
|
||||
dotenv: {
|
||||
extensions: ['.env'],
|
||||
filenames: [
|
||||
'.env',
|
||||
'.env.ci',
|
||||
'.env.dev',
|
||||
'.env.development',
|
||||
'.env.development.local',
|
||||
'.env.example',
|
||||
'.env.local',
|
||||
'.env.prod',
|
||||
'.env.production',
|
||||
'.env.sample',
|
||||
'.env.staging',
|
||||
'.env.test',
|
||||
'.env.testing',
|
||||
],
|
||||
},
|
||||
html: {
|
||||
extensions: [
|
||||
'.ect',
|
||||
'.ejs',
|
||||
'.ejs.t',
|
||||
'.jst',
|
||||
'.hta',
|
||||
'.htm',
|
||||
'.html',
|
||||
'.html.hl',
|
||||
'.html5',
|
||||
'.inc',
|
||||
'.jsp',
|
||||
'.njk',
|
||||
'.tpl',
|
||||
'.twig',
|
||||
'.wxml',
|
||||
'.xht',
|
||||
'.xhtml',
|
||||
'.phtml',
|
||||
'.liquid',
|
||||
],
|
||||
},
|
||||
elixir: {
|
||||
extensions: ['.ex', '.exs'],
|
||||
filenames: ['mix.lock'],
|
||||
},
|
||||
erlang: {
|
||||
extensions: ['.app', '.app.src', '.erl', '.es', '.escript', '.hrl', '.xrl', '.yrl'],
|
||||
filenames: ['Emakefile', 'rebar.config', 'rebar.config.lock', 'rebar.lock'],
|
||||
},
|
||||
fsharp: {
|
||||
extensions: ['.fs', '.fsi', '.fsx'],
|
||||
},
|
||||
go: {
|
||||
extensions: ['.go'],
|
||||
},
|
||||
groovy: {
|
||||
extensions: ['.gradle', '.groovy', '.grt', '.gtpl', '.gvy', '.jenkinsfile'],
|
||||
filenames: ['Jenkinsfile', 'Jenkinsfile'],
|
||||
},
|
||||
graphql: {
|
||||
extensions: ['.gql', '.graphql', '.graphqls'],
|
||||
},
|
||||
terraform: {
|
||||
extensions: ['.hcl', '.nomad', '.tf', '.tfvars', '.workflow'],
|
||||
},
|
||||
hlsl: {
|
||||
extensions: ['.cginc', '.fx', '.fxh', '.hlsl', '.hlsli'],
|
||||
},
|
||||
erb: {
|
||||
extensions: ['.erb', '.erb.deface', '.rhtml'],
|
||||
},
|
||||
razor: {
|
||||
extensions: ['.cshtml', '.razor'],
|
||||
},
|
||||
haml: {
|
||||
extensions: ['.haml', '.haml.deface'],
|
||||
},
|
||||
handlebars: {
|
||||
extensions: ['.handlebars', '.hbs'],
|
||||
},
|
||||
haskell: {
|
||||
extensions: ['.hs', '.hs-boot', '.hsc'],
|
||||
},
|
||||
ini: {
|
||||
extensions: ['.cfg', '.cnf', '.dof', '.ini', '.lektorproject', '.prefs', '.pro', '.properties', '.url'],
|
||||
filenames: [
|
||||
'.buckconfig',
|
||||
'.coveragerc',
|
||||
'.flake8',
|
||||
'.pylintrc',
|
||||
'HOSTS',
|
||||
'buildozer.spec',
|
||||
'hosts',
|
||||
'pylintrc',
|
||||
'vlcrc',
|
||||
],
|
||||
},
|
||||
json: {
|
||||
extensions: [
|
||||
'.4DForm',
|
||||
'.4DProject',
|
||||
'.JSON-tmLanguage',
|
||||
'.avsc',
|
||||
'.geojson',
|
||||
'.gltf',
|
||||
'.har',
|
||||
'.ice',
|
||||
'.json',
|
||||
'.json.example',
|
||||
'.jsonl',
|
||||
'.mcmeta',
|
||||
'.sarif',
|
||||
'.tact',
|
||||
'.tfstate',
|
||||
'.tfstate.backup',
|
||||
'.topojson',
|
||||
'.webapp',
|
||||
'.webmanifest',
|
||||
'.yy',
|
||||
'.yyp',
|
||||
],
|
||||
filenames: [
|
||||
'.all-contributorsrc',
|
||||
'.arcconfig',
|
||||
'.auto-changelog',
|
||||
'.c8rc',
|
||||
'.htmlhintrc',
|
||||
'.imgbotconfig',
|
||||
'.nycrc',
|
||||
'.tern-config',
|
||||
'.tern-project',
|
||||
'.watchmanconfig',
|
||||
'MODULE.bazel.lock',
|
||||
'Package.resolved',
|
||||
'Pipfile.lock',
|
||||
'bun.lock',
|
||||
'composer.lock',
|
||||
'deno.lock',
|
||||
'flake.lock',
|
||||
'mcmod.info',
|
||||
],
|
||||
},
|
||||
jsonc: {
|
||||
extensions: [
|
||||
'.code-snippets',
|
||||
'.code-workspace',
|
||||
'.jsonc',
|
||||
'.sublime-build',
|
||||
'.sublime-color-scheme',
|
||||
'.sublime-commands',
|
||||
'.sublime-completions',
|
||||
'.sublime-keymap',
|
||||
'.sublime-macro',
|
||||
'.sublime-menu',
|
||||
'.sublime-mousemap',
|
||||
'.sublime-project',
|
||||
'.sublime-settings',
|
||||
'.sublime-theme',
|
||||
'.sublime-workspace',
|
||||
'.sublime_metrics',
|
||||
'.sublime_session',
|
||||
],
|
||||
filenames: [
|
||||
'.babelrc',
|
||||
'.devcontainer.json',
|
||||
'.eslintrc.json',
|
||||
'.jscsrc',
|
||||
'.jshintrc',
|
||||
'.jslintrc',
|
||||
'.swcrc',
|
||||
'api-extractor.json',
|
||||
'argv.json',
|
||||
'devcontainer.json',
|
||||
'extensions.json',
|
||||
'jsconfig.json',
|
||||
'keybindings.json',
|
||||
'language-configuration.json',
|
||||
'launch.json',
|
||||
'profiles.json',
|
||||
'settings.json',
|
||||
'tasks.json',
|
||||
'tsconfig.json',
|
||||
'tslint.json',
|
||||
],
|
||||
},
|
||||
java: {
|
||||
extensions: ['.jav', '.java', '.jsh'],
|
||||
},
|
||||
javascript: {
|
||||
extensions: [
|
||||
'._js',
|
||||
'.bones',
|
||||
'.cjs',
|
||||
'.es',
|
||||
'.es6',
|
||||
'.frag',
|
||||
'.gs',
|
||||
'.jake',
|
||||
'.javascript',
|
||||
'.js',
|
||||
'.jsb',
|
||||
'.jscad',
|
||||
'.jsfl',
|
||||
'.jslib',
|
||||
'.jsm',
|
||||
'.jspre',
|
||||
'.jss',
|
||||
'.mjs',
|
||||
'.njs',
|
||||
'.pac',
|
||||
'.sjs',
|
||||
'.ssjs',
|
||||
'.xsjs',
|
||||
'.xsjslib',
|
||||
],
|
||||
filenames: ['Jakefile'],
|
||||
},
|
||||
julia: {
|
||||
extensions: ['.jl'],
|
||||
},
|
||||
kotlin: {
|
||||
extensions: ['.kt', '.ktm', '.kts'],
|
||||
},
|
||||
less: {
|
||||
extensions: ['.less'],
|
||||
},
|
||||
lua: {
|
||||
extensions: ['.fcgi', '.lua', '.luau', '.nse', '.p8', '.pd_lua', '.rbxs', '.rockspec', '.wlua'],
|
||||
filenames: ['.luacheckrc'],
|
||||
},
|
||||
makefile: {
|
||||
extensions: ['.d', '.mak', '.make', '.makefile', '.mk', '.mkfile'],
|
||||
filenames: [
|
||||
'BSDmakefile',
|
||||
'GNUmakefile',
|
||||
'Kbuild',
|
||||
'Makefile',
|
||||
'Makefile.am',
|
||||
'Makefile.boot',
|
||||
'Makefile.frag',
|
||||
'Makefile.in',
|
||||
'Makefile.inc',
|
||||
'Makefile.wat',
|
||||
'makefile',
|
||||
'makefile.sco',
|
||||
'mkfile',
|
||||
],
|
||||
},
|
||||
markdown: {
|
||||
extensions: [
|
||||
'.livemd',
|
||||
'.markdown',
|
||||
'.md',
|
||||
'.mdown',
|
||||
'.mdwn',
|
||||
'.mdx',
|
||||
'.mkd',
|
||||
'.mkdn',
|
||||
'.mkdown',
|
||||
'.ronn',
|
||||
'.scd',
|
||||
'.workbook',
|
||||
],
|
||||
filenames: ['contents.lr'],
|
||||
},
|
||||
'objective-c': {
|
||||
extensions: ['.h', '.m'],
|
||||
},
|
||||
'objective-cpp': {
|
||||
extensions: ['.mm'],
|
||||
},
|
||||
php: {
|
||||
extensions: [
|
||||
'.aw',
|
||||
'.ctp',
|
||||
'.fcgi',
|
||||
'.inc',
|
||||
'.install',
|
||||
'.module',
|
||||
'.php',
|
||||
'.php3',
|
||||
'.php4',
|
||||
'.php5',
|
||||
'.phps',
|
||||
'.phpt',
|
||||
'.theme',
|
||||
],
|
||||
filenames: ['.php', '.php_cs', '.php_cs.dist', 'Phakefile'],
|
||||
},
|
||||
perl: {
|
||||
extensions: ['.al', '.cgi', '.fcgi', '.perl', '.ph', '.pl', '.plx', '.pm', '.psgi', '.t'],
|
||||
filenames: ['.latexmkrc', 'Makefile.PL', 'Rexfile', 'ack', 'cpanfile', 'latexmkrc'],
|
||||
},
|
||||
powershell: {
|
||||
extensions: ['.ps1', '.psd1', '.psm1'],
|
||||
},
|
||||
pug: {
|
||||
extensions: ['.jade', '.pug'],
|
||||
},
|
||||
python: {
|
||||
extensions: [
|
||||
'.cgi',
|
||||
'.codon',
|
||||
'.fcgi',
|
||||
'.gyp',
|
||||
'.gypi',
|
||||
'.lmi',
|
||||
'.py',
|
||||
'.py3',
|
||||
'.pyde',
|
||||
'.pyi',
|
||||
'.pyp',
|
||||
'.pyt',
|
||||
'.pyw',
|
||||
'.rpy',
|
||||
'.sage',
|
||||
'.spec',
|
||||
'.tac',
|
||||
'.wsgi',
|
||||
'.xpy',
|
||||
],
|
||||
filenames: ['.gclient', 'DEPS', 'SConscript', 'SConstruct', 'wscript'],
|
||||
},
|
||||
r: {
|
||||
extensions: ['.r', '.rd', '.rsx'],
|
||||
filenames: ['.Rprofile', 'expr-dist'],
|
||||
},
|
||||
ruby: {
|
||||
extensions: [
|
||||
'.builder',
|
||||
'.eye',
|
||||
'.fcgi',
|
||||
'.gemspec',
|
||||
'.god',
|
||||
'.jbuilder',
|
||||
'.mspec',
|
||||
'.pluginspec',
|
||||
'.podspec',
|
||||
'.prawn',
|
||||
'.rabl',
|
||||
'.rake',
|
||||
'.rb',
|
||||
'.rbi',
|
||||
'.rbuild',
|
||||
'.rbw',
|
||||
'.rbx',
|
||||
'.ru',
|
||||
'.ruby',
|
||||
'.spec',
|
||||
'.thor',
|
||||
'.watchr',
|
||||
],
|
||||
filenames: [
|
||||
'.irbrc',
|
||||
'.pryrc',
|
||||
'.simplecov',
|
||||
'Appraisals',
|
||||
'Berksfile',
|
||||
'Brewfile',
|
||||
'Buildfile',
|
||||
'Capfile',
|
||||
'Dangerfile',
|
||||
'Deliverfile',
|
||||
'Fastfile',
|
||||
'Gemfile',
|
||||
'Guardfile',
|
||||
'Jarfile',
|
||||
'Mavenfile',
|
||||
'Podfile',
|
||||
'Puppetfile',
|
||||
'Rakefile',
|
||||
'Snapfile',
|
||||
'Steepfile',
|
||||
'Thorfile',
|
||||
'Vagrantfile',
|
||||
'buildfile',
|
||||
],
|
||||
},
|
||||
rust: {
|
||||
extensions: ['.rs', '.rs.in'],
|
||||
},
|
||||
scss: {
|
||||
extensions: ['.scss'],
|
||||
},
|
||||
sql: {
|
||||
extensions: ['.cql', '.ddl', '.inc', '.mysql', '.prc', '.sql', '.tab', '.udf', '.viw'],
|
||||
},
|
||||
sass: {
|
||||
extensions: ['.sass'],
|
||||
},
|
||||
scala: {
|
||||
extensions: ['.kojo', '.sbt', '.sc', '.scala'],
|
||||
},
|
||||
shellscript: {
|
||||
extensions: [
|
||||
'.bash',
|
||||
'.bats',
|
||||
'.cgi',
|
||||
'.command',
|
||||
'.fcgi',
|
||||
'.fish',
|
||||
'.ksh',
|
||||
'.sh',
|
||||
'.sh.in',
|
||||
'.tmux',
|
||||
'.tool',
|
||||
'.trigger',
|
||||
'.zsh',
|
||||
'.zsh-theme',
|
||||
],
|
||||
filenames: [
|
||||
'.bash_aliases',
|
||||
'.bash_functions',
|
||||
'.bash_history',
|
||||
'.bash_logout',
|
||||
'.bash_profile',
|
||||
'.bashrc',
|
||||
'.cshrc',
|
||||
'.envrc',
|
||||
'.flaskenv',
|
||||
'.kshrc',
|
||||
'.login',
|
||||
'.profile',
|
||||
'.tmux.conf',
|
||||
'.zlogin',
|
||||
'.zlogout',
|
||||
'.zprofile',
|
||||
'.zshenv',
|
||||
'.zshrc',
|
||||
'9fs',
|
||||
'PKGBUILD',
|
||||
'bash_aliases',
|
||||
'bash_logout',
|
||||
'bash_profile',
|
||||
'bashrc',
|
||||
'cshrc',
|
||||
'gradlew',
|
||||
'kshrc',
|
||||
'login',
|
||||
'man',
|
||||
'profile',
|
||||
'tmux.conf',
|
||||
'zlogin',
|
||||
'zlogout',
|
||||
'zprofile',
|
||||
'zshenv',
|
||||
'zshrc',
|
||||
],
|
||||
},
|
||||
slang: {
|
||||
extensions: ['.fxc', '.hlsl', '.s', '.slang', '.slangh', '.usf', '.ush', '.vfx'],
|
||||
},
|
||||
slim: {
|
||||
extensions: ['.slim'],
|
||||
},
|
||||
solidity: {
|
||||
extensions: ['.sol'],
|
||||
},
|
||||
stylus: {
|
||||
extensions: ['.styl'],
|
||||
},
|
||||
svelte: {
|
||||
extensions: ['.svelte'],
|
||||
},
|
||||
swift: {
|
||||
extensions: ['.swift'],
|
||||
},
|
||||
systemverilog: {
|
||||
extensions: ['.sv', '.svh', '.vh'],
|
||||
},
|
||||
typescriptreact: {
|
||||
extensions: ['.tsx'],
|
||||
},
|
||||
latex: {
|
||||
extensions: [
|
||||
'.aux',
|
||||
'.bbx',
|
||||
'.cbx',
|
||||
'.cls',
|
||||
'.dtx',
|
||||
'.ins',
|
||||
'.lbx',
|
||||
'.ltx',
|
||||
'.mkii',
|
||||
'.mkiv',
|
||||
'.mkvi',
|
||||
'.sty',
|
||||
'.tex',
|
||||
'.toc',
|
||||
],
|
||||
},
|
||||
typescript: {
|
||||
extensions: ['.cts', '.mts', '.ts'],
|
||||
},
|
||||
verilog: {
|
||||
extensions: ['.v', '.veo'],
|
||||
},
|
||||
vim: {
|
||||
extensions: ['.vba', '.vim', '.vimrc', '.vmb'],
|
||||
filenames: ['.exrc', '.gvimrc', '.nvimrc', '.vimrc', '_vimrc', 'gvimrc', 'nvimrc', 'vimrc'],
|
||||
},
|
||||
vb: {
|
||||
extensions: ['.vb', '.vbhtml', '.Dsr', '.bas', '.cls', '.ctl', '.frm', '.vbs'],
|
||||
},
|
||||
vue: {
|
||||
extensions: ['.nvue', '.vue'],
|
||||
},
|
||||
xml: {
|
||||
extensions: [
|
||||
'.adml',
|
||||
'.admx',
|
||||
'.ant',
|
||||
'.axaml',
|
||||
'.axml',
|
||||
'.builds',
|
||||
'.ccproj',
|
||||
'.ccxml',
|
||||
'.clixml',
|
||||
'.cproject',
|
||||
'.cscfg',
|
||||
'.csdef',
|
||||
'.csl',
|
||||
'.csproj',
|
||||
'.ct',
|
||||
'.depproj',
|
||||
'.dita',
|
||||
'.ditamap',
|
||||
'.ditaval',
|
||||
'.dll.config',
|
||||
'.dotsettings',
|
||||
'.filters',
|
||||
'.fsproj',
|
||||
'.fxml',
|
||||
'.glade',
|
||||
'.gml',
|
||||
'.gmx',
|
||||
'.gpx',
|
||||
'.grxml',
|
||||
'.gst',
|
||||
'.hzp',
|
||||
'.iml',
|
||||
'.ivy',
|
||||
'.jelly',
|
||||
'.jsproj',
|
||||
'.kml',
|
||||
'.launch',
|
||||
'.mdpolicy',
|
||||
'.mjml',
|
||||
'.mod',
|
||||
'.mojo',
|
||||
'.mxml',
|
||||
'.natvis',
|
||||
'.ncl',
|
||||
'.ndproj',
|
||||
'.nproj',
|
||||
'.nuspec',
|
||||
'.odd',
|
||||
'.osm',
|
||||
'.pkgproj',
|
||||
'.plist',
|
||||
'.pluginspec',
|
||||
'.proj',
|
||||
'.props',
|
||||
'.ps1xml',
|
||||
'.psc1',
|
||||
'.pt',
|
||||
'.pubxml',
|
||||
'.qhelp',
|
||||
'.rdf',
|
||||
'.res',
|
||||
'.resx',
|
||||
'.rss',
|
||||
'.sch',
|
||||
'.scxml',
|
||||
'.sfproj',
|
||||
'.shproj',
|
||||
'.srdf',
|
||||
'.storyboard',
|
||||
'.sublime-snippet',
|
||||
'.svg',
|
||||
'.sw',
|
||||
'.targets',
|
||||
'.tml',
|
||||
'.typ',
|
||||
'.ui',
|
||||
'.urdf',
|
||||
'.ux',
|
||||
'.vbproj',
|
||||
'.vcxproj',
|
||||
'.vsixmanifest',
|
||||
'.vssettings',
|
||||
'.vstemplate',
|
||||
'.vxml',
|
||||
'.wixproj',
|
||||
'.workflow',
|
||||
'.wsdl',
|
||||
'.wsf',
|
||||
'.wxi',
|
||||
'.wxl',
|
||||
'.wxs',
|
||||
'.x3d',
|
||||
'.xacro',
|
||||
'.xaml',
|
||||
'.xib',
|
||||
'.xlf',
|
||||
'.xliff',
|
||||
'.xmi',
|
||||
'.xml',
|
||||
'.xml.dist',
|
||||
'.xmp',
|
||||
'.xproj',
|
||||
'.xsd',
|
||||
'.xspec',
|
||||
'.xul',
|
||||
'.zcml',
|
||||
],
|
||||
filenames: [
|
||||
'.classpath',
|
||||
'.cproject',
|
||||
'.project',
|
||||
'App.config',
|
||||
'NuGet.config',
|
||||
'Settings.StyleCop',
|
||||
'Web.Debug.config',
|
||||
'Web.Release.config',
|
||||
'Web.config',
|
||||
'packages.config',
|
||||
],
|
||||
},
|
||||
xsl: {
|
||||
extensions: ['.xsl', '.xslt'],
|
||||
},
|
||||
yaml: {
|
||||
extensions: [
|
||||
'.mir',
|
||||
'.reek',
|
||||
'.rviz',
|
||||
'.sublime-syntax',
|
||||
'.syntax',
|
||||
'.yaml',
|
||||
'.yaml-tmlanguage',
|
||||
'.yaml.sed',
|
||||
'.yml',
|
||||
'.yml.mysql',
|
||||
],
|
||||
filenames: [
|
||||
'.clang-format',
|
||||
'.clang-tidy',
|
||||
'.clangd',
|
||||
'.gemrc',
|
||||
'CITATION.cff',
|
||||
'glide.lock',
|
||||
'pixi.lock',
|
||||
'yarn.lock',
|
||||
],
|
||||
},
|
||||
javascriptreact: {
|
||||
extensions: ['.jsx'],
|
||||
},
|
||||
legend: {
|
||||
extensions: ['.pure'],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { knownLanguages } from './generatedLanguages';
|
||||
import {
|
||||
knownFileExtensions,
|
||||
knownTemplateLanguageExtensions,
|
||||
templateLanguageLimitations,
|
||||
} from './languages';
|
||||
import { basename } from '../util/uri';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export class Language {
|
||||
constructor(
|
||||
readonly languageId: string,
|
||||
readonly isGuess: boolean,
|
||||
readonly fileExtension: string
|
||||
) { }
|
||||
}
|
||||
|
||||
interface LanguageDetectionInput {
|
||||
languageId: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export abstract class LanguageDetection {
|
||||
abstract detectLanguage(doc: LanguageDetectionInput): Language;
|
||||
}
|
||||
|
||||
type LanguageIdWithGuessing = { languageId: string; isGuess: boolean };
|
||||
|
||||
const knownExtensions = new Map<string, string[]>();
|
||||
const knownFilenames = new Map<string, string[]>();
|
||||
|
||||
for (const [languageId, { extensions, filenames }] of Object.entries(knownLanguages)) {
|
||||
for (const extension of extensions) {
|
||||
knownExtensions.set(extension, [...(knownExtensions.get(extension) ?? []), languageId]);
|
||||
}
|
||||
for (const filename of filenames ?? []) {
|
||||
knownFilenames.set(filename, [...(knownFilenames.get(filename) ?? []), languageId]);
|
||||
}
|
||||
}
|
||||
|
||||
class FilenameAndExensionLanguageDetection extends LanguageDetection {
|
||||
detectLanguage(doc: LanguageDetectionInput): Language {
|
||||
const filename = basename(doc.uri);
|
||||
const extension = path.extname(filename).toLowerCase();
|
||||
const extensionWithoutTemplate = this.extensionWithoutTemplateLanguage(filename, extension);
|
||||
const languageIdWithGuessing = this.detectLanguageId(filename, extensionWithoutTemplate);
|
||||
const ext = this.computeFullyQualifiedExtension(extension, extensionWithoutTemplate);
|
||||
if (!languageIdWithGuessing) {
|
||||
return new Language(doc.languageId, true, ext);
|
||||
}
|
||||
return new Language(languageIdWithGuessing.languageId, languageIdWithGuessing.isGuess, ext);
|
||||
}
|
||||
|
||||
private extensionWithoutTemplateLanguage(filename: string, extension: string): string {
|
||||
if (knownTemplateLanguageExtensions.includes(extension)) {
|
||||
const filenameWithoutExtension = filename.substring(0, filename.lastIndexOf('.'));
|
||||
const extensionWithoutTemplate = path.extname(filenameWithoutExtension).toLowerCase();
|
||||
const isTemplateLanguage =
|
||||
extensionWithoutTemplate.length > 0 &&
|
||||
knownFileExtensions.includes(extensionWithoutTemplate) &&
|
||||
this.isExtensionValidForTemplateLanguage(extension, extensionWithoutTemplate);
|
||||
if (isTemplateLanguage) {
|
||||
return extensionWithoutTemplate;
|
||||
}
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
private isExtensionValidForTemplateLanguage(extension: string, extensionWithoutTemplate: string): boolean {
|
||||
const limitations = templateLanguageLimitations[extension];
|
||||
return !limitations || limitations.includes(extensionWithoutTemplate);
|
||||
}
|
||||
|
||||
private detectLanguageId(filename: string, extension: string): LanguageIdWithGuessing | undefined {
|
||||
if (knownFilenames.has(filename)) {
|
||||
return { languageId: knownFilenames.get(filename)![0], isGuess: false };
|
||||
}
|
||||
const extensionCandidates = knownExtensions.get(extension) ?? [];
|
||||
if (extensionCandidates.length > 0) {
|
||||
return { languageId: extensionCandidates[0], isGuess: extensionCandidates.length > 1 };
|
||||
}
|
||||
while (filename.includes('.')) {
|
||||
filename = filename.replace(/\.[^.]*$/, '');
|
||||
if (knownFilenames.has(filename)) {
|
||||
return { languageId: knownFilenames.get(filename)![0], isGuess: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private computeFullyQualifiedExtension(extension: string, extensionWithoutTemplate: string): string {
|
||||
if (extension !== extensionWithoutTemplate) {
|
||||
return extensionWithoutTemplate + extension;
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
|
||||
// This class is used to group similar languages together.
|
||||
// The main drawback of trying to keep them apart is that for related files (e.g. header files),
|
||||
// the language detection might be wrong and thus features like neighbor tabs might not work as expected.
|
||||
// In the end, this feature should be moved to neighborTabs.ts (but that's hard to do behind a feature flag)
|
||||
class GroupingLanguageDetection extends LanguageDetection {
|
||||
constructor(private readonly delegate: LanguageDetection) {
|
||||
super();
|
||||
}
|
||||
|
||||
detectLanguage(doc: LanguageDetectionInput): Language {
|
||||
const language = this.delegate.detectLanguage(doc);
|
||||
const languageId = language.languageId;
|
||||
if (languageId === 'c' || languageId === 'cpp') {
|
||||
return new Language('cpp', language.isGuess, language.fileExtension);
|
||||
}
|
||||
return language;
|
||||
}
|
||||
}
|
||||
|
||||
class ClientProvidedLanguageDetection extends LanguageDetection {
|
||||
constructor(private readonly delegate: LanguageDetection) {
|
||||
super();
|
||||
}
|
||||
|
||||
detectLanguage(doc: LanguageDetectionInput): Language {
|
||||
if (doc.uri.startsWith('untitled:') || doc.uri.startsWith('vscode-notebook-cell:')) {
|
||||
return new Language(doc.languageId, true, '');
|
||||
}
|
||||
return this.delegate.detectLanguage(doc);
|
||||
}
|
||||
}
|
||||
|
||||
export const languageDetection = new GroupingLanguageDetection(
|
||||
new ClientProvidedLanguageDetection(new FilenameAndExensionLanguageDetection())
|
||||
);
|
||||
|
||||
export function detectLanguage({ uri, languageId }: { uri: string; languageId: string }): string;
|
||||
export function detectLanguage({ uri }: { uri: string }): string | undefined;
|
||||
export function detectLanguage({ uri, languageId }: { uri: string; languageId?: string }) {
|
||||
const language = languageDetection.detectLanguage({ uri, languageId: 'UNKNOWN' });
|
||||
if (language.languageId === 'UNKNOWN') {
|
||||
return languageId;
|
||||
}
|
||||
return language.languageId;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { knownLanguages } from './generatedLanguages';
|
||||
|
||||
export const knownTemplateLanguageExtensions = [
|
||||
'.ejs',
|
||||
'.erb',
|
||||
'.haml',
|
||||
'.hbs',
|
||||
'.j2',
|
||||
'.jinja',
|
||||
'.jinja2',
|
||||
'.liquid',
|
||||
'.mustache',
|
||||
'.njk',
|
||||
'.php',
|
||||
'.pug',
|
||||
'.slim',
|
||||
'.webc',
|
||||
];
|
||||
|
||||
export const templateLanguageLimitations: { [extension: string]: string[] } = {
|
||||
'.php': ['.blade'],
|
||||
};
|
||||
|
||||
export type LanguageInfo = {
|
||||
extensions: string[];
|
||||
filenames?: string[];
|
||||
};
|
||||
|
||||
export const knownFileExtensions = Object.keys(knownLanguages).flatMap(language => knownLanguages[language].extensions);
|
||||
@@ -0,0 +1,26 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { knownLanguages } from '../generatedLanguages';
|
||||
import { languageMarkers } from '../../../../prompt/src/languageMarker';
|
||||
import * as assert from 'assert';
|
||||
|
||||
suite('generated languages', function () {
|
||||
// tex exists as latex and tex in language markers
|
||||
// jsx exists as jsx and javascriptreact in language markers. However jsx is never detected according to telemetry data
|
||||
// vue-html will be detected as html
|
||||
const ignoredMappings = ['jsx', 'tex', 'vue-html'];
|
||||
|
||||
for (const marker in languageMarkers) {
|
||||
if (!ignoredMappings.includes(marker)) {
|
||||
test(`'${marker}' is generated`, function () {
|
||||
assert.ok(
|
||||
marker in knownLanguages,
|
||||
'language for comment marker ' + marker + ' has not been generated'
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { createTextDocument } from '../../test/textDocument';
|
||||
import { Language, LanguageDetection, languageDetection } from '../languageDetection';
|
||||
|
||||
suite('language detection', function () {
|
||||
test('reuse languages for untitled documents', function () {
|
||||
assert.deepStrictEqual(
|
||||
languageDetection.detectLanguage({ uri: 'untitled:///abc', languageId: 'typescript' }),
|
||||
new Language('typescript', true, '')
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes "c" to "cpp" for untitled documents', function () {
|
||||
assert.deepStrictEqual(
|
||||
languageDetection.detectLanguage({ uri: 'untitled:///abc', languageId: 'c' }),
|
||||
new Language('cpp', true, '')
|
||||
);
|
||||
});
|
||||
|
||||
test('reuse languages for notebook documents', function () {
|
||||
assert.deepStrictEqual(
|
||||
languageDetection.detectLanguage({ uri: 'vscode-notebook-cell:/abc', languageId: 'typescript' }).languageId,
|
||||
'typescript'
|
||||
);
|
||||
});
|
||||
|
||||
const toDetectByExtension: [string, string][] = [
|
||||
['.ts', 'typescript'],
|
||||
['.js', 'javascript'],
|
||||
['.jsx', 'javascriptreact'],
|
||||
['.tsx', 'typescriptreact'],
|
||||
['.html', 'html'],
|
||||
['.html5', 'html'],
|
||||
['.css', 'css'],
|
||||
['.scss', 'scss'],
|
||||
['.less', 'less'],
|
||||
['.jsonc', 'jsonc'],
|
||||
['.json', 'json'],
|
||||
['.xml', 'xml'],
|
||||
['.yml', 'yaml'],
|
||||
['.yaml', 'yaml'],
|
||||
['.php', 'php'],
|
||||
['.py', 'python'],
|
||||
['.rb', 'ruby'],
|
||||
['.go', 'go'],
|
||||
['.java', 'java'],
|
||||
['.cs', 'csharp'],
|
||||
['.cpp', 'cpp'],
|
||||
['.c', 'cpp'],
|
||||
['.C', 'cpp'],
|
||||
['.h', 'cpp'],
|
||||
['.sh', 'shellscript'],
|
||||
['.bash', 'shellscript'],
|
||||
['.sql', 'sql'],
|
||||
['.swift', 'swift'],
|
||||
['.vb', 'vb'],
|
||||
['.frm', 'vb'],
|
||||
['.lua', 'lua'],
|
||||
['.tex', 'latex'],
|
||||
['.md', 'markdown'],
|
||||
['.markdown', 'markdown'],
|
||||
['.r', 'r'],
|
||||
['.R', 'r'],
|
||||
['.blade.php', 'blade'],
|
||||
['.BLADE.php', 'blade'],
|
||||
['.gradle', 'groovy'],
|
||||
['.gradle.kts', 'kotlin'],
|
||||
['.ejs', 'html'],
|
||||
['.liquid', 'html'],
|
||||
['.yml.erb', 'yaml'],
|
||||
['.yml.njk', 'yaml'],
|
||||
['.some.file.yml.njk', 'yaml'],
|
||||
['.phtml', 'html'],
|
||||
['f.sourcecode.php', 'php'],
|
||||
['.plist', 'xml'],
|
||||
['.svg', 'xml'],
|
||||
['.jsp', 'html'],
|
||||
['.code-workspace', 'jsonc'],
|
||||
['.wxss', 'css'],
|
||||
['.luau', 'lua'],
|
||||
['.codon', 'python'],
|
||||
['.edn', 'clojure'],
|
||||
['.tpl', 'html'],
|
||||
['.rs', 'rust'],
|
||||
['.bas', 'vb'],
|
||||
['.wxml', 'html'],
|
||||
['.nvue', 'vue'],
|
||||
['.jenkinsfile', 'groovy'],
|
||||
['.twig', 'html'],
|
||||
['.inc.php', 'php'],
|
||||
['.mm', 'objective-cpp'],
|
||||
['.module', 'php'],
|
||||
['.install', 'php'],
|
||||
['.theme', 'php'],
|
||||
['.rc', 'cpp'],
|
||||
['.idl', 'cpp'],
|
||||
['.pubxml', 'xml'],
|
||||
['.njk', 'html'],
|
||||
['.fish', 'shellscript'],
|
||||
['.vbs', 'vb'],
|
||||
['.sage', 'python'],
|
||||
['.mdx', 'markdown'],
|
||||
['.somethingelse', 'clientProvidedLanguageId'],
|
||||
];
|
||||
|
||||
toDetectByExtension.forEach(([extension, languageId]) => {
|
||||
test(`detect ${languageId} by file extension ${extension}`, function () {
|
||||
assertLanguageId(`file:///test${extension}`, languageId);
|
||||
});
|
||||
});
|
||||
|
||||
const toDetectByFilename: [string, string][] = [
|
||||
['.bash_history', 'shellscript'],
|
||||
['.bashrc', 'shellscript'],
|
||||
['.zshrc', 'shellscript'],
|
||||
['.irbrc', 'ruby'],
|
||||
['Gemfile', 'ruby'],
|
||||
['riemann.config', 'clojure'],
|
||||
['Dockerfile', 'dockerfile'],
|
||||
['Dockerfile.local', 'dockerfile'],
|
||||
['.env.production', 'dotenv'],
|
||||
['.env.development.local', 'dotenv'],
|
||||
['Jenkinsfile', 'groovy'],
|
||||
['Makefile', 'makefile'],
|
||||
['.classpath', 'xml'],
|
||||
['.gemrc', 'yaml'],
|
||||
['tsconfig.json', 'jsonc'],
|
||||
['.eslintrc.json', 'jsonc'],
|
||||
['settings.json', 'jsonc'],
|
||||
['tasks.json', 'jsonc'],
|
||||
['keybindings.json', 'jsonc'],
|
||||
['extensions.json', 'jsonc'],
|
||||
['argv.json', 'jsonc'],
|
||||
['profiles.json', 'jsonc'],
|
||||
['devcontainer.json', 'jsonc'],
|
||||
['.devcontainer.json', 'jsonc'],
|
||||
];
|
||||
|
||||
toDetectByFilename.forEach(([filename, languageId]) => {
|
||||
test(`detect ${languageId} by filename ${filename}`, function () {
|
||||
assertLanguageId(`file:///${filename}`, languageId);
|
||||
});
|
||||
});
|
||||
|
||||
const urls: [string, string][] = [
|
||||
['file:///some/path/test.ts', 'typescript'],
|
||||
['untitled:///some/path/test', 'clientProvidedLanguageId'],
|
||||
['file:////server-name/shared-resource-pathname/test.sh', 'shellscript'],
|
||||
];
|
||||
|
||||
urls.forEach(([url, languageId]) => {
|
||||
test(`detect ${languageId} by url ${url}`, function () {
|
||||
assertLanguageId(url, languageId);
|
||||
});
|
||||
});
|
||||
|
||||
const extensionsToDetect: [string, string][] = [
|
||||
['', ''],
|
||||
['.ts', '.ts'],
|
||||
['a.longer.path.ts', '.ts'],
|
||||
['.sh', '.sh'],
|
||||
['.html.erb', '.html.erb'],
|
||||
['.html.slim', '.html.slim'],
|
||||
['.unknown.erb', '.erb'],
|
||||
['.yaml.njk', '.yaml.njk'],
|
||||
['.unknown', '.unknown'],
|
||||
];
|
||||
|
||||
extensionsToDetect.forEach(([filename, extension]) => {
|
||||
test(`detect extension ${extension} by filename test${filename}`, function () {
|
||||
assertExtension(`file:///test${filename}`, extension);
|
||||
});
|
||||
});
|
||||
|
||||
test(`has no extension for filename without extension`, function () {
|
||||
assertExtension(`file:///.secretproduct`, '');
|
||||
});
|
||||
|
||||
function assertExtension(uri: string, expectedExtension: string) {
|
||||
const doc = createTextDocument(uri, 'clientProvidedLanguageId', 1, 'test content');
|
||||
|
||||
const language = languageDetection.detectLanguage(doc);
|
||||
|
||||
assert.deepStrictEqual(language.fileExtension, expectedExtension);
|
||||
}
|
||||
|
||||
function assertLanguageId(uri: string, expectedLanguageId: string) {
|
||||
const doc = createTextDocument(uri, 'clientProvidedLanguageId', 1, 'test content');
|
||||
|
||||
const language = languageDetection.detectLanguage(doc);
|
||||
|
||||
assert.deepStrictEqual(language.languageId, expectedLanguageId);
|
||||
}
|
||||
|
||||
test('detected languages for ambiguous options will be re-detected', function () {
|
||||
assert.deepStrictEqual(detect('testfile.c', languageDetection).languageId, 'cpp');
|
||||
assert.deepStrictEqual(detect('testfile.h', languageDetection).languageId, 'cpp');
|
||||
assert.deepStrictEqual(detect('testfile.cpp', languageDetection).languageId, 'cpp');
|
||||
assert.deepStrictEqual(detect('testfile.h', languageDetection).languageId, 'cpp');
|
||||
});
|
||||
|
||||
function detect(filename: string, languageDetection: LanguageDetection): Language {
|
||||
return languageDetection.detectLanguage(
|
||||
createTextDocument(`file:///${filename}`, 'clientProvidedLanguageId', 1, 'test content')
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { Stats, promises as fsp } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { FileIdentifier, FileStat, FileType, ICompletionsFileSystemService } from './fileSystem';
|
||||
import { fsPath } from './util/uri';
|
||||
|
||||
export class LocalFileSystem implements ICompletionsFileSystemService {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
async readFileString(uri: FileIdentifier): Promise<string> {
|
||||
return (await fsp.readFile(fsPath(uri))).toString();
|
||||
}
|
||||
|
||||
async stat(uri: FileIdentifier): Promise<FileStat> {
|
||||
const { targetStat, lstat, stat } = await this.statWithLink(fsPath(uri));
|
||||
return {
|
||||
ctime: targetStat.ctimeMs,
|
||||
mtime: targetStat.mtimeMs,
|
||||
size: targetStat.size,
|
||||
type: this.getFileType(targetStat, lstat, stat),
|
||||
};
|
||||
}
|
||||
|
||||
async readDirectory(uri: FileIdentifier): Promise<[string, FileType][]> {
|
||||
const filePath = fsPath(uri);
|
||||
const readDir = await fsp.readdir(filePath, { withFileTypes: true });
|
||||
const result: [string, FileType][] = [];
|
||||
for (const file of readDir) {
|
||||
const { targetStat, lstat, stat } = await this.statWithLink(join(filePath, file.name));
|
||||
result.push([file.name, this.getFileType(targetStat, lstat, stat)]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async statWithLink(fsPath: string): Promise<{ lstat: Stats; stat?: Stats; targetStat: Stats }> {
|
||||
const lstat = await fsp.lstat(fsPath);
|
||||
|
||||
if (lstat.isSymbolicLink()) {
|
||||
try {
|
||||
const stat = await fsp.stat(fsPath);
|
||||
return { lstat, stat, targetStat: stat };
|
||||
} catch {
|
||||
// likely a dangling link or access error
|
||||
}
|
||||
}
|
||||
|
||||
return { lstat, targetStat: lstat };
|
||||
}
|
||||
|
||||
private getFileType(targetStat: Stats, lstat: Stats, stat?: Stats): FileType {
|
||||
let type = FileType.Unknown;
|
||||
if (targetStat.isFile()) {
|
||||
type = FileType.File;
|
||||
}
|
||||
if (targetStat.isDirectory()) {
|
||||
type = FileType.Directory;
|
||||
}
|
||||
// dangling links have FileType.Unknown
|
||||
if (lstat.isSymbolicLink() && stat) {
|
||||
type |= FileType.SymbolicLink;
|
||||
}
|
||||
return type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/**
|
||||
* This file is kept with minimal dependencies to avoid circular dependencies
|
||||
* breaking module resolution since the Logger class is instantiated at the
|
||||
* module level in many places.
|
||||
*
|
||||
* Do not add any concrete dependencies here.
|
||||
*/
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
import { ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsTelemetryService } from '../../bridge/src/completionsTelemetryServiceBridge';
|
||||
import { telemetryException } from './telemetry';
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 4,
|
||||
INFO = 3,
|
||||
WARN = 2,
|
||||
ERROR = 1,
|
||||
}
|
||||
|
||||
export const ICompletionsLogTargetService = createServiceIdentifier<ICompletionsLogTargetService>('ICompletionsLogTargetService');
|
||||
export interface ICompletionsLogTargetService {
|
||||
readonly _serviceBrand: undefined;
|
||||
logIt(level: LogLevel, category: string, ...extra: unknown[]): void;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
constructor(private readonly category: string) { }
|
||||
|
||||
private log(logTarget: ICompletionsLogTargetService, level: LogLevel, ...extra: unknown[]) {
|
||||
logTarget.logIt(level, this.category, ...extra);
|
||||
}
|
||||
|
||||
debug(logTarget: ICompletionsLogTargetService, ...extra: unknown[]) {
|
||||
this.log(logTarget, LogLevel.DEBUG, ...extra);
|
||||
}
|
||||
|
||||
info(logTarget: ICompletionsLogTargetService, ...extra: unknown[]) {
|
||||
this.log(logTarget, LogLevel.INFO, ...extra);
|
||||
}
|
||||
|
||||
warn(logTarget: ICompletionsLogTargetService, ...extra: unknown[]) {
|
||||
this.log(logTarget, LogLevel.WARN, ...extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error message and reports an error to telemetry. This is appropriate for generic
|
||||
* error logging, which might not be associated with an exception. Prefer `exception()` when
|
||||
* logging exception details.
|
||||
*/
|
||||
error(logTarget: ICompletionsLogTargetService, ...extra: unknown[]) {
|
||||
this.log(logTarget, LogLevel.ERROR, ...extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error message and reports the exception to telemetry. Prefer this method over
|
||||
* `error()` when logging exception details.
|
||||
*
|
||||
* @param accessor The accessor
|
||||
* @param error The Error object that was thrown
|
||||
* @param message An optional message for context (e.g. "Request error"). Must not contain customer data. **Do not include stack trace or messages from the error object.**
|
||||
*/
|
||||
exception(accessor: ServicesAccessor, error: unknown, origin: string) {
|
||||
// ignore VS Code cancellations
|
||||
if (error instanceof Error && error.name === 'Canceled' && error.message === 'Canceled') { return; }
|
||||
|
||||
let message = origin;
|
||||
if (origin.startsWith('.')) {
|
||||
message = origin.substring(1);
|
||||
origin = `${this.category}${origin}`;
|
||||
}
|
||||
|
||||
telemetryException(accessor.get(ICompletionsTelemetryService), error, origin);
|
||||
|
||||
const safeError: Error = error instanceof Error ? error : new Error(`Non-error thrown: ${String(error)}`);
|
||||
this.log(accessor.get(ICompletionsLogTargetService), LogLevel.ERROR, `${message}:`, safeError);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger('default');
|
||||
@@ -0,0 +1,14 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import util from 'node:util';
|
||||
|
||||
export function formatLogMessage(category: string, ...extra: unknown[]): string {
|
||||
return `[${category}] ${format(extra)}`;
|
||||
}
|
||||
|
||||
function format(args: unknown[]): string {
|
||||
return util.formatWithOptions({ maxStringLength: Infinity }, ...args);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { IAuthenticationService } from '../../../../../platform/authentication/common/authentication';
|
||||
import { ICAPIClientService } from '../../../../../platform/endpoint/common/capiClient';
|
||||
import { ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CopilotToken } from './auth/copilotTokenManager';
|
||||
import { BuildInfo, ConfigKey, ConfigKeyType, getConfig } from './config';
|
||||
import { ICompletionsRuntimeModeService } from './util/runtimeMode';
|
||||
import { joinPath } from './util/uri';
|
||||
|
||||
type ServiceEndpoints = {
|
||||
proxy: string;
|
||||
'origin-tracker': string;
|
||||
};
|
||||
|
||||
function getDefaultEndpoints(accessor: ServicesAccessor): ServiceEndpoints {
|
||||
const capi = accessor.get(ICAPIClientService);
|
||||
return {
|
||||
proxy: capi.proxyBaseURL,
|
||||
'origin-tracker': capi.originTrackerURL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If a configuration value has been configured for any of `overrideKeys`, returns
|
||||
* that value. If `testOverrideKeys` is supplied and the run mode is test,
|
||||
* `testOverrideKeys` is used instead of `overrideKeys`.
|
||||
*/
|
||||
function urlConfigOverride(
|
||||
accessor: ServicesAccessor,
|
||||
overrideKeys: ConfigKeyType[],
|
||||
testOverrideKeys?: ConfigKeyType[]
|
||||
): string | undefined {
|
||||
if (testOverrideKeys !== undefined && accessor.get(ICompletionsRuntimeModeService).isRunningInTest()) {
|
||||
for (const overrideKey of testOverrideKeys) {
|
||||
const override = getConfig<string>(accessor, overrideKey);
|
||||
if (override) { return override; }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const overrideKey of overrideKeys) {
|
||||
const override = getConfig<string>(accessor, overrideKey);
|
||||
if (override) { return override; }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getEndpointOverrideUrl(accessor: ServicesAccessor, endpoint: keyof ServiceEndpoints): string | undefined {
|
||||
switch (endpoint) {
|
||||
case 'proxy':
|
||||
return urlConfigOverride(
|
||||
accessor,
|
||||
[ConfigKey.DebugOverrideProxyUrl, ConfigKey.DebugOverrideProxyUrlLegacy],
|
||||
[ConfigKey.DebugTestOverrideProxyUrl, ConfigKey.DebugTestOverrideProxyUrlLegacy]
|
||||
);
|
||||
case 'origin-tracker':
|
||||
if (!BuildInfo.isProduction()) {
|
||||
return urlConfigOverride(accessor, [ConfigKey.DebugSnippyOverrideUrl]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getEndpointUrl(
|
||||
accessor: ServicesAccessor,
|
||||
token: CopilotToken,
|
||||
endpoint: keyof ServiceEndpoints,
|
||||
...paths: string[]
|
||||
): string {
|
||||
const root = getEndpointOverrideUrl(accessor, endpoint) ?? (token.endpoints ? token.endpoints[endpoint] : undefined) ?? getDefaultEndpoints(accessor)[endpoint];
|
||||
return joinPath(root, ...paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the endpoints from the most recent token, or fall back to the defaults if we don't have one.
|
||||
* Generally you should be using token.endpoints or getEndpointUrl() instead.
|
||||
*/
|
||||
export function getLastKnownEndpoints(accessor: ServicesAccessor) {
|
||||
return accessor.get(IAuthenticationService).copilotToken?.endpoints ?? getDefaultEndpoints(accessor);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { CancellationToken } from '../../types/src';
|
||||
import { apiVersion, editorVersionHeaders } from './config';
|
||||
import { telemetry, TelemetryData } from './telemetry';
|
||||
|
||||
/**
|
||||
* CIRCULAR DEPENDENCY FIX - PROGRESSIVE REFACTORING
|
||||
*
|
||||
* This module was refactored to resolve a circular dependency that caused runtime errors:
|
||||
*
|
||||
* Previous circular dependency chain:
|
||||
* networking.ts → config.ts → features.ts → copilotTokenManager.ts → copilotToken.ts → github.ts → networking.ts
|
||||
*
|
||||
* The issue:
|
||||
* - networking.ts defined FetchResponseError and other error classes
|
||||
* - network/github.ts needed FetchResponseError, so imported from networking.ts
|
||||
* - But networking.ts indirectly depended on github.ts through the config chain
|
||||
* - This caused "Cannot access 'FetchResponseError' before initialization" runtime error
|
||||
*
|
||||
* Solution - Module Separation:
|
||||
* 1. Extracted all error classes and types to '#lib/networking/networkingTypes'
|
||||
* 2. github.ts now imports FetchResponseError directly from the types module
|
||||
* 3. This breaks the circular dependency while preserving functionality
|
||||
* 4. No more dynamic imports needed since errors and types are in the same module
|
||||
*
|
||||
* Progressive Refactoring Strategy:
|
||||
* - Re-export everything from the new module to maintain API compatibility
|
||||
* - 22+ files across the codebase import from './networking' and expect these exports
|
||||
* - This approach allows internal restructuring without breaking existing imports
|
||||
* - Future: Could gradually migrate files to import directly from networkingTypes module
|
||||
*/
|
||||
|
||||
// Re-export everything from networking types module for backward compatibility
|
||||
export * from './networkingTypes';
|
||||
|
||||
// Import what we need locally for this module's implementation
|
||||
import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
|
||||
import { IEnvService } from '../../../../../platform/env/common/envService';
|
||||
import { IFetcherService } from '../../../../../platform/networking/common/fetcherService';
|
||||
import { IExperimentationService } from '../../../../../platform/telemetry/common/nullExperimentationService';
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { FetchOptions, ReqHeaders, Response } from './networkingTypes';
|
||||
|
||||
export const ICompletionsFetcherService = createServiceIdentifier<ICompletionsFetcherService>('ICompletionsFetcherService');
|
||||
export interface ICompletionsFetcherService {
|
||||
readonly _serviceBrand: undefined;
|
||||
getImplementation(): ICompletionsFetcherService | Promise<ICompletionsFetcherService>;
|
||||
fetch(url: string, options: FetchOptions): Promise<Response>;
|
||||
disconnectAll(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class CompletionsFetcher implements ICompletionsFetcherService {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IFetcherService private readonly fetcherService: IFetcherService,
|
||||
@IExperimentationService private readonly experimentationService: IExperimentationService
|
||||
) { }
|
||||
|
||||
getImplementation(): ICompletionsFetcherService | Promise<ICompletionsFetcherService> {
|
||||
return this;
|
||||
}
|
||||
|
||||
fetch(url: string, options: FetchOptions): Promise<Response> {
|
||||
const useFetcher = this.configurationService.getExperimentBasedConfig(ConfigKey.CompletionsFetcher, this.experimentationService) || undefined;
|
||||
return this.fetcherService.fetch(url, useFetcher ? { ...options, useFetcher } : options);
|
||||
}
|
||||
disconnectAll(): Promise<unknown> {
|
||||
return this.fetcherService.disconnectAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates all the functionality related to making GET/POST/DELETE requests using
|
||||
* different libraries (and in the future, different environments like web vs
|
||||
* node).
|
||||
*/
|
||||
export abstract class Fetcher {
|
||||
abstract readonly name: string;
|
||||
/**
|
||||
* Returns the real implementation, not a delegator. Used by diagnostics to ensure the fetcher name and all
|
||||
* reachability checks are aligned.
|
||||
*/
|
||||
getImplementation(): Fetcher | Promise<Fetcher> {
|
||||
return this;
|
||||
}
|
||||
abstract fetch(url: string, options: FetchOptions): Promise<Response>;
|
||||
abstract disconnectAll(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export function postRequest(
|
||||
accessor: ServicesAccessor,
|
||||
url: string,
|
||||
secretKey: string,
|
||||
intent: string | undefined, // Must be passed in, even if explicitly `undefined`
|
||||
requestId: string,
|
||||
body?: Record<string, unknown>,
|
||||
cancelToken?: CancellationToken,
|
||||
extraHeaders?: Record<string, string>,
|
||||
timeout?: number,
|
||||
modelProviderName?: string
|
||||
): Promise<Response> {
|
||||
const fetcher = accessor.get(ICompletionsFetcherService);
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
|
||||
const headers: ReqHeaders = {
|
||||
...extraHeaders,
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
...instantiationService.invokeFunction(editorVersionHeaders),
|
||||
};
|
||||
|
||||
// If we call byok endpoint, no need to add these headers
|
||||
if (modelProviderName === undefined) {
|
||||
headers['Openai-Organization'] = 'github-copilot';
|
||||
headers['X-Request-Id'] = requestId;
|
||||
headers['VScode-SessionId'] = accessor.get(IEnvService).sessionId;
|
||||
headers['VScode-MachineId'] = accessor.get(IEnvService).machineId;
|
||||
headers['X-GitHub-Api-Version'] = apiVersion;
|
||||
}
|
||||
|
||||
if (intent) {
|
||||
headers['OpenAI-Intent'] = intent;
|
||||
}
|
||||
|
||||
const request: FetchOptions = {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
json: body,
|
||||
timeout,
|
||||
};
|
||||
|
||||
if (cancelToken) {
|
||||
const abort = new AbortController();
|
||||
cancelToken.onCancellationRequested(() => {
|
||||
// abort the request when the token is canceled
|
||||
instantiationService.invokeFunction(telemetry,
|
||||
'networking.cancelRequest',
|
||||
TelemetryData.createAndMarkAsIssued({ headerRequestId: requestId })
|
||||
);
|
||||
abort.abort();
|
||||
});
|
||||
// pass the controller abort signal to the request
|
||||
request.signal = abort.signal;
|
||||
}
|
||||
|
||||
const requestPromise = fetcher.fetch(url, request).catch((reason: unknown) => {
|
||||
if (isInterruptedNetworkError(reason)) {
|
||||
// disconnect and retry the request once if the connection was reset
|
||||
instantiationService.invokeFunction(telemetry, 'networking.disconnectAll');
|
||||
return fetcher.disconnectAll().then(() => {
|
||||
return fetcher.fetch(url, request);
|
||||
});
|
||||
} else {
|
||||
throw reason;
|
||||
}
|
||||
});
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
export function isInterruptedNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) { return false; }
|
||||
if (error.message === 'ERR_HTTP2_GOAWAY_SESSION') { return true; }
|
||||
if (!('code' in error)) { return false; }
|
||||
return error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.code === 'ERR_HTTP2_INVALID_SESSION';
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
export { FetchOptions, Response } from '../../../../../platform/networking/common/fetcherService';
|
||||
|
||||
/**
|
||||
* NETWORKING TYPES, INTERFACES AND ERROR CLASSES
|
||||
*
|
||||
* This module contains all networking-related types, interfaces, error classes and utilities.
|
||||
*/
|
||||
|
||||
class HttpTimeoutError extends Error {
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, { cause });
|
||||
this.name = 'HttpTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isAbortError(e: unknown): boolean {
|
||||
if (!e || typeof e !== 'object') {
|
||||
// Reject invalid errors
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
e instanceof HttpTimeoutError ||
|
||||
// internal Node.js AbortError, emitted by helix-fetch and electron net
|
||||
('name' in e && e.name === 'AbortError') ||
|
||||
// that same internal Node.js AbortError, but wrapped in a Helix FetchError
|
||||
('code' in e && e.code === 'ABORT_ERR')
|
||||
);
|
||||
}
|
||||
|
||||
export interface IAbortController {
|
||||
readonly signal: IAbortSignal;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
export interface IHeaders extends Iterable<[string, string]> {
|
||||
append(name: string, value: string): void;
|
||||
delete(name: string): void;
|
||||
get(name: string): string | null;
|
||||
has(name: string): boolean;
|
||||
set(name: string, value: string): void;
|
||||
|
||||
entries(): Iterator<[string, string]>;
|
||||
keys(): Iterator<string>;
|
||||
values(): Iterator<string>;
|
||||
[Symbol.iterator](): Iterator<[string, string]>;
|
||||
}
|
||||
|
||||
export interface IAbortSignal extends Pick<EventTarget, 'addEventListener' | 'removeEventListener'> {
|
||||
readonly aborted: boolean;
|
||||
}
|
||||
|
||||
export type ReqHeaders = { [key: string]: string };
|
||||
@@ -0,0 +1,31 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { INotificationService } from '../../../../../platform/notification/common/notificationService';
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
|
||||
export interface ActionItem {
|
||||
title: string;
|
||||
[key: string]: string | boolean | object;
|
||||
}
|
||||
|
||||
export const ICompletionsNotificationSender = createServiceIdentifier<ICompletionsNotificationSender>('ICompletionsNotificationSender');
|
||||
export interface ICompletionsNotificationSender {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
showWarningMessage(message: string, ...actions: ActionItem[]): Promise<ActionItem | undefined>;
|
||||
}
|
||||
|
||||
export class ExtensionNotificationSender implements ICompletionsNotificationSender {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
constructor(@INotificationService private readonly notificationService: INotificationService) {
|
||||
}
|
||||
|
||||
async showWarningMessage(message: string, ...actions: ActionItem[]): Promise<ActionItem | undefined> {
|
||||
const response = await this.notificationService.showWarningMessage(message, ...actions.map(action => action.title));
|
||||
if (response === undefined) { return; }
|
||||
return { title: response };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { TokenizerName } from '../../../prompt/src/tokenization';
|
||||
import { TelemetryWithExp } from '../telemetry';
|
||||
import { CompletionHeaders } from './fetch';
|
||||
import { ICompletionsModelManagerService, ModelChoiceSourceTelemetryValue } from './model';
|
||||
|
||||
// Config methods
|
||||
|
||||
export type EngineRequestInfo = {
|
||||
headers: CompletionHeaders;
|
||||
modelId: string;
|
||||
engineChoiceSource: ModelChoiceSourceTelemetryValue;
|
||||
tokenizer: TokenizerName;
|
||||
};
|
||||
|
||||
export function getEngineRequestInfo(
|
||||
accessor: ServicesAccessor,
|
||||
telemetryData: TelemetryWithExp | undefined = undefined
|
||||
): EngineRequestInfo {
|
||||
const modelsManager = accessor.get(ICompletionsModelManagerService);
|
||||
const modelRequestInfo = modelsManager.getCurrentModelRequestInfo(telemetryData);
|
||||
const tokenizer = modelsManager.getTokenizerForModel(modelRequestInfo.modelId);
|
||||
|
||||
return {
|
||||
headers: modelRequestInfo.headers,
|
||||
modelId: modelRequestInfo.modelId,
|
||||
engineChoiceSource: modelRequestInfo.modelChoiceSource,
|
||||
tokenizer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { CancellationToken } from 'vscode';
|
||||
import { generateUuid } from '../../../../../../util/vs/base/common/uuid';
|
||||
import { getTokenizer } from '../../../prompt/src/tokenization';
|
||||
import { ICompletionsCopilotTokenManager } from '../auth/copilotTokenManager';
|
||||
import { Response } from '../networking';
|
||||
import { TelemetryData, TelemetryWithExp } from '../telemetry';
|
||||
import {
|
||||
CompletionError,
|
||||
CompletionParams,
|
||||
CompletionResults,
|
||||
FinishedCallback,
|
||||
LiveOpenAIFetcher,
|
||||
OpenAIFetcher,
|
||||
PostOptions,
|
||||
postProcessChoices,
|
||||
SolutionDecision,
|
||||
SpeculationFetchParams
|
||||
} from './fetch';
|
||||
import { APIChoice } from './openai';
|
||||
|
||||
/**
|
||||
* This module supports fake implementations of the completions returned by OpenAI, as well
|
||||
* as injecting synthetic completions that would be hard to trigger directly but are useful
|
||||
* for thoroughly testing the code that post-processes completions.
|
||||
*
|
||||
*/
|
||||
|
||||
export function fakeAPIChoice(
|
||||
headerRequestId: string,
|
||||
choiceIndex: number,
|
||||
completionText: string,
|
||||
telemetryData: TelemetryWithExp = TelemetryWithExp.createEmptyConfigForTesting()
|
||||
): APIChoice {
|
||||
const tokenizer = getTokenizer();
|
||||
|
||||
return {
|
||||
completionText: completionText,
|
||||
meanLogProb: 0.5,
|
||||
meanAlternativeLogProb: 0.5,
|
||||
numTokens: -1,
|
||||
choiceIndex,
|
||||
requestId: {
|
||||
headerRequestId,
|
||||
serverExperiments: 'dummy',
|
||||
deploymentId: 'dummy',
|
||||
gitHubRequestId: 'dummy',
|
||||
completionId: 'dummy',
|
||||
created: 0
|
||||
},
|
||||
telemetryData,
|
||||
// This slightly convoluted way of getting the tokens as a string array is an
|
||||
// alternative to exporting a way to do it directly from the tokenizer module.
|
||||
tokens: tokenizer
|
||||
.tokenize(completionText)
|
||||
.map(token => tokenizer.detokenize([token]))
|
||||
.concat(),
|
||||
blockFinished: false,
|
||||
clientCompletionId: generateUuid(),
|
||||
finishReason: 'stop',
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeAPIChoiceFromCompletion(completion: string): APIChoice {
|
||||
return fakeAPIChoice(generateUuid(), 0, completion);
|
||||
}
|
||||
|
||||
export async function* fakeAPIChoices(
|
||||
postOptions: PostOptions | undefined,
|
||||
finishedCb: FinishedCallback,
|
||||
completions: string[],
|
||||
telemetryData?: TelemetryWithExp
|
||||
): AsyncIterable<APIChoice> {
|
||||
const fakeHeaderRequestId = generateUuid();
|
||||
let choiceIndex = 0;
|
||||
for (let completion of completions) {
|
||||
let stopOffset = -1;
|
||||
if (postOptions?.stop !== undefined) {
|
||||
for (const stopToken of postOptions.stop) {
|
||||
const thisStopOffset = completion.indexOf(stopToken);
|
||||
if (thisStopOffset !== -1 && (stopOffset === -1 || thisStopOffset < stopOffset)) {
|
||||
stopOffset = thisStopOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stopOffset !== -1) {
|
||||
completion = completion.substring(0, stopOffset);
|
||||
}
|
||||
// This logic for using the finishedCb mirrors what happens in the live streamChoices function,
|
||||
// but it doesn't try to stop reading the completion early as there's no point.
|
||||
const finishOffset = asNumericOffset(await finishedCb(completion, { text: completion }));
|
||||
if (finishOffset !== undefined) {
|
||||
completion = completion.substring(0, finishOffset);
|
||||
}
|
||||
const choice = fakeAPIChoice(fakeHeaderRequestId, choiceIndex++, completion, telemetryData);
|
||||
choice.blockFinished = finishOffset === undefined ? false : true;
|
||||
yield choice;
|
||||
}
|
||||
}
|
||||
|
||||
function asNumericOffset(result: SolutionDecision | number | undefined): number | undefined {
|
||||
if (typeof result === 'number' || result === undefined) {
|
||||
return result;
|
||||
}
|
||||
return result.finishOffset;
|
||||
}
|
||||
|
||||
function fakeResponse(
|
||||
completions: string[],
|
||||
finishedCb: FinishedCallback,
|
||||
postOptions?: PostOptions,
|
||||
telemetryData?: TelemetryWithExp
|
||||
): Promise<CompletionResults> {
|
||||
const choices = postProcessChoices(fakeAPIChoices(postOptions, finishedCb, completions, telemetryData));
|
||||
return Promise.resolve({ type: 'success', choices, getProcessingTime: () => 0 });
|
||||
}
|
||||
|
||||
export class SyntheticCompletions extends OpenAIFetcher {
|
||||
private _wasCalled = false;
|
||||
|
||||
constructor(
|
||||
private readonly _completions: string[],
|
||||
@ICompletionsCopilotTokenManager private readonly copilotTokenManager: ICompletionsCopilotTokenManager,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchAndStreamCompletions(
|
||||
params: CompletionParams,
|
||||
baseTelemetryData: TelemetryWithExp,
|
||||
finishedCb: FinishedCallback,
|
||||
cancel?: CancellationToken,
|
||||
teletryProperties?: { [key: string]: string }
|
||||
): Promise<CompletionResults | CompletionError> {
|
||||
// check we have a valid token - ignore the result
|
||||
void this.copilotTokenManager.getToken();
|
||||
if (cancel?.isCancellationRequested) {
|
||||
return { type: 'canceled', reason: 'canceled during test' };
|
||||
}
|
||||
|
||||
if (!this._wasCalled) {
|
||||
this._wasCalled = true;
|
||||
return fakeResponse(this._completions, finishedCb, params.postOptions, baseTelemetryData);
|
||||
} else {
|
||||
// In indentation mode, if the preview completion isn't enough to finish the completion,
|
||||
// a second call will be made with the first prompt+preview completion as the prompt.
|
||||
// As we've already returned everything we have, the second completion should be empty.
|
||||
const emptyCompletions = this._completions.map(completion => '');
|
||||
return fakeResponse(emptyCompletions, finishedCb, params.postOptions, baseTelemetryData);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAndStreamCompletions2(
|
||||
params: CompletionParams,
|
||||
baseTelemetryData: TelemetryWithExp,
|
||||
finishedCb: FinishedCallback,
|
||||
cancel?: CancellationToken
|
||||
): Promise<CompletionResults | CompletionError> {
|
||||
return this.fetchAndStreamCompletions(params, baseTelemetryData, finishedCb, cancel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class ErrorReturningFetcher extends LiveOpenAIFetcher {
|
||||
lastSpeculationParams?: CompletionParams | SpeculationFetchParams;
|
||||
|
||||
private response: Response | 'not-sent' = 'not-sent';
|
||||
|
||||
setResponse(response: Response | 'not-sent') {
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
override fetchWithParameters(
|
||||
endpoint: string,
|
||||
params: CompletionParams,
|
||||
_copilotToken: unknown,
|
||||
telemetryData: TelemetryData,
|
||||
cancel?: CancellationToken
|
||||
): Promise<Response | 'not-sent'> {
|
||||
const response = this.response;
|
||||
this.response = 'not-sent';
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IAuthenticationService } from '../../../../../../platform/authentication/common/authentication';
|
||||
import { ICompletionModelInformation, IEndpointProvider } from '../../../../../../platform/endpoint/common/endpointProvider';
|
||||
import { createServiceIdentifier } from '../../../../../../util/common/services';
|
||||
import { Disposable } from '../../../../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { TokenizerName } from '../../../prompt/src/tokenization';
|
||||
import { onCopilotToken } from '../auth/copilotTokenNotifier';
|
||||
import { ConfigKey, getConfig } from '../config';
|
||||
import { ICompletionsFeaturesService } from '../experiments/featuresService';
|
||||
import { TelemetryWithExp } from '../telemetry';
|
||||
import { CompletionHeaders } from './fetch';
|
||||
|
||||
export const ICompletionsModelManagerService = createServiceIdentifier<ICompletionsModelManagerService>('ICompletionsModelManagerService');
|
||||
export interface ICompletionsModelManagerService {
|
||||
readonly _serviceBrand: undefined;
|
||||
getGenericCompletionModels(): ModelItem[];
|
||||
getDefaultModelId(): string;
|
||||
getTokenizerForModel(modelId: string): TokenizerName;
|
||||
getCurrentModelRequestInfo(featureSettings?: TelemetryWithExp): ModelRequestInfo;
|
||||
}
|
||||
|
||||
const FallbackModelId = 'gpt-41-copilot';
|
||||
export class AvailableModelsManager extends Disposable implements ICompletionsModelManagerService {
|
||||
declare _serviceBrand: undefined;
|
||||
fetchedModelData: ICompletionModelInformation[] = [];
|
||||
customModels: string[] = [];
|
||||
editorPreviewFeaturesDisabled: boolean = false;
|
||||
|
||||
constructor(
|
||||
shouldFetch: boolean = true,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@ICompletionsFeaturesService private readonly _featuresService: ICompletionsFeaturesService,
|
||||
@IEndpointProvider private readonly _endpointProvider: IEndpointProvider,
|
||||
@IAuthenticationService authenticationService: IAuthenticationService,
|
||||
) {
|
||||
super();
|
||||
|
||||
if (shouldFetch) {
|
||||
this._register(onCopilotToken(authenticationService, () => this.refreshAvailableModels()));
|
||||
}
|
||||
}
|
||||
|
||||
// This will get its initial call after the initial token got fetched
|
||||
private async refreshAvailableModels(): Promise<void> {
|
||||
await this.refreshModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default model, determined by the order returned from the API
|
||||
* Note: this does NOT fetch models to avoid side effects
|
||||
*/
|
||||
getDefaultModelId(): string {
|
||||
if (this.fetchedModelData) {
|
||||
const fetchedDefaultModel = AvailableModelsManager.filterCompletionModels(
|
||||
this.fetchedModelData,
|
||||
this.editorPreviewFeaturesDisabled
|
||||
)[0];
|
||||
|
||||
if (fetchedDefaultModel) {
|
||||
return fetchedDefaultModel.id;
|
||||
}
|
||||
}
|
||||
|
||||
return FallbackModelId;
|
||||
}
|
||||
|
||||
async refreshModels(): Promise<void> {
|
||||
const fetchedData = await this._endpointProvider.getAllCompletionModels(true);
|
||||
if (fetchedData) {
|
||||
this.fetchedModelData = fetchedData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of models that are available for generic completions.
|
||||
* Calls to CAPI to retrieve the list.
|
||||
*/
|
||||
getGenericCompletionModels(): ModelItem[] {
|
||||
const filteredResult = AvailableModelsManager.filterCompletionModels(
|
||||
this.fetchedModelData,
|
||||
this.editorPreviewFeaturesDisabled
|
||||
);
|
||||
|
||||
return AvailableModelsManager.mapCompletionModels(filteredResult);
|
||||
}
|
||||
|
||||
getTokenizerForModel(modelId: string): TokenizerName {
|
||||
const modelItems = this.getGenericCompletionModels();
|
||||
const modelItem = modelItems.find(item => item.modelId === modelId);
|
||||
if (modelItem) {
|
||||
return modelItem.tokenizer as TokenizerName;
|
||||
}
|
||||
// The tokenizer the default model uses
|
||||
return TokenizerName.o200k;
|
||||
}
|
||||
|
||||
static filterCompletionModels(data: ICompletionModelInformation[], editorPreviewFeaturesDisabled: boolean): ICompletionModelInformation[] {
|
||||
return data
|
||||
.filter(item => item.capabilities.type === 'completion')
|
||||
.filter(item => !editorPreviewFeaturesDisabled || item.preview === false || item.preview === undefined);
|
||||
}
|
||||
|
||||
static filterModelsWithEditorPreviewFeatures(
|
||||
data: ICompletionModelInformation[],
|
||||
editorPreviewFeaturesDisabled: boolean
|
||||
): ICompletionModelInformation[] {
|
||||
return data.filter(
|
||||
item => !editorPreviewFeaturesDisabled || item.preview === false || item.preview === undefined
|
||||
);
|
||||
}
|
||||
|
||||
static mapCompletionModels(data: ICompletionModelInformation[]): ModelItem[] {
|
||||
return data.map(item => ({
|
||||
modelId: item.id,
|
||||
label: item.name,
|
||||
preview: !!item.preview,
|
||||
tokenizer: item.capabilities.tokenizer,
|
||||
}));
|
||||
}
|
||||
|
||||
getCurrentModelRequestInfo(featureSettings: TelemetryWithExp | undefined = undefined): ModelRequestInfo {
|
||||
const defaultModelId = this.getDefaultModelId();
|
||||
|
||||
const debugOverride =
|
||||
this._instantiationService.invokeFunction(getConfig<string>, ConfigKey.DebugOverrideEngine) ||
|
||||
this._instantiationService.invokeFunction(getConfig<string>, ConfigKey.DebugOverrideEngineLegacy);
|
||||
|
||||
if (debugOverride) {
|
||||
return new ModelRequestInfo(debugOverride, 'override');
|
||||
}
|
||||
|
||||
const customEngine = featureSettings ? this._featuresService.customEngine(featureSettings) : '';
|
||||
if (customEngine) {
|
||||
return new ModelRequestInfo(customEngine, 'exp');
|
||||
}
|
||||
|
||||
if (this.customModels.length > 0) {
|
||||
return new ModelRequestInfo(this.customModels[0], 'custommodel');
|
||||
}
|
||||
|
||||
return new ModelRequestInfo(defaultModelId, 'default');
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelItem {
|
||||
modelId: string;
|
||||
label: string;
|
||||
preview: boolean;
|
||||
tokenizer: string;
|
||||
}
|
||||
|
||||
export type ModelChoiceSourceTelemetryValue =
|
||||
| 'override'
|
||||
| 'modelpicker'
|
||||
| 'exp'
|
||||
| 'default'
|
||||
| 'custommodel'
|
||||
| 'prerelease';
|
||||
|
||||
class ModelRequestInfo {
|
||||
constructor(
|
||||
readonly modelId: string,
|
||||
readonly modelChoiceSource: ModelChoiceSourceTelemetryValue
|
||||
) { }
|
||||
|
||||
get headers(): CompletionHeaders {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CopilotNamedAnnotationList } from '../../../../../../platform/completions-core/common/openai/copilotAnnotations';
|
||||
import { RequestId } from '../../../../../../platform/networking/common/fetch';
|
||||
import { generateUuid } from '../../../../../../util/vs/base/common/uuid';
|
||||
import { ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { DEFAULT_MAX_COMPLETION_LENGTH } from '../../../prompt/src/prompt';
|
||||
import { logger } from '../logger';
|
||||
import { TelemetryWithExp, logEngineCompletion } from '../telemetry';
|
||||
import { ICompletionsRuntimeModeService } from '../util/runtimeMode';
|
||||
|
||||
export { FinishedCallback } from './fetch';
|
||||
|
||||
export interface APIChoice {
|
||||
completionText: string;
|
||||
meanLogProb: number | undefined;
|
||||
meanAlternativeLogProb: number | undefined;
|
||||
choiceIndex: number;
|
||||
requestId: RequestId;
|
||||
tokens: readonly string[];
|
||||
numTokens: number;
|
||||
blockFinished: boolean; // Whether the block completion was determined to be finished
|
||||
telemetryData: TelemetryWithExp; // optional telemetry data providing background
|
||||
copilotAnnotations?: CopilotNamedAnnotationList; // optional annotations from the proxy
|
||||
clientCompletionId: string; // Unique identifier for the completion created in the client
|
||||
finishReason: string; // Reason the API used to describe why the stream of chunks finished.
|
||||
generatedChoiceIndex?: number; // when a completion is split into multiple choices, the index of the split choice
|
||||
}
|
||||
|
||||
/** How the logprobs field looks in the OpenAI API chunks. */
|
||||
export interface APILogprobs {
|
||||
text_offset: number[];
|
||||
token_logprobs: number[];
|
||||
top_logprobs?: { [key: string]: number }[];
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
export interface APIJsonData {
|
||||
text: string;
|
||||
/* Joining this together produces `text`, due to the way the proxy works. */
|
||||
tokens: readonly string[];
|
||||
/* These are only generated in certain situations. */
|
||||
logprobs?: APILogprobs;
|
||||
/* Copilot-specific annotations returned by the proxy. */
|
||||
copilot_annotations?: CopilotNamedAnnotationList;
|
||||
/* Reason the proxy returned for why the stream of chunks ended. */
|
||||
finish_reason: string; // Reason the API used to describe why the stream of chunks finished.
|
||||
}
|
||||
|
||||
export function convertToAPIChoice(
|
||||
accessor: ServicesAccessor,
|
||||
completionText: string,
|
||||
jsonData: APIJsonData,
|
||||
choiceIndex: number,
|
||||
requestId: RequestId,
|
||||
blockFinished: boolean,
|
||||
telemetryData: TelemetryWithExp
|
||||
): APIChoice {
|
||||
logEngineCompletion(accessor, completionText, jsonData, requestId, choiceIndex);
|
||||
|
||||
// NOTE: It's possible that the completion text we care about is not exactly jsonData.text but a prefix,
|
||||
// so we pass it down directly.
|
||||
return {
|
||||
// NOTE: This does not contain stop tokens necessarily
|
||||
completionText: completionText,
|
||||
meanLogProb: calculateMeanLogProb(accessor, jsonData),
|
||||
meanAlternativeLogProb: calculateMeanAlternativeLogProb(accessor, jsonData),
|
||||
choiceIndex: choiceIndex,
|
||||
requestId: requestId,
|
||||
blockFinished: blockFinished,
|
||||
tokens: jsonData.tokens,
|
||||
numTokens: jsonData.tokens.length,
|
||||
telemetryData: telemetryData,
|
||||
copilotAnnotations: jsonData.copilot_annotations,
|
||||
clientCompletionId: generateUuid(),
|
||||
finishReason: jsonData.finish_reason,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function calculateMeanLogProb(accessor: ServicesAccessor, jsonData: APIJsonData): number | undefined {
|
||||
if (!jsonData?.logprobs?.token_logprobs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
let logProbSum = 0.0;
|
||||
let numTokens = 0;
|
||||
|
||||
// Limit to first 50 logprobs, avoids up-ranking longer solutions
|
||||
let iterLimit = 50;
|
||||
|
||||
// First token is always null and last token can have multiple options if it hit a stop
|
||||
for (let i = 0; i < jsonData.logprobs.token_logprobs.length - 1 && iterLimit > 0; i++, iterLimit--) {
|
||||
logProbSum += jsonData.logprobs.token_logprobs[i];
|
||||
numTokens += 1;
|
||||
}
|
||||
|
||||
if (numTokens > 0) {
|
||||
return logProbSum / numTokens;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.exception(accessor, e, `Error calculating mean prob`);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateMeanAlternativeLogProb(accessor: ServicesAccessor, jsonData: APIJsonData): number | undefined {
|
||||
if (!jsonData?.logprobs?.top_logprobs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
let logProbSum = 0.0;
|
||||
let numTokens = 0;
|
||||
|
||||
// Limit to first 50 logprobs, avoids up-ranking longer solutions
|
||||
let iterLimit = 50;
|
||||
|
||||
for (let i = 0; i < jsonData.logprobs.token_logprobs.length - 1 && iterLimit > 0; i++, iterLimit--) {
|
||||
// copy the options object to avoid mutating the original
|
||||
const options = { ...jsonData.logprobs.top_logprobs[i] };
|
||||
delete options[jsonData.logprobs.tokens[i]];
|
||||
logProbSum += Math.max(...Object.values(options));
|
||||
numTokens += 1;
|
||||
}
|
||||
|
||||
if (numTokens > 0) {
|
||||
return logProbSum / numTokens;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.exception(accessor, e, `Error calculating mean prob`);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a temperature in range 0.0-1.0, using either a config setting,
|
||||
// or the following ranges: 1=0.0, <10=0.2, <20=0.4, >=20=0.8
|
||||
export function getTemperatureForSamples(runtime: ICompletionsRuntimeModeService, numShots: number): number {
|
||||
if (runtime.isRunningInTest()) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if (numShots <= 1) {
|
||||
return 0.0;
|
||||
} else if (numShots < 10) {
|
||||
return 0.2;
|
||||
} else if (numShots < 20) {
|
||||
return 0.4;
|
||||
} else {
|
||||
return 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
const stopsForLanguage: { [key: string]: string[] } = {
|
||||
markdown: ['\n\n\n'],
|
||||
python: ['\ndef ', '\nclass ', '\nif ', '\n\n#'],
|
||||
};
|
||||
|
||||
export function getStops(languageId?: string) {
|
||||
return stopsForLanguage[languageId ?? ''] ?? ['\n\n\n', '\n```'];
|
||||
}
|
||||
|
||||
export function getTopP(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getMaxSolutionTokens(): number {
|
||||
return DEFAULT_MAX_COMPLETION_LENGTH;
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CopilotAnnotation, CopilotAnnotations, CopilotNamedAnnotationList, StreamCopilotAnnotations } from '../../../../../../platform/completions-core/common/openai/copilotAnnotations';
|
||||
import { getRequestId, RequestId } from '../../../../../../platform/networking/common/fetch';
|
||||
import { DestroyableStream } from '../../../../../../platform/networking/common/fetcherService';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CancellationToken as ICancellationToken } from '../../../types/src';
|
||||
import { ICompletionsLogTargetService, Logger } from '../logger';
|
||||
import { Response } from '../networking';
|
||||
import { TelemetryWithExp } from '../telemetry';
|
||||
import { getEngineRequestInfo } from './config';
|
||||
import { CopilotConfirmation, CopilotError, CopilotReference, SolutionDecision } from './fetch';
|
||||
import {
|
||||
APIChoice,
|
||||
APIJsonData,
|
||||
APILogprobs,
|
||||
convertToAPIChoice,
|
||||
FinishedCallback,
|
||||
} from './openai';
|
||||
|
||||
const streamChoicesLogger = new Logger('streamChoices');
|
||||
|
||||
/** Gathers together many chunks of a single completion choice. */
|
||||
class APIJsonDataStreaming {
|
||||
logprobs: number[][] = [];
|
||||
top_logprobs: { [key: string]: number }[][] = [];
|
||||
text: string[] = [];
|
||||
tokens: string[][] = [];
|
||||
text_offset: number[][] = [];
|
||||
copilot_annotations: CopilotAnnotations = new StreamCopilotAnnotations();
|
||||
tool_calls: StreamingToolCalls = new StreamingToolCalls();
|
||||
function_call: StreamingFunctionCall = new StreamingFunctionCall();
|
||||
copilot_references: CopilotReference[] = [];
|
||||
finish_reason?: string;
|
||||
yielded = false;
|
||||
|
||||
append(choice: ChoiceJSON) {
|
||||
if (choice.text) {
|
||||
this.text.push(choice.text);
|
||||
}
|
||||
// Role function is not included in the main answer.
|
||||
if (choice.delta?.content && choice.delta.role !== 'function') {
|
||||
this.text.push(choice.delta.content);
|
||||
}
|
||||
if (choice.logprobs) {
|
||||
this.tokens.push(choice.logprobs.tokens ?? []);
|
||||
this.text_offset.push(choice.logprobs.text_offset ?? []);
|
||||
this.logprobs.push(choice.logprobs.token_logprobs ?? []);
|
||||
this.top_logprobs.push(choice.logprobs.top_logprobs ?? []);
|
||||
}
|
||||
if (choice.copilot_annotations) {
|
||||
this.copilot_annotations.update(choice.copilot_annotations);
|
||||
}
|
||||
if (choice.delta?.copilot_annotations) {
|
||||
this.copilot_annotations.update(choice.delta.copilot_annotations);
|
||||
}
|
||||
if (choice.delta?.tool_calls && choice.delta.tool_calls.length > 0) {
|
||||
this.tool_calls.update(choice.delta.tool_calls);
|
||||
}
|
||||
if (choice.delta?.function_call) {
|
||||
this.function_call.update(choice.delta.function_call);
|
||||
}
|
||||
if (choice?.finish_reason) {
|
||||
this.finish_reason = choice.finish_reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given a string of lines separated by one or more newlines, returns complete
|
||||
// lines and any remaining partial line data. Exported for test only.
|
||||
export function splitChunk(chunk: string): [string[], string] {
|
||||
const dataLines = chunk.split('\n');
|
||||
const newExtra = dataLines.pop(); // will be empty string if chunk ends with "\n"
|
||||
return [dataLines.filter(line => line !== ''), newExtra!];
|
||||
}
|
||||
|
||||
type ModelUsage = {
|
||||
completion_tokens: number;
|
||||
prompt_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single finished completion returned from the model or proxy, along with
|
||||
* some metadata.
|
||||
*/
|
||||
export interface FinishedCompletion {
|
||||
solution: APIJsonDataStreaming;
|
||||
/** An optional offset into `solution.text.join('')` where the completion finishes. */
|
||||
finishOffset: number | undefined;
|
||||
/** A copilot-specific human-readable reason for the completion finishing. */
|
||||
reason: string | null;
|
||||
requestId: RequestId;
|
||||
index: number;
|
||||
model?: string;
|
||||
usage?: ModelUsage;
|
||||
}
|
||||
|
||||
class StreamingToolCall {
|
||||
// Right now we only support functions.
|
||||
name?: string;
|
||||
arguments: string[] = [];
|
||||
id?: string; // Unique ID for the tool call, if available
|
||||
|
||||
update(toolCall: { type: 'function'; id?: string; function: { name?: string; arguments: string } }) {
|
||||
if (toolCall.id) {
|
||||
this.id = toolCall.id;
|
||||
}
|
||||
if (toolCall.function.name) {
|
||||
this.name = toolCall.function.name;
|
||||
}
|
||||
this.arguments.push(toolCall.function.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
class StreamingToolCalls {
|
||||
private toolCalls: StreamingToolCall[] = [];
|
||||
|
||||
constructor() { }
|
||||
|
||||
update(
|
||||
toolCallsArray: { type: 'function'; id?: string; index?: number; function: { name?: string; arguments: string } }[]
|
||||
) {
|
||||
toolCallsArray.forEach(toolCall => {
|
||||
let currentCall = this.toolCalls.length > 0 ? this.toolCalls[this.toolCalls.length - 1] : undefined;
|
||||
// Create a new tool call if:
|
||||
// 1. No existing tool calls, OR
|
||||
// 2. The new tool call has an ID and it's different from the current one
|
||||
if (!currentCall || (toolCall.id && currentCall.id !== toolCall.id)) {
|
||||
currentCall = new StreamingToolCall();
|
||||
this.toolCalls.push(currentCall);
|
||||
}
|
||||
|
||||
currentCall.update(toolCall);
|
||||
});
|
||||
}
|
||||
|
||||
getToolCalls(): StreamingToolCall[] {
|
||||
return this.toolCalls;
|
||||
}
|
||||
}
|
||||
|
||||
class StreamingFunctionCall {
|
||||
name?: string;
|
||||
arguments: string[] = [];
|
||||
|
||||
update(functionCall: { name?: string; arguments: string }) {
|
||||
if (functionCall.name) {
|
||||
this.name = functionCall.name;
|
||||
}
|
||||
this.arguments.push(functionCall.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
interface FunctionCallJSON {
|
||||
name?: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
interface ToolCallJSON {
|
||||
id: string;
|
||||
function: FunctionCallJSON;
|
||||
index: number;
|
||||
type: 'function';
|
||||
}
|
||||
|
||||
/** What comes back from the OpenAI API for a single choice in an SSE chunk. */
|
||||
interface ChoiceJSON {
|
||||
index: number;
|
||||
/**
|
||||
* The text attribute as defined in completions streaming.
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
|
||||
*/
|
||||
text: string;
|
||||
copilot_annotations: { [key: string]: CopilotAnnotation[] };
|
||||
/**
|
||||
* The delta attribute as defined in chat streaming.
|
||||
* See https://github.com/openai/openai-cookbook/blob/main/examples/How_to_stream_completions.ipynb
|
||||
*/
|
||||
delta: {
|
||||
content: string;
|
||||
copilot_annotations?: { [key: string]: CopilotAnnotation[] };
|
||||
role?: string;
|
||||
function_call?: FunctionCallJSON;
|
||||
tool_calls?: ToolCallJSON[];
|
||||
};
|
||||
finish_reason: string | null;
|
||||
logprobs?: APILogprobs;
|
||||
copilot_annotation?: CopilotNamedAnnotationList;
|
||||
copilot_references?: CopilotReference[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an HTTP request containing what is assumed to be an SSE stream of
|
||||
* OpenAI API data. Yields a stream of `FinishedCompletion` objects, each as
|
||||
* soon as it's finished.
|
||||
*/
|
||||
export class SSEProcessor {
|
||||
private requestId: RequestId = getRequestId(this.response.headers);
|
||||
private stats = new ChunkStats();
|
||||
/**
|
||||
* A key & value being here means at least one chunk with that choice index
|
||||
* has been received. A null value means we've already finished the given
|
||||
* solution and should not process incoming tokens further.
|
||||
*/
|
||||
private readonly solutions: Record<number, APIJsonDataStreaming | null> = {};
|
||||
|
||||
private constructor(
|
||||
private readonly expectedNumChoices: number,
|
||||
private readonly response: Response,
|
||||
private readonly body: DestroyableStream<string>,
|
||||
private readonly telemetryData: TelemetryWithExp,
|
||||
private readonly dropCompletionReasons: string[],
|
||||
private readonly cancellationToken: ICancellationToken | undefined = undefined,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ICompletionsLogTargetService private readonly logTarget: ICompletionsLogTargetService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Creates a new instance of SSEProcessor.
|
||||
*
|
||||
* Supports dropping completions with specific finish reasons.
|
||||
* Historically, this was used to drop RAI ('content_filter') completions, instead of showing partially finished completions to the user. We've gone back and forth on this.
|
||||
*/
|
||||
static async create(
|
||||
accessor: ServicesAccessor,
|
||||
expectedNumChoices: number,
|
||||
response: Response,
|
||||
telemetryData: TelemetryWithExp,
|
||||
dropCompletionReasons?: string[],
|
||||
cancellationToken?: ICancellationToken
|
||||
) {
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const logTargetService = accessor.get(ICompletionsLogTargetService);
|
||||
|
||||
const body = response.body.pipeThrough(new TextDecoderStream());
|
||||
|
||||
// TODO@benibenj can we switch to our SSEProcessor implementation?
|
||||
// It seems like they build more on top of the shared impl
|
||||
// I made this function async and commented out the web ReadableStream approach
|
||||
|
||||
return new SSEProcessor(
|
||||
expectedNumChoices,
|
||||
response,
|
||||
body,
|
||||
telemetryData,
|
||||
dropCompletionReasons ?? [],
|
||||
cancellationToken,
|
||||
instantiationService,
|
||||
logTargetService,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Yields finished completions as soon as they are available. The finishedCb
|
||||
* is used to determine when a completion is done and should be truncated.
|
||||
* It is called on the whole of the received solution text, once at the end
|
||||
* of the completion (if it stops by itself) and also on any chunk that has
|
||||
* a newline in it.
|
||||
*
|
||||
* Closes the server request stream when all choices are finished/truncated.
|
||||
*
|
||||
* Note that for this to work, the caller must consume the entire stream.
|
||||
* This happens automatically when using a `for await` loop, but when
|
||||
* iterating manually this needs to be done by calling `.next()` until it
|
||||
* returns an item with done = true (or calling `.return()`).
|
||||
*/
|
||||
async *processSSE(finishedCb: FinishedCallback = () => undefined): AsyncIterable<FinishedCompletion> {
|
||||
try {
|
||||
yield* this.processSSEInner(finishedCb);
|
||||
} finally {
|
||||
await this.cancel();
|
||||
streamChoicesLogger.debug(this.logTarget,
|
||||
`request done: headerRequestId: [${this.requestId.headerRequestId}] model deployment ID: [${this.requestId.deploymentId}]`
|
||||
);
|
||||
streamChoicesLogger.debug(this.logTarget, 'request stats:', this.stats);
|
||||
}
|
||||
}
|
||||
|
||||
private async *processSSEInner(finishedCb: FinishedCallback): AsyncIterable<FinishedCompletion> {
|
||||
// Collects pieces of the SSE stream that haven't been fully processed
|
||||
// yet.
|
||||
let extraData = '';
|
||||
|
||||
let currentFinishReason: string | null = null;
|
||||
let model: string | undefined;
|
||||
let usage: ModelUsage | undefined;
|
||||
|
||||
// Iterate over arbitrarily sized chunks coming in from the network.
|
||||
networkRead: for await (const chunk of this.body) {
|
||||
if (await this.maybeCancel('after awaiting body chunk')) {
|
||||
return;
|
||||
}
|
||||
|
||||
streamChoicesLogger.debug(this.logTarget, 'chunk', chunk.toString());
|
||||
const [dataLines, remainder] = splitChunk(extraData + chunk.toString());
|
||||
extraData = remainder;
|
||||
|
||||
// Each dataLine is complete since we've seen at least one \n after
|
||||
// it.
|
||||
for (const dataLine of dataLines) {
|
||||
const lineWithoutData = dataLine.slice('data:'.length).trim();
|
||||
if (lineWithoutData === '[DONE]') {
|
||||
yield* this.finishSolutions(currentFinishReason, model, usage, finishedCb);
|
||||
return;
|
||||
}
|
||||
// If this is not a DONE line, we reset the finish reason.
|
||||
currentFinishReason = null;
|
||||
|
||||
interface StreamingResponse {
|
||||
choices?: ChoiceJSON[];
|
||||
error?: { message: string };
|
||||
copilot_references?: CopilotReference[];
|
||||
copilot_confirmation?: unknown;
|
||||
copilot_errors: CopilotError[];
|
||||
model?: string; // Note: model should only be expected from CAPI, not copilot-proxy
|
||||
usage?: ModelUsage;
|
||||
}
|
||||
|
||||
let json;
|
||||
try {
|
||||
json = <StreamingResponse>JSON.parse(lineWithoutData);
|
||||
} catch (e) {
|
||||
streamChoicesLogger.error(this.logTarget, 'Error parsing JSON stream data', dataLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A message with a confirmation may or may not have 'choices'
|
||||
if (json.copilot_confirmation && isCopilotConfirmation(json.copilot_confirmation)) {
|
||||
await finishedCb('', {
|
||||
text: '',
|
||||
requestId: this.requestId,
|
||||
copilotConfirmation: json.copilot_confirmation,
|
||||
});
|
||||
}
|
||||
|
||||
// we do not process the data from role=function right now because copilot_references seem to contain the same data in a more structured way
|
||||
if (json.copilot_references) {
|
||||
await finishedCb('', {
|
||||
text: '',
|
||||
requestId: this.requestId,
|
||||
copilotReferences: json.copilot_references,
|
||||
});
|
||||
}
|
||||
|
||||
if (json.choices === undefined) {
|
||||
if (!json.copilot_references && !json.copilot_confirmation) {
|
||||
if (json.error !== undefined) {
|
||||
streamChoicesLogger.error(this.logTarget, 'Error in response:', json.error!.message);
|
||||
} else {
|
||||
streamChoicesLogger.error(this.logTarget,
|
||||
'Unexpected response with no choices or error: ' + lineWithoutData
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// There are also messages with a null 'choices' that include copilot_errors- report these
|
||||
if (json.copilot_errors) {
|
||||
await finishedCb('', { text: '', requestId: this.requestId, copilotErrors: json.copilot_errors });
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (model === undefined && json.model) {
|
||||
model = json.model;
|
||||
}
|
||||
|
||||
if (usage === undefined && json.usage) {
|
||||
usage = json.usage;
|
||||
}
|
||||
|
||||
if (this.allSolutionsDone()) {
|
||||
// discard any extra data; there's no need to log it as an error
|
||||
extraData = '';
|
||||
break networkRead;
|
||||
}
|
||||
|
||||
for (let i = 0; i < json.choices?.length; i++) {
|
||||
const choice: ChoiceJSON = json.choices[i];
|
||||
streamChoicesLogger.debug(this.logTarget, 'choice', choice);
|
||||
this.stats.add(choice.index);
|
||||
|
||||
if (!(choice.index in this.solutions)) {
|
||||
this.solutions[choice.index] = new APIJsonDataStreaming();
|
||||
}
|
||||
|
||||
const solution = this.solutions[choice.index];
|
||||
if (solution === null) {
|
||||
continue; // already finished
|
||||
}
|
||||
|
||||
solution.append(choice);
|
||||
|
||||
// Call finishedCb after each newline token to determine
|
||||
// if the solution is now complete. Also call it if the
|
||||
// solution has finished to make sure it's properly truncated.
|
||||
let decision = this.asSolutionDecision();
|
||||
const hasNewLine = choice.text?.indexOf('\n') > -1 || choice.delta?.content?.indexOf('\n') > -1;
|
||||
if (choice.finish_reason || hasNewLine) {
|
||||
const text = solution.text.join('');
|
||||
decision = this.asSolutionDecision(
|
||||
await finishedCb(text, {
|
||||
text,
|
||||
index: choice.index,
|
||||
requestId: this.requestId,
|
||||
annotations: solution.copilot_annotations,
|
||||
copilotReferences: solution.copilot_references,
|
||||
getAPIJsonData: () => convertToAPIJsonData(solution),
|
||||
finished: choice.finish_reason ? true : false,
|
||||
telemetryData: this.telemetryData,
|
||||
})
|
||||
);
|
||||
|
||||
if (await this.maybeCancel('after awaiting finishedCb')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If this is a function call and we have a finish reason, continue to the next choice.
|
||||
* This is because of how extensibility platform agents work, where multiple finish reasons can be returned.
|
||||
*
|
||||
* This should be updated to tools in the future.
|
||||
*/
|
||||
if (choice.finish_reason && solution.function_call.name !== undefined) {
|
||||
currentFinishReason = choice.finish_reason;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
decision.yieldSolution = true;
|
||||
decision.continueStreaming = false;
|
||||
}
|
||||
if (!decision.yieldSolution) {
|
||||
continue;
|
||||
}
|
||||
// NOTE: When there is a finish_reason the text of subsequent chunks is always '',
|
||||
// (current chunk might still have useful text, that is why we add it above).
|
||||
// So we know that we already got all the text to be displayed for the user.
|
||||
// TODO: This might contain additional logprobs for excluded next tokens. We should
|
||||
// filter out indices that correspond to excluded tokens. It will not affect the
|
||||
// text though.
|
||||
const loggedReason = choice.finish_reason ?? 'client-trimmed';
|
||||
streamChoicesLogger.debug(this.logTarget,
|
||||
'completion.finishReason',
|
||||
this.telemetryData.extendedBy({
|
||||
completionChoiceFinishReason: loggedReason,
|
||||
engineName: model ?? '',
|
||||
engineChoiceSource: this.instantiationService.invokeFunction(getEngineRequestInfo, this.telemetryData).engineChoiceSource,
|
||||
})
|
||||
);
|
||||
if (this.dropCompletionReasons.includes(choice.finish_reason!)) {
|
||||
// In this case we drop the choice on the floor.
|
||||
this.solutions[choice.index] = null;
|
||||
} else if (!solution.yielded) {
|
||||
this.stats.markYielded(choice.index);
|
||||
yield {
|
||||
solution,
|
||||
finishOffset: decision.finishOffset,
|
||||
reason: choice.finish_reason,
|
||||
requestId: this.requestId,
|
||||
index: choice.index,
|
||||
model: model,
|
||||
usage: usage,
|
||||
};
|
||||
solution.yielded = true;
|
||||
}
|
||||
|
||||
if (await this.maybeCancel('after yielding finished choice')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!decision.continueStreaming) {
|
||||
this.solutions[choice.index] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Yield whatever solutions remain incomplete in case no [DONE] was received.
|
||||
// This shouldn't happen in practice unless there was an error somewhere.
|
||||
for (const [index, solution] of Object.entries(this.solutions)) {
|
||||
const solutionIndex = Number(index); // Convert `index` from string to number
|
||||
if (solution === null) {
|
||||
continue; // already finished
|
||||
}
|
||||
streamChoicesLogger.debug(this.logTarget,
|
||||
'completion.finishReason',
|
||||
this.telemetryData.extendedBy({
|
||||
completionChoiceFinishReason: 'Iteration Done',
|
||||
engineName: model ?? '',
|
||||
})
|
||||
);
|
||||
this.stats.markYielded(solutionIndex);
|
||||
yield {
|
||||
solution,
|
||||
finishOffset: undefined,
|
||||
reason: 'Iteration Done',
|
||||
requestId: this.requestId,
|
||||
index: solutionIndex,
|
||||
model: model,
|
||||
usage: usage,
|
||||
};
|
||||
|
||||
if (await this.maybeCancel('after yielding after iteration done')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Error message can be present in `extraData`
|
||||
if (extraData.length > 0) {
|
||||
try {
|
||||
const extraDataJson = <{ error?: { message: string } }>JSON.parse(extraData);
|
||||
if (extraDataJson.error !== undefined) {
|
||||
streamChoicesLogger.error(this.logTarget,
|
||||
`Error in response: ${extraDataJson.error!.message}`,
|
||||
extraDataJson.error
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
streamChoicesLogger.error(this.logTarget, `Error parsing extraData: ${extraData}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private asSolutionDecision(result?: SolutionDecision | number): SolutionDecision {
|
||||
if (result === undefined) {
|
||||
return {
|
||||
yieldSolution: false,
|
||||
continueStreaming: true,
|
||||
};
|
||||
} else if (typeof result === 'number') {
|
||||
return {
|
||||
yieldSolution: true,
|
||||
continueStreaming: false,
|
||||
finishOffset: result,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Yields the solutions that weren't yet finished, with a 'DONE' reason. */
|
||||
private async *finishSolutions(
|
||||
currentFinishReason: string | null,
|
||||
model: string | undefined,
|
||||
usage: ModelUsage | undefined,
|
||||
finishedCb: FinishedCallback
|
||||
): AsyncIterable<FinishedCompletion> {
|
||||
for (const [index, solution] of Object.entries(this.solutions)) {
|
||||
const solutionIndex = Number(index); // Convert `index` from string to number
|
||||
if (solution === null) {
|
||||
continue; // already finished
|
||||
}
|
||||
// ensure the callback receives the final result
|
||||
const text = solution.text.join('');
|
||||
await finishedCb(text, {
|
||||
text,
|
||||
index: solutionIndex,
|
||||
requestId: this.requestId,
|
||||
annotations: solution.copilot_annotations,
|
||||
copilotReferences: solution.copilot_references,
|
||||
getAPIJsonData: () => convertToAPIJsonData(solution), // observation from @ulugbekna: this conversion will make `finishReason` for this object 'stop' while we're yielding with 'DONE' below
|
||||
finished: true,
|
||||
telemetryData: this.telemetryData,
|
||||
});
|
||||
if (solution.yielded) {
|
||||
continue; // already produced
|
||||
}
|
||||
this.stats.markYielded(solutionIndex);
|
||||
streamChoicesLogger.debug(this.logTarget,
|
||||
'completion.finishReason',
|
||||
this.telemetryData.extendedBy({
|
||||
completionChoiceFinishReason: currentFinishReason ?? 'DONE',
|
||||
engineName: model ?? '',
|
||||
})
|
||||
);
|
||||
yield {
|
||||
solution,
|
||||
finishOffset: undefined,
|
||||
reason: currentFinishReason ?? 'DONE',
|
||||
requestId: this.requestId,
|
||||
index: solutionIndex,
|
||||
model: model,
|
||||
usage: usage,
|
||||
};
|
||||
|
||||
if (await this.maybeCancel('after yielding on DONE')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the cancellation token was cancelled and closes the
|
||||
* stream if it was.
|
||||
*/
|
||||
private async maybeCancel(description: string) {
|
||||
if (this.cancellationToken?.isCancellationRequested) {
|
||||
streamChoicesLogger.debug(this.logTarget, 'Cancelled: ' + description);
|
||||
await this.cancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Cancels the network request to the proxy. */
|
||||
private async cancel() {
|
||||
await this.body.destroy();
|
||||
}
|
||||
|
||||
/** Returns whether we've finished receiving all expected solutions. */
|
||||
private allSolutionsDone(): boolean {
|
||||
const solutions = Object.values(this.solutions);
|
||||
return solutions.length === this.expectedNumChoices && solutions.every(s => s === null);
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareSolutionForReturn(
|
||||
accessor: ServicesAccessor,
|
||||
c: FinishedCompletion,
|
||||
telemetryData: TelemetryWithExp
|
||||
): APIChoice {
|
||||
const logTarget = accessor.get(ICompletionsLogTargetService);
|
||||
let completionText = c.solution.text.join('');
|
||||
|
||||
let blockFinished = false;
|
||||
if (c.finishOffset !== undefined) {
|
||||
// Trim solution to finishOffset returned by finishedCb
|
||||
streamChoicesLogger.debug(logTarget, `solution ${c.index}: early finish at offset ${c.finishOffset}`);
|
||||
completionText = completionText.substring(0, c.finishOffset);
|
||||
blockFinished = true;
|
||||
}
|
||||
|
||||
streamChoicesLogger.info(logTarget, `solution ${c.index} returned. finish reason: [${c.reason}]`);
|
||||
streamChoicesLogger.debug(logTarget, `solution ${c.index} details: finishOffset: [${c.finishOffset}]`);
|
||||
const jsonData: APIJsonData = convertToAPIJsonData(c.solution);
|
||||
return convertToAPIChoice(accessor, completionText, jsonData, c.index, c.requestId, blockFinished, telemetryData);
|
||||
}
|
||||
|
||||
// Function to convert from APIJsonDataStreaming to APIJsonData format
|
||||
function convertToAPIJsonData(streamingData: APIJsonDataStreaming): APIJsonData {
|
||||
const joinedText = streamingData.text.join('');
|
||||
const annotations = streamingData.copilot_annotations.current;
|
||||
const out: APIJsonData = {
|
||||
text: joinedText,
|
||||
tokens: streamingData.text,
|
||||
copilot_annotations: annotations,
|
||||
finish_reason: streamingData.finish_reason ?? 'stop',
|
||||
};
|
||||
if (streamingData.logprobs.length === 0) {
|
||||
return out;
|
||||
}
|
||||
const flattenedLogprobs = streamingData.logprobs.reduce((acc, cur) => acc.concat(cur), []);
|
||||
const flattenedTopLogprobs = streamingData.top_logprobs.reduce((acc, cur) => acc.concat(cur), []);
|
||||
const flattenedOffsets = streamingData.text_offset.reduce((acc, cur) => acc.concat(cur), []);
|
||||
const flattenedTokens = streamingData.tokens.reduce((acc, cur) => acc.concat(cur), []);
|
||||
|
||||
return {
|
||||
...out,
|
||||
logprobs: {
|
||||
token_logprobs: flattenedLogprobs,
|
||||
top_logprobs: flattenedTopLogprobs,
|
||||
text_offset: flattenedOffsets,
|
||||
tokens: flattenedTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// data: {"choices":null,"copilot_confirmation":{"type":"action","title":"Are you sure you want to proceed?","message":"This action is irreversible.","confirmation":{"id":"123"}},"id":null}
|
||||
function isCopilotConfirmation(obj: unknown): obj is CopilotConfirmation {
|
||||
return (
|
||||
typeof (obj as CopilotConfirmation).title === 'string' &&
|
||||
typeof (obj as CopilotConfirmation).message === 'string' &&
|
||||
!!(obj as CopilotConfirmation).confirmation
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps track of how many chunks of a choice were read and yielded out. */
|
||||
class ChunkStats {
|
||||
private readonly choices = new Map<number, ChoiceStats>();
|
||||
|
||||
private getChoiceStats(choiceIndex: number): ChoiceStats {
|
||||
let choiceStat = this.choices.get(choiceIndex);
|
||||
if (!choiceStat) {
|
||||
choiceStat = new ChoiceStats();
|
||||
this.choices.set(choiceIndex, choiceStat);
|
||||
}
|
||||
return choiceStat;
|
||||
}
|
||||
|
||||
add(choiceIndex: number) {
|
||||
this.getChoiceStats(choiceIndex).increment();
|
||||
}
|
||||
|
||||
markYielded(choiceIndex: number) {
|
||||
this.getChoiceStats(choiceIndex).markYielded();
|
||||
}
|
||||
|
||||
toString() {
|
||||
return Array.from(this.choices.entries())
|
||||
.map(([index, stats]) => `${index}: ${stats.yieldedTokens} -> ${stats.seenTokens}`)
|
||||
.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceStats {
|
||||
yieldedTokens = -1;
|
||||
seenTokens = 0;
|
||||
|
||||
increment() {
|
||||
this.seenTokens++;
|
||||
}
|
||||
|
||||
markYielded() {
|
||||
this.yieldedTokens = this.seenTokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ExpTreatmentVariables } from '../../experiments/expConfig';
|
||||
import { TelemetryWithExp } from '../../telemetry';
|
||||
import { createLibTestingContext } from '../../test/context';
|
||||
import { getEngineRequestInfo } from '../config';
|
||||
|
||||
suite('OpenAI Config Tests', function () {
|
||||
let accessor: ServicesAccessor;
|
||||
|
||||
setup(function () {
|
||||
accessor = createLibTestingContext().createTestingAccessor();
|
||||
});
|
||||
|
||||
test('getEngineRequestInfo() returns the model from AvailableModelManager', function () {
|
||||
const telem = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
telem.filtersAndExp.exp.variables[ExpTreatmentVariables.CustomEngine] = 'model.override';
|
||||
|
||||
const info = getEngineRequestInfo(accessor, telem);
|
||||
|
||||
assert.strictEqual(info.modelId, 'model.override');
|
||||
assert.deepStrictEqual(info.headers, {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as Sinon from 'sinon';
|
||||
import { TestingServiceCollection } from '../../../../../../../platform/test/node/services';
|
||||
import { generateUuid } from '../../../../../../../util/vs/base/common/uuid';
|
||||
import { SyncDescriptor } from '../../../../../../../util/vs/platform/instantiation/common/descriptors';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CancellationTokenSource } from '../../../../types/src';
|
||||
import { ICompletionsCopilotTokenManager } from '../../auth/copilotTokenManager';
|
||||
import { FetchOptions, ICompletionsFetcherService, Response } from '../../networking';
|
||||
import { ICompletionsStatusReporter, StatusChangedEvent, StatusReporter } from '../../progress';
|
||||
import { TelemetryWithExp } from '../../telemetry';
|
||||
import { createLibTestingContext } from '../../test/context';
|
||||
import { createFakeResponse, createFakeStreamResponse, StaticFetcher } from '../../test/fetcher';
|
||||
import { withInMemoryTelemetry } from '../../test/telemetry';
|
||||
import {
|
||||
CMDQuotaExceeded,
|
||||
CompletionParams,
|
||||
CopilotUiKind,
|
||||
ICompletionsOpenAIFetcherService,
|
||||
LiveOpenAIFetcher, sanitizeRequestOptionTelemetry
|
||||
} from '../fetch';
|
||||
import { ErrorReturningFetcher, SyntheticCompletions } from '../fetch.fake';
|
||||
|
||||
suite('"Fetch" unit tests', function () {
|
||||
let accessor: ServicesAccessor;
|
||||
let serviceCollection: TestingServiceCollection;
|
||||
let resetSpy: Sinon.SinonSpy<Parameters<ICompletionsCopilotTokenManager['resetToken']>>;
|
||||
|
||||
setup(function () {
|
||||
serviceCollection = createLibTestingContext();
|
||||
serviceCollection.define(ICompletionsOpenAIFetcherService, new SyncDescriptor(ErrorReturningFetcher));
|
||||
accessor = serviceCollection.createTestingAccessor();
|
||||
resetSpy = Sinon.spy(accessor.get(ICompletionsCopilotTokenManager), 'resetToken');
|
||||
});
|
||||
|
||||
test('Empty/whitespace completions are stripped', async function () {
|
||||
const fetcher = new SyntheticCompletions(['', ' ', '\n'], accessor.get(ICompletionsCopilotTokenManager));
|
||||
const params: CompletionParams = {
|
||||
prompt: {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
isFimEnabled: false,
|
||||
},
|
||||
languageId: '',
|
||||
repoInfo: undefined,
|
||||
engineModelId: '',
|
||||
count: 1,
|
||||
uiKind: CopilotUiKind.GhostText,
|
||||
ourRequestId: generateUuid(),
|
||||
extra: {},
|
||||
};
|
||||
const cancellationToken = new CancellationTokenSource().token;
|
||||
const res = await fetcher.fetchAndStreamCompletions(
|
||||
params,
|
||||
TelemetryWithExp.createEmptyConfigForTesting(),
|
||||
() => undefined,
|
||||
cancellationToken
|
||||
);
|
||||
assert.deepStrictEqual(res.type, 'success');
|
||||
// keep the type checker happy
|
||||
if (res.type !== 'success') {
|
||||
throw new Error(`internal error: res.type is not 'success'`);
|
||||
}
|
||||
const stream = res.choices;
|
||||
const results = [];
|
||||
for await (const result of stream) {
|
||||
results.push(result);
|
||||
}
|
||||
assert.strictEqual(results.length, 0);
|
||||
});
|
||||
|
||||
test('If in the split context experiment, send the context field as part of the request', async function () {
|
||||
const networkFetcher = new OptionsRecorderFetcher(() => createFakeStreamResponse('data: [DONE]\n'));
|
||||
const params: CompletionParams = {
|
||||
prompt: {
|
||||
context: ['# Language: Python'],
|
||||
prefix: 'prefix without context',
|
||||
suffix: '\ndef sum(a, b):\n return a + b',
|
||||
isFimEnabled: true,
|
||||
},
|
||||
languageId: 'python',
|
||||
repoInfo: undefined,
|
||||
engineModelId: 'copilot-codex',
|
||||
count: 1,
|
||||
uiKind: CopilotUiKind.GhostText,
|
||||
postOptions: {},
|
||||
ourRequestId: generateUuid(),
|
||||
extra: {},
|
||||
};
|
||||
|
||||
const serviceCollectionClone = serviceCollection.clone();
|
||||
serviceCollectionClone.define(ICompletionsFetcherService, networkFetcher);
|
||||
const accessor = serviceCollectionClone.createTestingAccessor();
|
||||
|
||||
const telemetryWithExp = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
telemetryWithExp.filtersAndExp.exp.variables.copilotenablepromptcontextproxyfield = true;
|
||||
|
||||
const openAIFetcher = accessor.get(IInstantiationService).createInstance(LiveOpenAIFetcher);
|
||||
await openAIFetcher.fetchAndStreamCompletions(params, telemetryWithExp, () => undefined);
|
||||
|
||||
const options = networkFetcher.options;
|
||||
const json = options?.json as Record<string, unknown> | undefined;
|
||||
assert.strictEqual(json?.prompt, params.prompt.prefix);
|
||||
const extra = json?.extra as Record<string, unknown> | undefined;
|
||||
assert.strictEqual(extra?.context, params.prompt.context);
|
||||
});
|
||||
|
||||
test('properly handles 466 (client outdated) responses from proxy', async function () {
|
||||
const statusReporter = new TestStatusReporter();
|
||||
const result = await assertResponseWithStatus(466, statusReporter);
|
||||
|
||||
assert.deepStrictEqual(result, { type: 'failed', reason: 'client not supported: response-text' });
|
||||
assert.deepStrictEqual(statusReporter.kind, 'Error');
|
||||
assert.deepStrictEqual(statusReporter.message, 'response-text');
|
||||
assert.deepStrictEqual(statusReporter.eventCount, 1);
|
||||
});
|
||||
|
||||
test('has fallback for unknown http response codes from proxy', async function () {
|
||||
const statusReporter = new TestStatusReporter();
|
||||
const result = await assertResponseWithStatus(518, statusReporter);
|
||||
|
||||
assert.deepStrictEqual(result, { type: 'failed', reason: 'unhandled status from server: 518 response-text' });
|
||||
assert.deepStrictEqual(statusReporter.kind, 'Warning');
|
||||
assert.deepStrictEqual(statusReporter.message, 'Last response was a 518 error');
|
||||
});
|
||||
|
||||
test('calls out possible proxy for 4xx requests without x-github-request-id', async function () {
|
||||
const statusReporter = new TestStatusReporter();
|
||||
const result = await assertResponseWithStatus(418, statusReporter, { 'x-github-request-id': '' });
|
||||
|
||||
assert.deepStrictEqual(result, { type: 'failed', reason: 'unhandled status from server: 418 response-text' });
|
||||
assert.deepStrictEqual(statusReporter.kind, 'Warning');
|
||||
assert.deepStrictEqual(
|
||||
statusReporter.message,
|
||||
'Last response was a 418 error and does not appear to originate from GitHub. Is a proxy or firewall intercepting this request? https://gh.io/copilot-firewall'
|
||||
);
|
||||
});
|
||||
|
||||
test('HTTP `Unauthorized` invalidates token', async function () {
|
||||
const result = await assertResponseWithContext(accessor, 401);
|
||||
|
||||
assert.deepStrictEqual(result, { type: 'failed', reason: 'token expired or invalid: 401' });
|
||||
assert.ok(resetSpy.calledOnce, 'resetToken should have been called once');
|
||||
});
|
||||
|
||||
test('HTTP `Forbidden` invalidates token', async function () {
|
||||
const result = await assertResponseWithContext(accessor, 403);
|
||||
|
||||
assert.deepStrictEqual(result, { type: 'failed', reason: 'token expired or invalid: 403' });
|
||||
assert.ok(resetSpy.calledOnce, 'resetToken should have been called once');
|
||||
});
|
||||
|
||||
test('HTTP `Too many requests` enforces rate limiting locally', async function () {
|
||||
const serviceCollection = createLibTestingContext();
|
||||
serviceCollection.define(ICompletionsOpenAIFetcherService, new SyncDescriptor(ErrorReturningFetcher));
|
||||
const accessor = serviceCollection.createTestingAccessor();
|
||||
const result = await assertResponseWithContext(accessor, 429);
|
||||
const fetcherService = accessor.get(ICompletionsOpenAIFetcherService);
|
||||
|
||||
assert.deepStrictEqual(result, { type: 'failed', reason: 'rate limited' });
|
||||
const limited = await fetcherService.fetchAndStreamCompletions(
|
||||
{} as CompletionParams,
|
||||
TelemetryWithExp.createEmptyConfigForTesting(),
|
||||
() => Promise.reject(new Error()),
|
||||
new CancellationTokenSource().token
|
||||
);
|
||||
assert.deepStrictEqual(limited, { type: 'canceled', reason: 'rate limited' });
|
||||
});
|
||||
|
||||
test.skip('properly handles 402 (free plan exhausted) responses from proxy', async function () {
|
||||
const fetcherService = accessor.get(ICompletionsOpenAIFetcherService);
|
||||
const tokenManager = accessor.get(ICompletionsCopilotTokenManager);
|
||||
await tokenManager.primeToken(); // Trigger initial status
|
||||
const statusReporter = new TestStatusReporter();
|
||||
|
||||
const serviceCollectionClone = serviceCollection.clone();
|
||||
serviceCollectionClone.define(ICompletionsStatusReporter, statusReporter);
|
||||
const accessorClone = serviceCollectionClone.createTestingAccessor();
|
||||
const result = await assertResponseWithContext(accessorClone, 402);
|
||||
|
||||
assert.deepStrictEqual(result, { type: 'failed', reason: 'monthly free code completions exhausted' });
|
||||
assert.deepStrictEqual(statusReporter.kind, 'Error');
|
||||
assert.match(statusReporter.message, /limit/);
|
||||
assert.deepStrictEqual(statusReporter.eventCount, 1);
|
||||
assert.deepStrictEqual(statusReporter.command, CMDQuotaExceeded);
|
||||
const exhausted = await fetcherService.fetchAndStreamCompletions(
|
||||
fakeCompletionParams(),
|
||||
TelemetryWithExp.createEmptyConfigForTesting(),
|
||||
() => Promise.reject(new Error()),
|
||||
new CancellationTokenSource().token
|
||||
);
|
||||
assert.deepStrictEqual(exhausted, { type: 'canceled', reason: 'monthly free code completions exhausted' });
|
||||
|
||||
tokenManager.resetToken();
|
||||
await tokenManager.getToken();
|
||||
|
||||
const refreshed = await assertResponseWithContext(accessorClone, 429);
|
||||
assert.deepStrictEqual(refreshed, { type: 'failed', reason: 'rate limited' });
|
||||
assert.deepStrictEqual(statusReporter.kind, 'Error');
|
||||
});
|
||||
|
||||
test('additional headers are included in the request', async function () {
|
||||
const networkFetcher = new StaticFetcher(() => createFakeStreamResponse('data: [DONE]\n'));
|
||||
const params: CompletionParams = {
|
||||
prompt: {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
isFimEnabled: false,
|
||||
},
|
||||
languageId: '',
|
||||
repoInfo: undefined,
|
||||
engineModelId: 'copilot-codex',
|
||||
count: 1,
|
||||
uiKind: CopilotUiKind.GhostText,
|
||||
ourRequestId: generateUuid(),
|
||||
headers: { Host: 'bla' },
|
||||
extra: {},
|
||||
};
|
||||
const serviceCollectionClone = serviceCollection.clone();
|
||||
serviceCollectionClone.define(ICompletionsFetcherService, networkFetcher);
|
||||
const accessor = serviceCollectionClone.createTestingAccessor();
|
||||
|
||||
const openAIFetcher = accessor.get(IInstantiationService).createInstance(LiveOpenAIFetcher);
|
||||
await openAIFetcher.fetchAndStreamCompletions(
|
||||
params,
|
||||
TelemetryWithExp.createEmptyConfigForTesting(),
|
||||
() => undefined
|
||||
);
|
||||
|
||||
assert.strictEqual(networkFetcher.headerBuffer!['Host'], 'bla');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
suite('Telemetry sent on fetch', function () {
|
||||
let accessor: ServicesAccessor;
|
||||
|
||||
setup(function () {
|
||||
const serviceCollection = createLibTestingContext();
|
||||
serviceCollection.define(ICompletionsFetcherService, new OptionsRecorderFetcher(() => createFakeStreamResponse('data: [DONE]\n')));
|
||||
accessor = serviceCollection.createTestingAccessor();
|
||||
});
|
||||
|
||||
test('sanitizeRequestOptionTelemetry properly excludes top-level keys', function () {
|
||||
const request = {
|
||||
prompt: 'prompt prefix',
|
||||
suffix: 'prompt suffix',
|
||||
stream: true as const,
|
||||
count: 1,
|
||||
extra: {
|
||||
language: 'python',
|
||||
},
|
||||
};
|
||||
|
||||
const telemetryWithExp = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
|
||||
sanitizeRequestOptionTelemetry(request, telemetryWithExp, ['prompt', 'suffix']);
|
||||
|
||||
assert.deepStrictEqual(telemetryWithExp.properties, {
|
||||
'request.option.stream': 'true',
|
||||
'request.option.count': '1',
|
||||
'request.option.extra': '{"language":"python"}',
|
||||
});
|
||||
});
|
||||
|
||||
test('sanitizeRequestOptionTelemetry properly excludes `extra` keys', function () {
|
||||
const request = {
|
||||
prompt: 'prefix without context',
|
||||
suffix: 'prompt suffix',
|
||||
stream: true as const,
|
||||
count: 1,
|
||||
extra: {
|
||||
language: 'python',
|
||||
context: ['# Language: Python'],
|
||||
},
|
||||
};
|
||||
|
||||
const telemetryWithExp = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
|
||||
sanitizeRequestOptionTelemetry(request, telemetryWithExp, ['prompt', 'suffix'], ['context']);
|
||||
|
||||
assert.deepStrictEqual(telemetryWithExp.properties, {
|
||||
'request.option.stream': 'true',
|
||||
'request.option.count': '1',
|
||||
'request.option.extra': '{"language":"python"}',
|
||||
});
|
||||
});
|
||||
|
||||
test('If context is provided while in the split context experiment, only send it in restricted telemetry events', async function () {
|
||||
const params: CompletionParams = {
|
||||
prompt: {
|
||||
context: ['# Language: Python'],
|
||||
prefix: 'prefix without context',
|
||||
suffix: '\ndef sum(a, b):\n return a + b',
|
||||
isFimEnabled: true,
|
||||
},
|
||||
languageId: 'python',
|
||||
repoInfo: undefined,
|
||||
engineModelId: 'copilot-codex',
|
||||
count: 1,
|
||||
uiKind: CopilotUiKind.GhostText,
|
||||
postOptions: {},
|
||||
ourRequestId: generateUuid(),
|
||||
extra: {},
|
||||
};
|
||||
|
||||
const openAIFetcher = accessor.get(IInstantiationService).createInstance(LiveOpenAIFetcher);
|
||||
const telemetryWithExp = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
telemetryWithExp.filtersAndExp.exp.variables.copilotenablepromptcontextproxyfield = true;
|
||||
|
||||
const { reporter } = await withInMemoryTelemetry(accessor, async () => {
|
||||
await openAIFetcher.fetchAndStreamCompletions(params, telemetryWithExp, () => undefined);
|
||||
});
|
||||
|
||||
const standardEvents = reporter.events;
|
||||
const hasContext = standardEvents.some(event => event.properties['request_option_extra']?.includes('context'));
|
||||
assert.strictEqual(hasContext, false, 'Standard telemetry event should not include context');
|
||||
|
||||
// todo@dbaeumer we need to understand what our restricted telemetry story is.
|
||||
// const restrictedEvents = enhancedReporter.events;
|
||||
// const hasRestrictedContext = restrictedEvents.some(event =>
|
||||
// event.properties['request_option_extra']?.includes('context')
|
||||
// );
|
||||
// assert.strictEqual(hasRestrictedContext, true, 'Restricted telemetry event should include context');
|
||||
});
|
||||
|
||||
test('If context is provided, include it in `engine.prompt` telemetry events', function () { });
|
||||
});
|
||||
|
||||
class TestStatusReporter extends StatusReporter {
|
||||
eventCount = 0;
|
||||
kind = 'Normal';
|
||||
message = '';
|
||||
command: string | undefined;
|
||||
|
||||
override didChange(event: StatusChangedEvent): void {
|
||||
this.eventCount++;
|
||||
this.kind = event.kind;
|
||||
this.message = event.message || '';
|
||||
this.command = event.command?.command;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertResponseWithStatus(
|
||||
statusCode: number,
|
||||
statusReporter: ICompletionsStatusReporter,
|
||||
headers?: Record<string, string>
|
||||
) {
|
||||
const serviceCollection = createLibTestingContext();
|
||||
serviceCollection.define(ICompletionsStatusReporter, statusReporter);
|
||||
const accessor = serviceCollection.createTestingAccessor();
|
||||
const copilotTokenManager = accessor.get(ICompletionsCopilotTokenManager);
|
||||
await copilotTokenManager.primeToken(); // Trigger initial status
|
||||
return assertResponseWithContext(accessor, statusCode, headers);
|
||||
}
|
||||
|
||||
async function assertResponseWithContext(accessor: ServicesAccessor, statusCode: number, headers?: Record<string, string>) {
|
||||
const response = createFakeResponse(statusCode, 'response-text', headers);
|
||||
const fetcher = accessor.getIfExists(ICompletionsOpenAIFetcherService) as ErrorReturningFetcher ?? accessor.get(IInstantiationService).createInstance(ErrorReturningFetcher);
|
||||
fetcher.setResponse(response);
|
||||
const completionParams: CompletionParams = fakeCompletionParams();
|
||||
const result = await fetcher.fetchAndStreamCompletions(
|
||||
completionParams,
|
||||
TelemetryWithExp.createEmptyConfigForTesting(),
|
||||
() => Promise.reject(new Error()),
|
||||
new CancellationTokenSource().token
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function fakeCompletionParams(): CompletionParams {
|
||||
return {
|
||||
prompt: {
|
||||
prefix: 'xxx',
|
||||
suffix: '',
|
||||
isFimEnabled: false,
|
||||
},
|
||||
languageId: '',
|
||||
repoInfo: undefined,
|
||||
ourRequestId: generateUuid(),
|
||||
engineModelId: 'foo/bar',
|
||||
count: 1,
|
||||
uiKind: CopilotUiKind.GhostText,
|
||||
postOptions: {},
|
||||
extra: {},
|
||||
};
|
||||
}
|
||||
|
||||
class OptionsRecorderFetcher extends StaticFetcher {
|
||||
options: FetchOptions | undefined;
|
||||
|
||||
override fetch(url: string, options: FetchOptions): Promise<Response> {
|
||||
this.options = options;
|
||||
|
||||
return super.fetch(url, options);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,471 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { CopilotNamedAnnotationList } from '../../../../../platform/completions-core/common/openai/copilotAnnotations';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsTelemetryService } from '../../bridge/src/completionsTelemetryServiceBridge';
|
||||
import { ICompletionsCopilotTokenManager } from './auth/copilotTokenManager';
|
||||
import { ChangeTracker } from './changeTracker';
|
||||
import { ICompletionsCitationManager, IPCitationDetail } from './citationManager';
|
||||
import { createCompletionState } from './completionState';
|
||||
import { ICompletionsFileReaderService } from './fileReader';
|
||||
import { PostInsertionCategory, telemetryAccepted, telemetryRejected } from './ghostText/telemetry';
|
||||
import { ICompletionsLogTargetService, Logger } from './logger';
|
||||
import { contextIndentationFromText, indentationBlockFinished } from './prompt/parseBlock';
|
||||
import { Prompt, extractPrompt } from './prompt/prompt';
|
||||
import { fetchCitations } from './snippy/handlePostInsertion';
|
||||
import { editDistance, lexEditDistance } from './suggestions/editDistance';
|
||||
import { SuggestionStatus, computeCompletionText } from './suggestions/partialSuggestions';
|
||||
import { TelemetryStore, TelemetryWithExp, telemetry, telemetryCatch } from './telemetry';
|
||||
import { ICompletionsTextDocumentManagerService } from './textDocumentManager';
|
||||
import { ICompletionsPromiseQueueService } from './util/promiseQueue';
|
||||
import { ICompletionsRuntimeModeService } from './util/runtimeMode';
|
||||
|
||||
const postInsertionLogger = new Logger('postInsertion');
|
||||
|
||||
type Timeout = {
|
||||
seconds: number;
|
||||
captureCode: boolean;
|
||||
captureRejection: boolean;
|
||||
};
|
||||
// windows for telemetry checks, in seconds
|
||||
// captureCode = capture the code after acceptance,
|
||||
// captureRejection = capture the code after rejection
|
||||
const captureTimeouts: Timeout[] = [
|
||||
{ seconds: 15, captureCode: false, captureRejection: false },
|
||||
{ seconds: 30, captureCode: true, captureRejection: true },
|
||||
{ seconds: 120, captureCode: false, captureRejection: false },
|
||||
{ seconds: 300, captureCode: false, captureRejection: false },
|
||||
{ seconds: 600, captureCode: false, captureRejection: false },
|
||||
];
|
||||
|
||||
// No. of chars before/after insertion point to look for the completion
|
||||
const stillInCodeNearMargin = 50;
|
||||
const stillInCodeFarMargin = 1500;
|
||||
|
||||
// If lex edit distance is below this fraction of completion length it is considered
|
||||
// in the code
|
||||
const stillInCodeFraction = 0.5;
|
||||
|
||||
// Number of characters captured after the insertion point.
|
||||
// Used only if we couldn't detect termination point with indent-based parsing.
|
||||
const captureCodeMargin = 500;
|
||||
|
||||
const postInsertConfiguration: {
|
||||
triggerPostInsertionSynchroneously: boolean;
|
||||
captureCode: boolean;
|
||||
captureRejection: boolean;
|
||||
} = {
|
||||
triggerPostInsertionSynchroneously: false,
|
||||
captureCode: false,
|
||||
captureRejection: false,
|
||||
};
|
||||
|
||||
async function captureCode(
|
||||
accessor: ServicesAccessor,
|
||||
uri: string,
|
||||
completionTelemetry: TelemetryWithExp,
|
||||
offset: number,
|
||||
suffixOffset?: number
|
||||
): Promise<{ prompt: Prompt; capturedCode: string; terminationOffset: number }> {
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const logTarget = accessor.get(ICompletionsLogTargetService);
|
||||
const result = await accessor.get(ICompletionsFileReaderService).getOrReadTextDocumentWithFakeClientProperties({ uri });
|
||||
if (result.status !== 'valid') {
|
||||
postInsertionLogger.info(logTarget, `Could not get document for ${uri}. Maybe it was closed by the editor.`);
|
||||
return {
|
||||
prompt: {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
isFimEnabled: false,
|
||||
},
|
||||
capturedCode: '',
|
||||
terminationOffset: 0,
|
||||
};
|
||||
}
|
||||
const document = result.document;
|
||||
const documentText = document.getText();
|
||||
const documentTextBefore = documentText.substring(0, offset);
|
||||
const position = document.positionAt(offset);
|
||||
|
||||
// Treat the code before offset as the hypothetical prompt
|
||||
const hypotheticalPromptResponse = await instantiationService.invokeFunction(extractPrompt,
|
||||
completionTelemetry.properties.headerRequestId,
|
||||
createCompletionState(document, position),
|
||||
completionTelemetry
|
||||
);
|
||||
const hypotheticalPrompt =
|
||||
hypotheticalPromptResponse.type === 'prompt'
|
||||
? hypotheticalPromptResponse.prompt
|
||||
: {
|
||||
prefix: documentTextBefore,
|
||||
suffix: '',
|
||||
isFimEnabled: false,
|
||||
}; // TODO(eaftan): Pass an actual suffix when we're ready to support it
|
||||
|
||||
if (hypotheticalPrompt.isFimEnabled && suffixOffset !== undefined) {
|
||||
// With FIM enabled, we can exactly determine capturedCode, suffix and prefix by propertly initialized trackers. No need to guess.
|
||||
const capturedCode = documentText.substring(offset, suffixOffset);
|
||||
hypotheticalPrompt.suffix = documentText.substring(suffixOffset);
|
||||
|
||||
return { prompt: hypotheticalPrompt, capturedCode, terminationOffset: 0 };
|
||||
} else {
|
||||
//Everything after the insertion point is hypothetical response we could get from AI
|
||||
const hypotheticalResponse = documentText.substring(offset);
|
||||
|
||||
//Try to find the termination offset in the hypothetical response using indentation based parsing
|
||||
const contextIndent = contextIndentationFromText(documentTextBefore, offset, document.detectedLanguageId);
|
||||
const indentTerminationFunction = indentationBlockFinished(contextIndent, undefined);
|
||||
const terminationResult = indentTerminationFunction(hypotheticalResponse);
|
||||
|
||||
//If we could detect termination of the indentation block, capture 2x length of detected suggestion
|
||||
//Otherwise capture a lot of characters
|
||||
const maxOffset = Math.min(
|
||||
documentText.length,
|
||||
offset + (terminationResult ? terminationResult * 2 : captureCodeMargin)
|
||||
);
|
||||
|
||||
const capturedCode = documentText.substring(offset, maxOffset);
|
||||
|
||||
return { prompt: hypotheticalPrompt, capturedCode, terminationOffset: terminationResult ?? -1 };
|
||||
}
|
||||
}
|
||||
|
||||
export function postRejectionTasks(
|
||||
accessor: ServicesAccessor,
|
||||
insertionCategory: PostInsertionCategory,
|
||||
insertionOffset: number,
|
||||
uri: string,
|
||||
completions: { completionText: string; completionTelemetryData: TelemetryWithExp }[]
|
||||
) {
|
||||
const logTarget = accessor.get(ICompletionsLogTargetService);
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const telemetryService = accessor.get(ICompletionsTelemetryService);
|
||||
const promiseQueueService = accessor.get(ICompletionsPromiseQueueService);
|
||||
|
||||
//Send `.rejected` telemetry event for each rejected completion
|
||||
completions.forEach(({ completionText, completionTelemetryData }) => {
|
||||
postInsertionLogger.debug(
|
||||
logTarget,
|
||||
`${insertionCategory}.rejected choiceIndex: ${completionTelemetryData.properties.choiceIndex}`
|
||||
);
|
||||
instantiationService.invokeFunction(telemetryRejected, insertionCategory, completionTelemetryData);
|
||||
});
|
||||
const positionTracker = instantiationService.createInstance(ChangeTracker, uri, insertionOffset - 1);
|
||||
const suffixTracker = instantiationService.createInstance(ChangeTracker, uri, insertionOffset);
|
||||
|
||||
const checkInCode = async (t: Timeout) => {
|
||||
postInsertionLogger.debug(
|
||||
logTarget,
|
||||
`Original offset: ${insertionOffset}, Tracked offset: ${positionTracker.offset}`
|
||||
);
|
||||
const { completionTelemetryData } = completions[0];
|
||||
|
||||
const { prompt, capturedCode, terminationOffset } = await instantiationService.invokeFunction(captureCode,
|
||||
uri,
|
||||
completionTelemetryData,
|
||||
positionTracker.offset + 1,
|
||||
suffixTracker.offset
|
||||
);
|
||||
|
||||
const promptTelemetry = {
|
||||
hypotheticalPromptJson: JSON.stringify({ prefix: prompt.prefix, context: prompt.context }),
|
||||
hypotheticalPromptSuffixJson: JSON.stringify(prompt.suffix),
|
||||
};
|
||||
|
||||
const customTelemetryData = completionTelemetryData.extendedBy(
|
||||
{
|
||||
...promptTelemetry,
|
||||
capturedCodeJson: JSON.stringify(capturedCode),
|
||||
},
|
||||
{
|
||||
timeout: t.seconds,
|
||||
insertionOffset: insertionOffset,
|
||||
trackedOffset: positionTracker.offset,
|
||||
terminationOffsetInCapturedCode: terminationOffset,
|
||||
}
|
||||
);
|
||||
postInsertionLogger.debug(
|
||||
logTarget,
|
||||
`${insertionCategory}.capturedAfterRejected choiceIndex: ${completionTelemetryData.properties.choiceIndex}`,
|
||||
customTelemetryData
|
||||
);
|
||||
instantiationService.invokeFunction(telemetry, insertionCategory + '.capturedAfterRejected', customTelemetryData, TelemetryStore.Enhanced);
|
||||
};
|
||||
// Capture the code typed after we detected that completion was rejected,
|
||||
// Uses first displayed completion as the source/seed of telemetry information.
|
||||
captureTimeouts
|
||||
.filter(t => t.captureRejection)
|
||||
.map(t =>
|
||||
positionTracker.push(
|
||||
telemetryCatch(telemetryService, promiseQueueService, () => checkInCode(t), 'postRejectionTasks'),
|
||||
t.seconds * 1000
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function postInsertionTasks(
|
||||
accessor: ServicesAccessor,
|
||||
insertionCategory: PostInsertionCategory,
|
||||
completionText: string,
|
||||
insertionOffset: number,
|
||||
uri: string,
|
||||
telemetryData: TelemetryWithExp,
|
||||
suggestionStatus: SuggestionStatus,
|
||||
copilotAnnotations?: CopilotNamedAnnotationList
|
||||
) {
|
||||
const logTarget = accessor.get(ICompletionsLogTargetService);
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const promiseQueueService = accessor.get(ICompletionsPromiseQueueService);
|
||||
const telemetryService = accessor.get(ICompletionsTelemetryService);
|
||||
const runtimeModeService = accessor.get(ICompletionsRuntimeModeService);
|
||||
|
||||
const telemetryDataWithStatus = telemetryData.extendedBy(
|
||||
{
|
||||
compType: suggestionStatus.compType,
|
||||
},
|
||||
{
|
||||
compCharLen: suggestionStatus.acceptedLength,
|
||||
numLines: suggestionStatus.acceptedLines,
|
||||
}
|
||||
);
|
||||
// send ".accepted" telemetry
|
||||
postInsertionLogger.debug(
|
||||
logTarget,
|
||||
`${insertionCategory}.accepted choiceIndex: ${telemetryDataWithStatus.properties.choiceIndex}`
|
||||
);
|
||||
instantiationService.invokeFunction(telemetryAccepted, insertionCategory, telemetryDataWithStatus);
|
||||
|
||||
const fullCompletionText = completionText;
|
||||
completionText = computeCompletionText(completionText, suggestionStatus);
|
||||
const trimmedCompletion = completionText.trim();
|
||||
const tracker = instantiationService.createInstance(ChangeTracker, uri, insertionOffset);
|
||||
const suffixTracker = instantiationService.createInstance(ChangeTracker, uri, insertionOffset + completionText.length);
|
||||
|
||||
const stillInCodeCheck = async (timeout: Timeout) => {
|
||||
const check = instantiationService.invokeFunction(checkStillInCode,
|
||||
insertionCategory,
|
||||
trimmedCompletion,
|
||||
insertionOffset,
|
||||
uri,
|
||||
timeout,
|
||||
telemetryDataWithStatus,
|
||||
tracker,
|
||||
suffixTracker
|
||||
);
|
||||
await check;
|
||||
};
|
||||
|
||||
// For test purposes, we add one set of these telemetry events synchronously to allow asserting the telemetry
|
||||
if (postInsertConfiguration.triggerPostInsertionSynchroneously && runtimeModeService.isRunningInTest()) {
|
||||
const check = stillInCodeCheck({
|
||||
seconds: 0,
|
||||
captureCode: postInsertConfiguration.captureCode,
|
||||
captureRejection: postInsertConfiguration.captureRejection,
|
||||
});
|
||||
promiseQueueService.register(check);
|
||||
} else {
|
||||
captureTimeouts.map(timeout =>
|
||||
tracker.push(
|
||||
telemetryCatch(telemetryService, promiseQueueService, () => stillInCodeCheck(timeout), 'postInsertionTasks'),
|
||||
timeout.seconds * 1000
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
instantiationService.invokeFunction(acc => telemetryCatch(telemetryService, promiseQueueService, citationCheck, 'post insertion citation check')(
|
||||
acc,
|
||||
uri,
|
||||
fullCompletionText,
|
||||
completionText,
|
||||
insertionOffset,
|
||||
copilotAnnotations
|
||||
));
|
||||
}
|
||||
|
||||
async function citationCheck(
|
||||
accessor: ServicesAccessor,
|
||||
uri: string,
|
||||
fullCompletionText: string,
|
||||
insertedText: string,
|
||||
insertionOffset: number,
|
||||
copilotAnnotations?: CopilotNamedAnnotationList
|
||||
) {
|
||||
const logTarget = accessor.get(ICompletionsLogTargetService);
|
||||
const textDocumentManagerService = accessor.get(ICompletionsTextDocumentManagerService);
|
||||
const copilotTokenManager = accessor.get(ICompletionsCopilotTokenManager);
|
||||
const citationManagerService = accessor.get(ICompletionsCitationManager);
|
||||
|
||||
// If there are no citations, request directly from the snippy service
|
||||
if (!copilotAnnotations || (copilotAnnotations.ip_code_citations?.length ?? 0) < 1) {
|
||||
// Do not request citations if in blocking mode
|
||||
if (copilotTokenManager.getLastToken()?.getTokenValue('sn') === '1') { return; }
|
||||
await fetchCitations(accessor, uri, insertedText, insertionOffset);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await textDocumentManagerService.getTextDocument({ uri });
|
||||
|
||||
// in the CLS, if the editor does not wait to send document updates until the
|
||||
// acceptance function returns, we could be in a race condition with ongoing
|
||||
// edits. This searches for the completion text so that hopefully we're providing
|
||||
// an exact location in a known version of the document.
|
||||
if (doc) {
|
||||
const found = find(doc.getText(), insertedText, stillInCodeNearMargin, insertionOffset);
|
||||
if (found.stillInCodeHeuristic) {
|
||||
insertionOffset = found.foundOffset;
|
||||
}
|
||||
}
|
||||
|
||||
for (const citation of copilotAnnotations.ip_code_citations) {
|
||||
const citationStart = computeCitationStart(
|
||||
fullCompletionText.length,
|
||||
insertedText.length,
|
||||
citation.start_offset
|
||||
);
|
||||
if (citationStart === undefined) {
|
||||
postInsertionLogger.info(
|
||||
logTarget,
|
||||
`Full completion for ${uri} contains a reference matching public code, but the partially inserted text did not include the match.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const offsetStart = insertionOffset + citationStart;
|
||||
const start = doc?.positionAt(offsetStart);
|
||||
const offsetEnd =
|
||||
insertionOffset + computeCitationEnd(fullCompletionText.length, insertedText.length, citation.stop_offset);
|
||||
const end = doc?.positionAt(offsetEnd);
|
||||
const text = start && end ? doc?.getText({ start, end }) : '<unknown>';
|
||||
|
||||
await citationManagerService.handleIPCodeCitation({
|
||||
inDocumentUri: uri,
|
||||
offsetStart,
|
||||
offsetEnd,
|
||||
version: doc?.version,
|
||||
location: start && end ? { start, end } : undefined,
|
||||
matchingText: text,
|
||||
details: citation.details.citations as IPCitationDetail[],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function computeCitationStart(
|
||||
completionLength: number,
|
||||
insertedLength: number,
|
||||
citationStartOffset: number
|
||||
): number | undefined {
|
||||
if (insertedLength < completionLength && citationStartOffset > insertedLength) {
|
||||
return undefined;
|
||||
}
|
||||
return citationStartOffset;
|
||||
}
|
||||
|
||||
function computeCitationEnd(completionLength: number, insertedLength: number, citationStopOffset: number): number {
|
||||
if (insertedLength < completionLength) {
|
||||
return Math.min(citationStopOffset, insertedLength);
|
||||
}
|
||||
return citationStopOffset;
|
||||
}
|
||||
|
||||
function find(documentText: string, completion: string, margin: number, offset: number) {
|
||||
// Compute the best alignment between a window of the document text and the completion
|
||||
const window = documentText.substring(
|
||||
Math.max(0, offset - margin),
|
||||
Math.min(documentText.length, offset + completion.length + margin)
|
||||
);
|
||||
const lexAlignment = lexEditDistance(window, completion);
|
||||
const fraction = lexAlignment.lexDistance / lexAlignment.needleLexLength;
|
||||
const { distance: charEditDistance } = editDistance(
|
||||
window.substring(lexAlignment.startOffset, lexAlignment.endOffset),
|
||||
completion
|
||||
);
|
||||
return {
|
||||
relativeLexEditDistance: fraction,
|
||||
charEditDistance,
|
||||
completionLexLength: lexAlignment.needleLexLength,
|
||||
foundOffset: lexAlignment.startOffset + Math.max(0, offset - margin),
|
||||
lexEditDistance: lexAlignment.lexDistance,
|
||||
stillInCodeHeuristic: fraction <= stillInCodeFraction ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkStillInCode(
|
||||
accessor: ServicesAccessor,
|
||||
insertionCategory: string,
|
||||
completion: string,
|
||||
insertionOffset: number, // offset where the completion was inserted to
|
||||
uri: string,
|
||||
timeout: Timeout,
|
||||
telemetryData: TelemetryWithExp,
|
||||
tracker: ChangeTracker,
|
||||
suffixTracker: ChangeTracker
|
||||
) {
|
||||
// Get contents of file from file system
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const logTarget = accessor.get(ICompletionsLogTargetService);
|
||||
const result = await accessor.get(ICompletionsFileReaderService).getOrReadTextDocument({ uri });
|
||||
if (result.status === 'valid') {
|
||||
const document = result.document;
|
||||
const documentText = document.getText();
|
||||
|
||||
// We try twice, first very close to the insertion point, then a bit
|
||||
// further. This is to increase accuracy for short completions,
|
||||
// where the completion might appear elsewhere.
|
||||
let finding = find(documentText, completion, stillInCodeNearMargin, tracker.offset);
|
||||
if (!finding.stillInCodeHeuristic) {
|
||||
finding = find(documentText, completion, stillInCodeFarMargin, tracker.offset);
|
||||
}
|
||||
// Debug and log a binary decision
|
||||
postInsertionLogger.debug(
|
||||
logTarget,
|
||||
`stillInCode: ${finding.stillInCodeHeuristic ? 'Found' : 'Not found'}! Completion '${completion}' in file ${uri
|
||||
}. lexEditDistance fraction was ${finding.relativeLexEditDistance}. Char edit distance was ${finding.charEditDistance
|
||||
}. Inserted at ${insertionOffset}, tracked at ${tracker.offset}, found at ${finding.foundOffset
|
||||
}. choiceIndex: ${telemetryData.properties.choiceIndex}`
|
||||
);
|
||||
// Log all the details for analysis
|
||||
const customTelemetryData = telemetryData
|
||||
.extendedBy({}, { timeout: timeout.seconds, insertionOffset: insertionOffset, trackedOffset: tracker.offset })
|
||||
.extendedBy({}, finding);
|
||||
instantiationService.invokeFunction(telemetry, insertionCategory + '.stillInCode', customTelemetryData);
|
||||
|
||||
if (timeout.captureCode) {
|
||||
const { prompt, capturedCode, terminationOffset } = await instantiationService.invokeFunction(
|
||||
captureCode,
|
||||
uri,
|
||||
customTelemetryData,
|
||||
tracker.offset,
|
||||
suffixTracker.offset
|
||||
);
|
||||
const promptTelemetry = {
|
||||
hypotheticalPromptJson: JSON.stringify({ prefix: prompt.prefix, context: prompt.context }),
|
||||
hypotheticalPromptSuffixJson: JSON.stringify(prompt.suffix),
|
||||
};
|
||||
|
||||
const afterAcceptedTelemetry = telemetryData.extendedBy(
|
||||
{
|
||||
...promptTelemetry,
|
||||
capturedCodeJson: JSON.stringify(capturedCode),
|
||||
},
|
||||
{
|
||||
timeout: timeout.seconds,
|
||||
insertionOffset: insertionOffset,
|
||||
trackedOffset: tracker.offset,
|
||||
terminationOffsetInCapturedCode: terminationOffset,
|
||||
}
|
||||
);
|
||||
postInsertionLogger.debug(
|
||||
logTarget,
|
||||
`${insertionCategory}.capturedAfterAccepted choiceIndex: ${telemetryData.properties.choiceIndex}`,
|
||||
customTelemetryData
|
||||
);
|
||||
instantiationService.invokeFunction(
|
||||
telemetry,
|
||||
insertionCategory + '.capturedAfterAccepted',
|
||||
afterAcceptedTelemetry,
|
||||
TelemetryStore.Enhanced
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { createServiceIdentifier } from '../../../../../util/common/services';
|
||||
import { Command, StatusKind } from '../../types/src';
|
||||
|
||||
export interface StatusChangedEvent {
|
||||
kind: StatusKind;
|
||||
message?: string;
|
||||
busy: boolean;
|
||||
command?: Command;
|
||||
}
|
||||
|
||||
export const ICompletionsStatusReporter = createServiceIdentifier<ICompletionsStatusReporter>('ICompletionsStatusReporter');
|
||||
export interface ICompletionsStatusReporter {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
busy: boolean;
|
||||
|
||||
withProgress<T>(callback: () => Promise<T>): Promise<T>;
|
||||
|
||||
forceStatus(kind: StatusKind, message?: string, command?: Command): void;
|
||||
forceNormal(): void;
|
||||
setError(message: string, command?: Command): void;
|
||||
setWarning(message: string): void;
|
||||
setInactive(message: string): void;
|
||||
clearInactive(): void;
|
||||
}
|
||||
|
||||
export abstract class StatusReporter implements ICompletionsStatusReporter {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
#inProgressCount = 0;
|
||||
#kind: StatusKind = 'Normal';
|
||||
#message: string | undefined;
|
||||
#command: Command | undefined;
|
||||
#startup = true;
|
||||
|
||||
abstract didChange(event: StatusChangedEvent): void;
|
||||
|
||||
get busy() {
|
||||
return this.#inProgressCount > 0;
|
||||
}
|
||||
|
||||
withProgress<T>(callback: () => Promise<T>): Promise<T> {
|
||||
if (this.#kind === 'Warning') { this.forceNormal(); }
|
||||
if (this.#inProgressCount++ === 0) { this.#didChange(); }
|
||||
return callback().finally(() => {
|
||||
if (--this.#inProgressCount === 0) { this.#didChange(); }
|
||||
});
|
||||
}
|
||||
|
||||
forceStatus(kind: StatusKind, message?: string, command?: Command) {
|
||||
if (this.#kind === kind && this.#message === message && !command && !this.#command && !this.#startup) { return; }
|
||||
this.#kind = kind;
|
||||
this.#message = message;
|
||||
this.#command = command;
|
||||
this.#startup = false;
|
||||
this.#didChange();
|
||||
}
|
||||
|
||||
forceNormal() {
|
||||
if (this.#kind === 'Inactive') { return; }
|
||||
this.forceStatus('Normal');
|
||||
}
|
||||
|
||||
setError(message: string, command?: Command) {
|
||||
this.forceStatus('Error', message, command);
|
||||
}
|
||||
|
||||
setWarning(message: string) {
|
||||
if (this.#kind === 'Error') { return; }
|
||||
this.forceStatus('Warning', message);
|
||||
}
|
||||
|
||||
setInactive(message: string) {
|
||||
if (this.#kind === 'Error' || this.#kind === 'Warning') { return; }
|
||||
this.forceStatus('Inactive', message);
|
||||
}
|
||||
|
||||
clearInactive() {
|
||||
if (this.#kind !== 'Inactive') { return; }
|
||||
this.forceStatus('Normal');
|
||||
}
|
||||
|
||||
#didChange() {
|
||||
const event = { kind: this.#kind, message: this.#message, busy: this.busy, command: this.#command };
|
||||
this.didChange(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't delete. Needed for tests that don't care about status changes
|
||||
export class NoOpStatusReporter extends StatusReporter {
|
||||
override didChange() { }
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { Disposable } from 'vscode';
|
||||
import { CancellationToken } from 'vscode-languageserver-protocol';
|
||||
import { ResolveOnTimeoutResult, ResolveResult } from '../../../types/src';
|
||||
import { Deferred } from '../util/async';
|
||||
|
||||
/**
|
||||
* Converts an event to a Promise that resolves when the event is fired
|
||||
* @param subscribe A function that takes a listener and returns a Disposable for cleanup
|
||||
* @returns A Promise that resolves with the event data when the event fires
|
||||
*/
|
||||
export async function eventToPromise<T>(subscribe: (listener: (event: T) => void) => Disposable): Promise<T> {
|
||||
const deferred = new Deferred<T>();
|
||||
const disposable = subscribe((event: T) => {
|
||||
deferred.resolve(event);
|
||||
disposable.dispose();
|
||||
});
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a CancellationToken to a Promise that resolves when cancellation is requested
|
||||
* @param token The CancellationToken to observe
|
||||
* @returns A Promise that resolves when the token is canceled
|
||||
*/
|
||||
async function cancellationTokenToPromise(token: CancellationToken): Promise<void> {
|
||||
if (token.isCancellationRequested) { return; }
|
||||
const deferred = new Deferred<void>();
|
||||
const disposable = token.onCancellationRequested(() => {
|
||||
deferred.resolve();
|
||||
disposable.dispose();
|
||||
});
|
||||
await deferred.promise;
|
||||
}
|
||||
|
||||
async function raceCancellation(promise: Promise<void>, token?: CancellationToken): Promise<void> {
|
||||
if (token) {
|
||||
const cancellationPromise = cancellationTokenToPromise(token);
|
||||
await Promise.race([promise, cancellationPromise]);
|
||||
} else {
|
||||
await promise;
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for https://github.com/microsoft/TypeScript/issues/17002
|
||||
export function isArrayOfT<T>(value: ResolveOnTimeoutResult<T> | undefined): value is readonly T[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
type ResolvedItem<T> =
|
||||
| {
|
||||
status: 'full' | 'partial';
|
||||
resolutionTime: number;
|
||||
value: T[];
|
||||
}
|
||||
| {
|
||||
status: 'none';
|
||||
resolutionTime: number;
|
||||
value: null;
|
||||
}
|
||||
| {
|
||||
status: 'error';
|
||||
resolutionTime: number;
|
||||
reason: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves concurrently all given promises or async iterables, returning a map of their results.
|
||||
*
|
||||
* Given a collection of either promises resolving to single elements, arrays or async iterables,
|
||||
* this function will resolve them all to arrays and return a map of the results.
|
||||
* If a cancellation token is provided, when it is triggered, the function will stop resolving
|
||||
* and return the results collected so far, with the async iterables potentially returning partial results.
|
||||
*
|
||||
* @param resolvables A map of keys to promises or async iterables.
|
||||
* @param cancellation An optional cancellation promise.
|
||||
* @returns A promise that resolves to a map of the results.
|
||||
*/
|
||||
export async function resolveAll<K, T>(
|
||||
resolvables: Map<K, ResolveResult<T>>,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<Map<K, ResolvedItem<T>>> {
|
||||
const results: Map<K, ResolvedItem<T>> = new Map();
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [key, resolvable] of resolvables.entries()) {
|
||||
const promise = (async () => {
|
||||
const result = await resolve(resolvable, cancellationToken);
|
||||
results.set(key, result);
|
||||
})();
|
||||
promises.push(promise);
|
||||
}
|
||||
await Promise.allSettled(promises.values());
|
||||
return results;
|
||||
}
|
||||
|
||||
async function resolve<T>(
|
||||
resolvable: ResolveResult<T>,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<ResolvedItem<T>> {
|
||||
let result: ResolvedItem<T>;
|
||||
if (resolvable instanceof Promise) {
|
||||
result = await resolvePromise(resolvable, cancellationToken);
|
||||
} else {
|
||||
result = await resolveIterable(resolvable, cancellationToken);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Resolves a promise until cancelled, and possibly converts result to array
|
||||
*/
|
||||
async function resolvePromise<T>(
|
||||
promise: Promise<ResolveOnTimeoutResult<T>>,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<ResolvedItem<T>> {
|
||||
const startTime = performance.now();
|
||||
let resolved: ResolvedItem<T> = { status: 'none', resolutionTime: 0, value: null };
|
||||
const collectPromise = (async () => {
|
||||
try {
|
||||
const result = await promise;
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
resolved = { status: 'full', resolutionTime: 0, value: isArrayOfT<T>(result) ? [...result] : [result] };
|
||||
} catch (e) {
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
resolved = { status: 'error', resolutionTime: 0, reason: e };
|
||||
}
|
||||
})();
|
||||
await raceCancellation(collectPromise, cancellationToken);
|
||||
resolved.resolutionTime = performance.now() - startTime;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Resolves an async iterable until cancelled
|
||||
*/
|
||||
async function resolveIterable<T>(
|
||||
iterable: AsyncIterable<T>,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<ResolvedItem<T>> {
|
||||
const startTime = performance.now();
|
||||
let resolved: ResolvedItem<T> = { status: 'none', resolutionTime: 0, value: null };
|
||||
const collectPromise = (async () => {
|
||||
try {
|
||||
for await (const item of iterable) {
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
if (resolved.status !== 'partial') {
|
||||
resolved = { status: 'partial', resolutionTime: 0, value: [] };
|
||||
}
|
||||
resolved.value.push(item);
|
||||
}
|
||||
if (!cancellationToken?.isCancellationRequested) {
|
||||
if (resolved.status !== 'partial') {
|
||||
resolved = { status: 'full', resolutionTime: 0, value: [] };
|
||||
} else {
|
||||
resolved.status = 'full';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
resolved = { status: 'error', resolutionTime: 0, reason: e };
|
||||
}
|
||||
})();
|
||||
await raceCancellation(collectPromise, cancellationToken);
|
||||
resolved.resolutionTime = performance.now() - startTime;
|
||||
return resolved;
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IIgnoreService } from '../../../../../../../platform/ignore/common/ignoreService';
|
||||
import { URI } from '../../../../../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsTelemetryService } from '../../../../bridge/src/completionsTelemetryServiceBridge';
|
||||
import { ComponentStatistics, PromptMetadata } from '../../../../prompt/src/components/components';
|
||||
import { commentBlockAsSingles } from '../../../../prompt/src/languageMarker';
|
||||
import { PromptComponentAllocation, PromptComponentId } from '../../../../prompt/src/prompt';
|
||||
import { TokenizerName } from '../../../../prompt/src/tokenization';
|
||||
import { CancellationToken } from '../../../../types/src';
|
||||
import { CompletionState } from '../../completionState';
|
||||
import { ICompletionsFeaturesService } from '../../experiments/featuresService';
|
||||
import { ICompletionsLogTargetService, logger } from '../../logger';
|
||||
import { telemetryException, TelemetryWithExp } from '../../telemetry';
|
||||
import { TextDocumentContents } from '../../textDocument';
|
||||
import { ICompletionsContextProviderBridgeService } from '../components/contextProviderBridge';
|
||||
import {
|
||||
renderWithMetadata,
|
||||
type RenderedComponent,
|
||||
type ValidatedContextItems,
|
||||
type VirtualPromptComponent,
|
||||
} from '../components/virtualComponent';
|
||||
import {
|
||||
ContextProviderTelemetry,
|
||||
matchContextItems,
|
||||
ResolvedContextItem,
|
||||
telemetrizeContextItems,
|
||||
useContextProviderAPI,
|
||||
} from '../contextProviderRegistry';
|
||||
import { getCodeSnippetsFromContextItems } from '../contextProviders/codeSnippets';
|
||||
import { CodeSnippetWithId, TraitWithId } from '../contextProviders/contextItemSchemas';
|
||||
import { getTraitsFromContextItems, ReportTraitsTelemetry } from '../contextProviders/traits';
|
||||
import { componentStatisticsToPromptMatcher, ICompletionsContextProviderService } from '../contextProviderStatistics';
|
||||
import {
|
||||
_contextTooShort,
|
||||
_copilotContentExclusion,
|
||||
_promptCancelled,
|
||||
_promptError,
|
||||
getPromptOptions,
|
||||
MIN_PROMPT_CHARS,
|
||||
PromptResponse,
|
||||
trimLastLine,
|
||||
} from '../prompt';
|
||||
import {
|
||||
CompletionsPromptOptions,
|
||||
ICompletionsPromptFactoryService
|
||||
} from './completionsPromptFactory';
|
||||
|
||||
// If the space allocated to the suffix is at least this fraction of the estimated suffix cost,
|
||||
// we will render the suffix before the prefix and use any surplus suffix budget to fill the prefix.
|
||||
// Otherwise, we render the prefix first and use any surplus prefix budget to fill the suffix.
|
||||
const SMALL_SUFFIX_THRESHOLD = 0.8;
|
||||
|
||||
export abstract class CascadingPromptFactory implements ICompletionsPromptFactoryService {
|
||||
declare _serviceBrand: undefined;
|
||||
private renderId = 0;
|
||||
|
||||
constructor(
|
||||
protected components: Record<PromptComponentId, VirtualPromptComponent>,
|
||||
@IIgnoreService protected readonly ignoreService: IIgnoreService,
|
||||
@IInstantiationService protected readonly instantiationService: IInstantiationService,
|
||||
@ICompletionsFeaturesService protected readonly featuresService: ICompletionsFeaturesService,
|
||||
@ICompletionsTelemetryService protected readonly completionsTelemetryService: ICompletionsTelemetryService,
|
||||
@ICompletionsContextProviderBridgeService protected readonly contextProviderBridge: ICompletionsContextProviderBridgeService,
|
||||
@ICompletionsLogTargetService protected readonly logTargetService: ICompletionsLogTargetService,
|
||||
@ICompletionsContextProviderService protected readonly contextProviderStatistics: ICompletionsContextProviderService,
|
||||
) { }
|
||||
|
||||
async prompt(opts: CompletionsPromptOptions, cancellationToken?: CancellationToken): Promise<PromptResponse> {
|
||||
try {
|
||||
return await this.createPromptUnsafe(opts, cancellationToken);
|
||||
} catch (e) {
|
||||
return this.errorPrompt(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
getComponentAllocation(telemetryData: TelemetryWithExp): PromptComponentAllocation {
|
||||
const suffixPercent = this.featuresService.suffixPercent(telemetryData);
|
||||
const stableContextPercent = this.featuresService.stableContextPercent(telemetryData);
|
||||
const volatileContextPercent = this.featuresService.volatileContextPercent(telemetryData);
|
||||
|
||||
if (suffixPercent < 0 || suffixPercent > 100) {
|
||||
throw new Error(`suffixPercent must be between 0 and 100, but was ${suffixPercent}`);
|
||||
}
|
||||
|
||||
if (stableContextPercent < 0 || stableContextPercent > 100) {
|
||||
throw new Error(`stableContextPercent must be between 0 and 100, but was ${stableContextPercent}`);
|
||||
}
|
||||
|
||||
if (volatileContextPercent < 0 || volatileContextPercent > 100) {
|
||||
throw new Error(`volatileContextPercent must be between 0 and 100, but was ${volatileContextPercent}`);
|
||||
}
|
||||
|
||||
const prefixPercent = 100 - suffixPercent - stableContextPercent - volatileContextPercent;
|
||||
if (prefixPercent <= 1 || prefixPercent > 100) {
|
||||
throw new Error(`prefixPercent must be between 1 and 100, but was ${prefixPercent}`);
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: prefixPercent / 100,
|
||||
suffix: suffixPercent / 100,
|
||||
stableContext: stableContextPercent / 100,
|
||||
volatileContext: volatileContextPercent / 100,
|
||||
};
|
||||
}
|
||||
|
||||
private async createPromptUnsafe(
|
||||
opts: CompletionsPromptOptions,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<PromptResponse> {
|
||||
this.renderId++;
|
||||
const { completionId, completionState, telemetryData, promptOpts } = opts;
|
||||
const failFastPrompt = await this.failFastPrompt(completionState.textDocument, cancellationToken);
|
||||
if (failFastPrompt) {
|
||||
return failFastPrompt;
|
||||
}
|
||||
|
||||
const languageId = completionState.textDocument.detectedLanguageId;
|
||||
const start = performance.now();
|
||||
let contextItems;
|
||||
if (this.instantiationService.invokeFunction(useContextProviderAPI, languageId, telemetryData)) {
|
||||
contextItems = await this.resolveContext(completionId, completionState, telemetryData, cancellationToken);
|
||||
}
|
||||
const updateDataTimeMs = performance.now() - start;
|
||||
const renderedComponents: Partial<Record<PromptComponentId, RenderedComponent>> = {};
|
||||
const aggregatedMetadata: PromptMetadata = {
|
||||
renderId: this.renderId,
|
||||
rendererName: 'w',
|
||||
tokenizer: promptOpts?.tokenizer ?? TokenizerName.o200k,
|
||||
elisionTimeMs: 0,
|
||||
renderTimeMs: 0,
|
||||
updateDataTimeMs: updateDataTimeMs,
|
||||
componentStatistics: [],
|
||||
};
|
||||
|
||||
const { maxPromptLength } = this.instantiationService.invokeFunction(getPromptOptions, telemetryData, languageId);
|
||||
const allocation = this.getComponentAllocation(telemetryData);
|
||||
|
||||
const suffixAllocation = allocation.suffix * maxPromptLength;
|
||||
const estimatedMaxSuffixCost = this.components.suffix.estimatedCost?.(opts, contextItems);
|
||||
let cascadeOrder: PromptComponentId[] = ['stableContext', 'volatileContext', 'prefix', 'suffix'];
|
||||
if (suffixAllocation > SMALL_SUFFIX_THRESHOLD * (estimatedMaxSuffixCost ?? 0)) {
|
||||
cascadeOrder = ['stableContext', 'volatileContext', 'suffix', 'prefix'];
|
||||
}
|
||||
|
||||
let surplusBudget = 0;
|
||||
// Allocate excess budget in cascade order
|
||||
for (const id of cascadeOrder) {
|
||||
const componentBudget = surplusBudget + maxPromptLength * allocation[id];
|
||||
const rendered = renderWithMetadata(this.components[id], componentBudget, opts, contextItems);
|
||||
surplusBudget = componentBudget - rendered.cost;
|
||||
renderedComponents[id] = rendered;
|
||||
aggregateMetadata(aggregatedMetadata, rendered.metadata);
|
||||
}
|
||||
|
||||
const [prefix, trailingWs] = trimLastLine(renderedComponents.prefix!.text);
|
||||
|
||||
const end = performance.now();
|
||||
const contextProvidersTelemetry = this.instantiationService.invokeFunction(useContextProviderAPI, languageId, telemetryData)
|
||||
? this.telemetrizeContext(
|
||||
completionId,
|
||||
aggregatedMetadata.componentStatistics,
|
||||
contextItems?.resolvedContextItems ?? []
|
||||
)
|
||||
: [];
|
||||
|
||||
const context = [
|
||||
renderedComponents.stableContext!.text.trim(),
|
||||
renderedComponents.volatileContext!.text.trim(),
|
||||
];
|
||||
const prefixWithContext = promptOpts?.separateContext
|
||||
? prefix
|
||||
: // This should not happen, since we always separate context. If it does happen,
|
||||
// the token counts for the prefix will be wrong, since the workspace context
|
||||
// will have comment markers.
|
||||
commentBlockAsSingles(context.join('\n'), languageId) + '\n\n' + prefix;
|
||||
|
||||
return {
|
||||
type: 'prompt',
|
||||
prompt: {
|
||||
prefix: prefixWithContext,
|
||||
prefixTokens:
|
||||
renderedComponents.prefix!.cost +
|
||||
renderedComponents.stableContext!.cost +
|
||||
renderedComponents.volatileContext!.cost,
|
||||
suffix: renderedComponents.suffix!.text,
|
||||
suffixTokens: renderedComponents.suffix!.cost,
|
||||
context: promptOpts?.separateContext ? context : undefined,
|
||||
isFimEnabled: renderedComponents.suffix!.text.length > 0,
|
||||
},
|
||||
computeTimeMs: end - start,
|
||||
trailingWs,
|
||||
neighborSource: new Map(),
|
||||
metadata: aggregatedMetadata,
|
||||
contextProvidersTelemetry,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveContext(
|
||||
completionId: string,
|
||||
completionState: CompletionState,
|
||||
telemetryData: TelemetryWithExp,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<ValidatedContextItems & { resolvedContextItems: ResolvedContextItem[] }> {
|
||||
const resolvedContextItems: ResolvedContextItem[] = await this.contextProviderBridge.resolution(completionId);
|
||||
const { textDocument } = completionState;
|
||||
const matchedContextItems = resolvedContextItems.filter(matchContextItems);
|
||||
|
||||
const traits: TraitWithId[] = this.instantiationService.invokeFunction(getTraitsFromContextItems, completionId, matchedContextItems);
|
||||
void this.instantiationService.invokeFunction(ReportTraitsTelemetry,
|
||||
`contextProvider.traits`,
|
||||
traits,
|
||||
textDocument.detectedLanguageId,
|
||||
textDocument.detectedLanguageId, // TextDocumentContext does not have clientLanguageId
|
||||
telemetryData
|
||||
);
|
||||
|
||||
const codeSnippets: CodeSnippetWithId[] = await this.instantiationService.invokeFunction(getCodeSnippetsFromContextItems,
|
||||
completionId,
|
||||
matchedContextItems,
|
||||
textDocument.detectedLanguageId
|
||||
);
|
||||
return { traits, codeSnippets, resolvedContextItems };
|
||||
}
|
||||
|
||||
private telemetrizeContext(
|
||||
completionId: string,
|
||||
componentStatistics: ComponentStatistics[],
|
||||
resolvedContextItems: ResolvedContextItem[]
|
||||
): ContextProviderTelemetry[] {
|
||||
const promptMatcher = componentStatisticsToPromptMatcher(componentStatistics);
|
||||
this.contextProviderStatistics.getStatisticsForCompletion(completionId).computeMatch(promptMatcher);
|
||||
const contextProvidersTelemetry = telemetrizeContextItems(this.contextProviderStatistics, completionId, resolvedContextItems);
|
||||
// To support generating context provider metrics of completion in COffE.
|
||||
logger.debug(this.logTargetService, `Context providers telemetry: '${JSON.stringify(contextProvidersTelemetry)}'`);
|
||||
return contextProvidersTelemetry;
|
||||
}
|
||||
|
||||
private async failFastPrompt(textDocument: TextDocumentContents, cancellationToken: CancellationToken | undefined) {
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return _promptCancelled;
|
||||
}
|
||||
if (await this.ignoreService.isCopilotIgnored(URI.parse(textDocument.uri))) {
|
||||
return _copilotContentExclusion;
|
||||
}
|
||||
|
||||
if (textDocument.getText().length < MIN_PROMPT_CHARS) {
|
||||
// Too short context
|
||||
return _contextTooShort;
|
||||
}
|
||||
}
|
||||
|
||||
private errorPrompt(error: Error): PromptResponse {
|
||||
telemetryException(this.completionsTelemetryService, error, 'WorkspaceContextPromptFactory');
|
||||
return _promptError;
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateMetadata(aggregated: PromptMetadata, metadata: PromptMetadata): void {
|
||||
aggregated.elisionTimeMs += metadata.elisionTimeMs;
|
||||
aggregated.renderTimeMs += metadata.renderTimeMs;
|
||||
aggregated.updateDataTimeMs += metadata.updateDataTimeMs;
|
||||
aggregated.componentStatistics.push(...metadata.componentStatistics);
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CancellationToken, CancellationTokenSource } from 'vscode-languageserver-protocol';
|
||||
import { IInstantiationService } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { VirtualPrompt } from '../../../../prompt/src/components/virtualPrompt';
|
||||
import { TokenizerName } from '../../../../prompt/src/tokenization';
|
||||
import { CompletionState } from '../../completionState';
|
||||
import { TelemetryWithExp } from '../../telemetry';
|
||||
import { _promptCancelled, _promptError, _promptTimeout, PromptResponse } from '../prompt';
|
||||
import {
|
||||
PromptOrdering,
|
||||
TestComponentsCompletionsPromptFactory
|
||||
} from './componentsCompletionsPromptFactory';
|
||||
import { createServiceIdentifier } from '../../../../../../../util/common/services';
|
||||
|
||||
export interface PromptOpts {
|
||||
data?: unknown;
|
||||
separateContext?: boolean;
|
||||
tokenizer?: TokenizerName;
|
||||
}
|
||||
|
||||
export interface CompletionsPromptOptions {
|
||||
completionId: string;
|
||||
completionState: CompletionState;
|
||||
telemetryData: TelemetryWithExp;
|
||||
promptOpts?: PromptOpts;
|
||||
}
|
||||
|
||||
export interface IPromptFactory {
|
||||
prompt(
|
||||
opts: CompletionsPromptOptions,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<PromptResponse>;
|
||||
}
|
||||
|
||||
export const ICompletionsPromptFactoryService = createServiceIdentifier<ICompletionsPromptFactoryService>('ICompletionsPromptFactoryService');
|
||||
export interface ICompletionsPromptFactoryService extends IPromptFactory {
|
||||
readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
// This class needs to extend CompletionsPromptFactory since it's set on the context.
|
||||
class SequentialCompletionsPromptFactory implements IPromptFactory {
|
||||
declare _serviceBrand: undefined;
|
||||
private lastPromise?: Promise<PromptResponse>;
|
||||
|
||||
constructor(private readonly delegate: IPromptFactory) { }
|
||||
|
||||
async prompt(opts: CompletionsPromptOptions, cancellationToken?: CancellationToken): Promise<PromptResponse> {
|
||||
this.lastPromise = this.promptAsync(opts, cancellationToken);
|
||||
return this.lastPromise;
|
||||
}
|
||||
|
||||
private async promptAsync(
|
||||
opts: CompletionsPromptOptions,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<PromptResponse> {
|
||||
// Wait for previous request to complete
|
||||
await this.lastPromise;
|
||||
|
||||
// Check if request was cancelled while waiting
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return _promptCancelled;
|
||||
}
|
||||
|
||||
// Return prompt from delegate catching any errors
|
||||
try {
|
||||
return await this.delegate.prompt(opts, cancellationToken);
|
||||
} catch {
|
||||
return _promptError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0.01% of prompt construction time is 1s+. Setting this to 1200ms should be safe.
|
||||
export const DEFAULT_PROMPT_TIMEOUT = 1200;
|
||||
class TimeoutHandlingCompletionsPromptFactory implements IPromptFactory {
|
||||
constructor(private readonly delegate: IPromptFactory) { }
|
||||
|
||||
async prompt(opts: CompletionsPromptOptions, cancellationToken?: CancellationToken): Promise<PromptResponse> {
|
||||
const timeoutTokenSource = new CancellationTokenSource();
|
||||
const timeoutToken = timeoutTokenSource.token;
|
||||
cancellationToken?.onCancellationRequested(() => {
|
||||
timeoutTokenSource.cancel();
|
||||
});
|
||||
|
||||
return await Promise.race([
|
||||
this.delegate.prompt(opts, timeoutToken),
|
||||
new Promise<PromptResponse>(resolve => {
|
||||
setTimeout(() => {
|
||||
// Cancel the token when timeout occurs
|
||||
timeoutTokenSource.cancel();
|
||||
resolve(_promptTimeout);
|
||||
}, DEFAULT_PROMPT_TIMEOUT);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class BaseComponentsCompletionsPromptFactory implements IPromptFactory {
|
||||
declare _serviceBrand: undefined;
|
||||
|
||||
private readonly delegate: IPromptFactory;
|
||||
|
||||
constructor(
|
||||
virtualPrompt: VirtualPrompt | undefined,
|
||||
ordering: PromptOrdering | undefined,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
) {
|
||||
this.delegate = new SequentialCompletionsPromptFactory(
|
||||
new TimeoutHandlingCompletionsPromptFactory(
|
||||
instantiationService.createInstance(TestComponentsCompletionsPromptFactory, virtualPrompt, ordering)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
prompt(opts: CompletionsPromptOptions, cancellationToken?: CancellationToken): Promise<PromptResponse> {
|
||||
return this.delegate.prompt(opts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
export class CompletionsPromptFactory extends BaseComponentsCompletionsPromptFactory {
|
||||
constructor(
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
) {
|
||||
super(undefined, undefined, instantiationService);
|
||||
}
|
||||
}
|
||||
|
||||
export class TestCompletionsPromptFactory extends BaseComponentsCompletionsPromptFactory { }
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
import { ICompletionsLogTargetService, logger } from '../../logger';
|
||||
|
||||
import { IIgnoreService } from '../../../../../../../platform/ignore/common/ignoreService';
|
||||
import { URI } from '../../../../../../../util/vs/base/common/uri';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsTelemetryService } from '../../../../bridge/src/completionsTelemetryServiceBridge';
|
||||
import { DataPipe, VirtualPrompt } from '../../../../prompt/src/components/virtualPrompt';
|
||||
import { TokenizerName } from '../../../../prompt/src/tokenization';
|
||||
import { CancellationToken, Position } from '../../../../types/src';
|
||||
import { CompletionState } from '../../completionState';
|
||||
import { telemetryException, TelemetryWithExp } from '../../telemetry';
|
||||
import { TextDocumentContents } from '../../textDocument';
|
||||
import { ICompletionsTextDocumentManagerService } from '../../textDocumentManager';
|
||||
import { CodeSnippets } from '../components/codeSnippets';
|
||||
import { CompletionsContext } from '../components/completionsContext';
|
||||
import { CompletionsPromptOk, CompletionsPromptRenderer } from '../components/completionsPromptRenderer';
|
||||
import { ICompletionsContextProviderBridgeService } from '../components/contextProviderBridge';
|
||||
import { CurrentFile } from '../components/currentFile';
|
||||
import { DocumentMarker } from '../components/marker';
|
||||
import { RecentEdits } from '../components/recentEdits';
|
||||
import { SimilarFiles } from '../components/similarFiles';
|
||||
import { splitContextCompletionsPrompt } from '../components/splitContextPrompt';
|
||||
import { SplitContextPromptRenderer } from '../components/splitContextPromptRenderer';
|
||||
import { Traits } from '../components/traits';
|
||||
|
||||
import { Diagnostics } from '../components/diagnostics';
|
||||
import {
|
||||
ContextProviderTelemetry,
|
||||
matchContextItems,
|
||||
ResolvedContextItem,
|
||||
telemetrizeContextItems,
|
||||
useContextProviderAPI,
|
||||
} from '../contextProviderRegistry';
|
||||
import { getCodeSnippetsFromContextItems } from '../contextProviders/codeSnippets';
|
||||
import {
|
||||
CodeSnippetWithId,
|
||||
SupportedContextItemWithId,
|
||||
TraitWithId,
|
||||
type DiagnosticBagWithId,
|
||||
} from '../contextProviders/contextItemSchemas';
|
||||
import { getDiagnosticsFromContextItems } from '../contextProviders/diagnostics';
|
||||
import { getTraitsFromContextItems, ReportTraitsTelemetry } from '../contextProviders/traits';
|
||||
import { componentStatisticsToPromptMatcher, ICompletionsContextProviderService } from '../contextProviderStatistics';
|
||||
import {
|
||||
_contextTooShort,
|
||||
_copilotContentExclusion,
|
||||
_promptCancelled,
|
||||
_promptError,
|
||||
getPromptOptions,
|
||||
MIN_PROMPT_CHARS,
|
||||
PromptResponse,
|
||||
trimLastLine,
|
||||
} from '../prompt';
|
||||
import { ICompletionsRecentEditsProviderService } from '../recentEdits/recentEditsProvider';
|
||||
import { isIncludeNeighborFilesActive } from '../similarFiles/neighborFiles';
|
||||
import {
|
||||
CompletionsPromptOptions, IPromptFactory,
|
||||
PromptOpts
|
||||
} from './completionsPromptFactory';
|
||||
|
||||
export type CompletionRequestDocument = TextDocumentContents;
|
||||
|
||||
export type CompletionRequestData = {
|
||||
document: CompletionRequestDocument;
|
||||
position: Position;
|
||||
telemetryData: TelemetryWithExp;
|
||||
cancellationToken?: CancellationToken;
|
||||
// see inlineCompletions data param
|
||||
data?: unknown;
|
||||
// Context provider items
|
||||
traits?: TraitWithId[];
|
||||
codeSnippets?: CodeSnippetWithId[];
|
||||
diagnostics?: DiagnosticBagWithId[];
|
||||
turnOffSimilarFiles?: boolean;
|
||||
suffixMatchThreshold?: number;
|
||||
maxPromptTokens: number;
|
||||
tokenizer?: TokenizerName;
|
||||
};
|
||||
|
||||
export function isCompletionRequestData(data: unknown): data is CompletionRequestData {
|
||||
if (!data || typeof data !== 'object') { return false; }
|
||||
|
||||
const req = data as Partial<CompletionRequestData>;
|
||||
|
||||
// Check document
|
||||
if (!req.document) { return false; }
|
||||
|
||||
// Check position
|
||||
if (!req.position) { return false; }
|
||||
if (req.position.line === undefined) { return false; }
|
||||
if (req.position.character === undefined) { return false; }
|
||||
|
||||
// Check telemetryData
|
||||
if (!req.telemetryData) { return false; }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export enum PromptOrdering {
|
||||
Default = 'default',
|
||||
SplitContext = 'splitContext',
|
||||
}
|
||||
|
||||
type DeclarativePromptFunction = typeof defaultCompletionsPrompt;
|
||||
type AvailableDeclarativePrompts = {
|
||||
[K in PromptOrdering]: {
|
||||
promptFunction: DeclarativePromptFunction;
|
||||
renderer: typeof CompletionsPromptRenderer;
|
||||
};
|
||||
};
|
||||
|
||||
const availableDeclarativePrompts: AvailableDeclarativePrompts = {
|
||||
[PromptOrdering.Default]: {
|
||||
promptFunction: defaultCompletionsPrompt,
|
||||
renderer: CompletionsPromptRenderer,
|
||||
},
|
||||
[PromptOrdering.SplitContext]: {
|
||||
promptFunction: splitContextCompletionsPrompt,
|
||||
renderer: SplitContextPromptRenderer,
|
||||
},
|
||||
};
|
||||
|
||||
// The weights mimic the PromptPriorityList from prompt/src/wishlist.ts
|
||||
function defaultCompletionsPrompt(accessor: ServicesAccessor) {
|
||||
const tdms = accessor.get(ICompletionsTextDocumentManagerService);
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const recentEditsProvider = accessor.get(ICompletionsRecentEditsProviderService);
|
||||
return (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<DocumentMarker tdms={tdms} weight={0.7} />
|
||||
<Traits weight={0.6} />
|
||||
<Diagnostics tdms={tdms} weight={0.65} />
|
||||
<CodeSnippets tdms={tdms} weight={0.9} />
|
||||
<SimilarFiles tdms={tdms} instantiationService={instantiationService} weight={0.8} />
|
||||
<RecentEdits tdms={tdms} recentEditsProvider={recentEditsProvider} weight={0.99} />
|
||||
</CompletionsContext>
|
||||
<CurrentFile weight={1} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
abstract class BaseComponentsCompletionsPromptFactory implements IPromptFactory {
|
||||
declare _serviceBrand: undefined;
|
||||
private virtualPrompt: VirtualPrompt;
|
||||
private pipe: DataPipe;
|
||||
private renderer: CompletionsPromptRenderer;
|
||||
private promptOrdering: PromptOrdering;
|
||||
|
||||
constructor(
|
||||
virtualPrompt: VirtualPrompt | undefined,
|
||||
ordering: PromptOrdering | undefined,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ICompletionsTelemetryService private readonly completionsTelemetryService: ICompletionsTelemetryService,
|
||||
@IIgnoreService private readonly ignoreService: IIgnoreService,
|
||||
@ICompletionsContextProviderBridgeService private readonly contextProviderBridge: ICompletionsContextProviderBridgeService,
|
||||
@ICompletionsLogTargetService private readonly logTarget: ICompletionsLogTargetService,
|
||||
@ICompletionsContextProviderService private readonly contextProviderStatistics: ICompletionsContextProviderService,
|
||||
) {
|
||||
this.promptOrdering = ordering ?? PromptOrdering.Default;
|
||||
this.virtualPrompt = virtualPrompt ?? new VirtualPrompt(this.completionsPrompt());
|
||||
this.pipe = this.virtualPrompt.createPipe();
|
||||
this.renderer = this.getRenderer();
|
||||
}
|
||||
|
||||
async prompt(opts: CompletionsPromptOptions, cancellationToken?: CancellationToken): Promise<PromptResponse> {
|
||||
try {
|
||||
return await this.createPromptUnsafe(opts, cancellationToken);
|
||||
} catch (e) {
|
||||
return this.errorPrompt(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async createPromptUnsafe(
|
||||
{ completionId, completionState, telemetryData, promptOpts }: CompletionsPromptOptions,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<PromptResponse> {
|
||||
const { maxPromptLength, suffixPercent, suffixMatchThreshold } = this.instantiationService.invokeFunction(getPromptOptions,
|
||||
telemetryData,
|
||||
completionState.textDocument.detectedLanguageId
|
||||
);
|
||||
|
||||
const failFastPrompt = await this.failFastPrompt(
|
||||
completionState.textDocument,
|
||||
completionState.position,
|
||||
suffixPercent,
|
||||
cancellationToken
|
||||
);
|
||||
if (failFastPrompt) {
|
||||
return failFastPrompt;
|
||||
}
|
||||
|
||||
// TODO: Prompt ordering changes are triggered by ExP changes.
|
||||
// TODO@benibenj remove this as its always true (except in tests)
|
||||
const promptOrdering = promptOpts?.separateContext ? PromptOrdering.SplitContext : PromptOrdering.Default;
|
||||
this.setPromptOrdering(promptOrdering);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const { traits, codeSnippets, diagnostics, turnOffSimilarFiles, resolvedContextItems } = await this.resolveContext(
|
||||
completionId,
|
||||
completionState,
|
||||
telemetryData,
|
||||
cancellationToken,
|
||||
promptOpts
|
||||
);
|
||||
|
||||
await this.updateComponentData(
|
||||
completionState.textDocument,
|
||||
completionState.position,
|
||||
traits,
|
||||
codeSnippets,
|
||||
diagnostics,
|
||||
telemetryData,
|
||||
turnOffSimilarFiles,
|
||||
maxPromptLength,
|
||||
cancellationToken,
|
||||
promptOpts,
|
||||
suffixMatchThreshold,
|
||||
promptOpts?.tokenizer
|
||||
);
|
||||
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return _promptCancelled;
|
||||
}
|
||||
|
||||
const snapshot = this.virtualPrompt.snapshot(cancellationToken);
|
||||
const snapshotStatus = snapshot.status;
|
||||
if (snapshotStatus === 'cancelled') {
|
||||
return _promptCancelled;
|
||||
} else if (snapshotStatus === 'error') {
|
||||
return this.errorPrompt(snapshot.error);
|
||||
}
|
||||
|
||||
const rendered = this.renderer.render(
|
||||
snapshot.snapshot!,
|
||||
{
|
||||
delimiter: '\n',
|
||||
tokenizer: promptOpts?.tokenizer,
|
||||
promptTokenLimit: maxPromptLength,
|
||||
suffixPercent: suffixPercent,
|
||||
languageId: completionState.textDocument.detectedLanguageId,
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
if (rendered.status === 'cancelled') {
|
||||
return _promptCancelled;
|
||||
} else if (rendered.status === 'error') {
|
||||
return this.errorPrompt(rendered.error);
|
||||
}
|
||||
|
||||
const [prefix, trailingWs] = trimLastLine(rendered.prefix);
|
||||
const renderedTrimmed = { ...rendered, prefix };
|
||||
|
||||
let contextProvidersTelemetry: ContextProviderTelemetry[] | undefined = undefined;
|
||||
const languageId = completionState.textDocument.detectedLanguageId;
|
||||
if (this.instantiationService.invokeFunction(useContextProviderAPI, languageId, telemetryData)) {
|
||||
const promptMatcher = componentStatisticsToPromptMatcher(rendered.metadata.componentStatistics);
|
||||
this.contextProviderStatistics
|
||||
.getStatisticsForCompletion(completionId)
|
||||
.computeMatch(promptMatcher);
|
||||
contextProvidersTelemetry = telemetrizeContextItems(this.contextProviderStatistics, completionId, resolvedContextItems);
|
||||
// To support generating context provider metrics of completion in COffE.
|
||||
logger.debug(this.logTarget, `Context providers telemetry: '${JSON.stringify(contextProvidersTelemetry)}'`);
|
||||
}
|
||||
const end = performance.now();
|
||||
this.resetIfEmpty(rendered);
|
||||
return this.successPrompt(renderedTrimmed, end, start, trailingWs, contextProvidersTelemetry);
|
||||
}
|
||||
|
||||
private async updateComponentData(
|
||||
textDocument: CompletionRequestDocument,
|
||||
position: Position,
|
||||
traits: TraitWithId[] | undefined,
|
||||
codeSnippets: CodeSnippetWithId[] | undefined,
|
||||
diagnostics: DiagnosticBagWithId[] | undefined,
|
||||
telemetryData: TelemetryWithExp,
|
||||
turnOffSimilarFiles: boolean,
|
||||
maxPromptLength: number,
|
||||
cancellationToken?: CancellationToken,
|
||||
opts: PromptOpts = {},
|
||||
suffixMatchThreshold?: number,
|
||||
tokenizer?: TokenizerName
|
||||
) {
|
||||
const completionRequestData = this.createRequestData(
|
||||
textDocument,
|
||||
position,
|
||||
telemetryData,
|
||||
cancellationToken,
|
||||
opts,
|
||||
maxPromptLength,
|
||||
traits,
|
||||
codeSnippets,
|
||||
diagnostics,
|
||||
turnOffSimilarFiles,
|
||||
suffixMatchThreshold,
|
||||
tokenizer
|
||||
);
|
||||
await this.pipe.pump(completionRequestData);
|
||||
}
|
||||
|
||||
private async resolveContext(
|
||||
completionId: string,
|
||||
completionState: CompletionState,
|
||||
telemetryData: TelemetryWithExp,
|
||||
cancellationToken?: CancellationToken,
|
||||
opts: PromptOpts = {}
|
||||
): Promise<{
|
||||
traits: TraitWithId[] | undefined;
|
||||
codeSnippets: CodeSnippetWithId[] | undefined;
|
||||
diagnostics: DiagnosticBagWithId[] | undefined;
|
||||
turnOffSimilarFiles: boolean;
|
||||
resolvedContextItems: ResolvedContextItem[];
|
||||
}> {
|
||||
let resolvedContextItems: ResolvedContextItem[] = [];
|
||||
let traits: TraitWithId[] | undefined;
|
||||
let codeSnippets: CodeSnippetWithId[] | undefined;
|
||||
let diagnostics: DiagnosticBagWithId[] | undefined;
|
||||
let turnOffSimilarFiles = false;
|
||||
if (this.instantiationService.invokeFunction(useContextProviderAPI, completionState.textDocument.detectedLanguageId, telemetryData)) {
|
||||
resolvedContextItems = await this.contextProviderBridge.resolution(completionId);
|
||||
const { textDocument } = completionState;
|
||||
// Turn off neighboring files if:
|
||||
// - it's not explicitly enabled via EXP flag
|
||||
// - there are matched context providers
|
||||
const matchedContextItems = resolvedContextItems.filter(matchContextItems);
|
||||
if (!this.instantiationService.invokeFunction(similarFilesEnabled, textDocument.detectedLanguageId, matchedContextItems, telemetryData)) {
|
||||
turnOffSimilarFiles = true;
|
||||
}
|
||||
|
||||
traits = await this.instantiationService.invokeFunction(getTraitsFromContextItems, completionId, matchedContextItems);
|
||||
void this.instantiationService.invokeFunction(ReportTraitsTelemetry,
|
||||
`contextProvider.traits`,
|
||||
traits,
|
||||
textDocument.detectedLanguageId,
|
||||
textDocument.detectedLanguageId, // TextDocumentContext does not have clientLanguageId
|
||||
telemetryData
|
||||
);
|
||||
|
||||
codeSnippets = await this.instantiationService.invokeFunction(getCodeSnippetsFromContextItems,
|
||||
completionId,
|
||||
matchedContextItems,
|
||||
textDocument.detectedLanguageId
|
||||
);
|
||||
|
||||
diagnostics = await this.instantiationService.invokeFunction(getDiagnosticsFromContextItems,
|
||||
completionId,
|
||||
matchedContextItems
|
||||
);
|
||||
}
|
||||
return { traits, codeSnippets, diagnostics, turnOffSimilarFiles, resolvedContextItems };
|
||||
}
|
||||
|
||||
private async failFastPrompt(
|
||||
textDocument: TextDocumentContents,
|
||||
position: Position,
|
||||
suffixPercent: number,
|
||||
cancellationToken: CancellationToken | undefined
|
||||
) {
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return _promptCancelled;
|
||||
}
|
||||
if (await this.ignoreService.isCopilotIgnored(URI.parse(textDocument.uri))) {
|
||||
return _copilotContentExclusion;
|
||||
}
|
||||
|
||||
const eligibleChars = suffixPercent > 0 ? textDocument.getText().length : textDocument.offsetAt(position);
|
||||
if (eligibleChars < MIN_PROMPT_CHARS) {
|
||||
// Too short context
|
||||
return _contextTooShort;
|
||||
}
|
||||
}
|
||||
|
||||
private createRequestData(
|
||||
textDocument: CompletionRequestDocument,
|
||||
position: Position,
|
||||
telemetryData: TelemetryWithExp,
|
||||
cancellationToken: CancellationToken | undefined,
|
||||
opts: PromptOpts,
|
||||
maxPromptLength: number,
|
||||
traits?: TraitWithId[],
|
||||
codeSnippets?: CodeSnippetWithId[],
|
||||
diagnostics?: DiagnosticBagWithId[],
|
||||
turnOffSimilarFiles?: boolean,
|
||||
suffixMatchThreshold?: number,
|
||||
tokenizer?: TokenizerName
|
||||
): CompletionRequestData {
|
||||
return {
|
||||
document: textDocument,
|
||||
position,
|
||||
telemetryData,
|
||||
cancellationToken,
|
||||
data: opts.data,
|
||||
traits,
|
||||
codeSnippets,
|
||||
diagnostics,
|
||||
turnOffSimilarFiles,
|
||||
suffixMatchThreshold,
|
||||
maxPromptTokens: maxPromptLength,
|
||||
tokenizer,
|
||||
};
|
||||
}
|
||||
|
||||
private resetIfEmpty(rendered: CompletionsPromptOk) {
|
||||
if (rendered.prefix.length === 0 && rendered.suffix.length === 0) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
private successPrompt(
|
||||
rendered: CompletionsPromptOk,
|
||||
end: number,
|
||||
start: number,
|
||||
trailingWs: string,
|
||||
contextProvidersTelemetry?: ContextProviderTelemetry[]
|
||||
): PromptResponse {
|
||||
return {
|
||||
type: 'prompt',
|
||||
prompt: {
|
||||
prefix: rendered.prefix,
|
||||
prefixTokens: rendered.prefixTokens,
|
||||
suffix: rendered.suffix,
|
||||
suffixTokens: rendered.suffixTokens,
|
||||
context: rendered.context,
|
||||
isFimEnabled: rendered.suffix.length > 0,
|
||||
},
|
||||
computeTimeMs: end - start,
|
||||
trailingWs,
|
||||
neighborSource: new Map(),
|
||||
metadata: rendered.metadata,
|
||||
contextProvidersTelemetry,
|
||||
};
|
||||
}
|
||||
|
||||
private errorPrompt(error: Error): PromptResponse {
|
||||
telemetryException(this.completionsTelemetryService, error, 'PromptComponents.CompletionsPromptFactory');
|
||||
this.reset();
|
||||
return _promptError;
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.renderer = this.getRenderer();
|
||||
this.virtualPrompt = new VirtualPrompt(this.completionsPrompt());
|
||||
this.pipe = this.virtualPrompt.createPipe();
|
||||
}
|
||||
|
||||
private setPromptOrdering(ordering: PromptOrdering) {
|
||||
if (this.promptOrdering !== ordering) {
|
||||
this.promptOrdering = ordering;
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
private completionsPrompt() {
|
||||
const promptFunction =
|
||||
availableDeclarativePrompts[this.promptOrdering]?.promptFunction ?? defaultCompletionsPrompt;
|
||||
return this.instantiationService.invokeFunction(promptFunction);
|
||||
}
|
||||
|
||||
private getRenderer() {
|
||||
const promptInfo =
|
||||
availableDeclarativePrompts[this.promptOrdering] ?? availableDeclarativePrompts[PromptOrdering.Default];
|
||||
return new promptInfo.renderer();
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentsCompletionsPromptFactory extends BaseComponentsCompletionsPromptFactory {
|
||||
constructor(
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@ICompletionsTelemetryService completionsTelemetryService: ICompletionsTelemetryService,
|
||||
@IIgnoreService ignoreService: IIgnoreService,
|
||||
@ICompletionsContextProviderBridgeService contextProviderBridge: ICompletionsContextProviderBridgeService,
|
||||
@ICompletionsLogTargetService logTarget: ICompletionsLogTargetService,
|
||||
@ICompletionsContextProviderService contextProviderStatistics: ICompletionsContextProviderService,
|
||||
) {
|
||||
super(
|
||||
undefined,
|
||||
undefined,
|
||||
instantiationService,
|
||||
completionsTelemetryService,
|
||||
ignoreService,
|
||||
contextProviderBridge,
|
||||
logTarget,
|
||||
contextProviderStatistics
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class TestComponentsCompletionsPromptFactory extends BaseComponentsCompletionsPromptFactory { }
|
||||
|
||||
// Similar files is enabled if:
|
||||
// - the languageId is C/C++.
|
||||
// - it's explicitly enabled via EXP flag or config.
|
||||
// - no code snippets are provided (which includes the case when all providers error).
|
||||
function similarFilesEnabled(
|
||||
accessor: ServicesAccessor,
|
||||
detectedLanguageId: string,
|
||||
matchedContextItems: ResolvedContextItem<SupportedContextItemWithId>[],
|
||||
telemetryData: TelemetryWithExp
|
||||
) {
|
||||
const cppLanguageIds = ['cpp', 'c'];
|
||||
const includeNeighboringFiles =
|
||||
isIncludeNeighborFilesActive(accessor, detectedLanguageId, telemetryData) || cppLanguageIds.includes(detectedLanguageId);
|
||||
return (
|
||||
includeNeighboringFiles || !matchedContextItems.some(ci => ci.data.some(item => item.type === 'CodeSnippet'))
|
||||
);
|
||||
}
|
||||
+935
@@ -0,0 +1,935 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as sinon from 'sinon';
|
||||
import dedent from 'ts-dedent';
|
||||
import { Diagnostic, DiagnosticSeverity, Range, Uri } from 'vscode';
|
||||
import { CancellationTokenSource, Position } from 'vscode-languageserver-protocol';
|
||||
import { MutableObservableWorkspace } from '../../../../../../../../platform/inlineEdits/common/observableWorkspace';
|
||||
import { TestingServiceCollection } from '../../../../../../../../platform/test/node/services';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ComponentContext, PromptElementProps, Text } from '../../../../../prompt/src/components/components';
|
||||
import { Dispatch, StateUpdater } from '../../../../../prompt/src/components/hooks';
|
||||
import { VirtualPrompt } from '../../../../../prompt/src/components/virtualPrompt';
|
||||
import { DEFAULT_MAX_COMPLETION_LENGTH } from '../../../../../prompt/src/prompt';
|
||||
import { getTokenizer, TokenizerName } from '../../../../../prompt/src/tokenization';
|
||||
import { CodeSnippet, ContextProvider, SupportedContextItem, Trait, type DiagnosticBag } from '../../../../../types/src';
|
||||
import { ICompletionsObservableWorkspace } from '../../../completionsObservableWorkspace';
|
||||
import { createCompletionState } from '../../../completionState';
|
||||
import { ConfigKey, ICompletionsConfigProvider, InMemoryConfigProvider } from '../../../config';
|
||||
import { ICompletionsFeaturesService } from '../../../experiments/featuresService';
|
||||
import { TelemetryWithExp } from '../../../telemetry';
|
||||
import { createLibTestingContext } from '../../../test/context';
|
||||
import { withInMemoryTelemetry } from '../../../test/telemetry';
|
||||
import { createTextDocument, TestTextDocumentManager } from '../../../test/textDocument';
|
||||
import { ITextDocument } from '../../../textDocument';
|
||||
import { ICompletionsTextDocumentManagerService } from '../../../textDocumentManager';
|
||||
import { CompletionsContext } from '../../components/completionsContext';
|
||||
import { ICompletionsContextProviderBridgeService } from '../../components/contextProviderBridge';
|
||||
import { CurrentFile } from '../../components/currentFile';
|
||||
import { ContextProviderTelemetry, ICompletionsContextProviderRegistryService } from '../../contextProviderRegistry';
|
||||
import { _contextTooShort, _promptCancelled, _promptError } from '../../prompt';
|
||||
import { FullRecentEditsProvider, ICompletionsRecentEditsProviderService } from '../../recentEdits/recentEditsProvider';
|
||||
import { NeighborSource } from '../../similarFiles/neighborFiles';
|
||||
import {
|
||||
DEFAULT_PROMPT_TIMEOUT, IPromptFactory,
|
||||
TestCompletionsPromptFactory
|
||||
} from '../completionsPromptFactory';
|
||||
import {
|
||||
isCompletionRequestData,
|
||||
PromptOrdering,
|
||||
TestComponentsCompletionsPromptFactory
|
||||
} from '../componentsCompletionsPromptFactory';
|
||||
|
||||
suite('Completions Prompt Factory', function () {
|
||||
let telemetryData: TelemetryWithExp;
|
||||
let accessor: ServicesAccessor;
|
||||
let serviceCollection: TestingServiceCollection;
|
||||
let clock: sinon.SinonFakeTimers | undefined;
|
||||
let cts: CancellationTokenSource;
|
||||
const longPrefix = Array.from({ length: 60 }, (_, i) => `const a${i} = ${i};`).join('\n');
|
||||
const defaultTextDocument = createTextDocument(
|
||||
'file:///path/basename',
|
||||
'typescript',
|
||||
0,
|
||||
dedent`
|
||||
${longPrefix}
|
||||
function f|
|
||||
const b = 2;
|
||||
`
|
||||
);
|
||||
let promptFactory: IPromptFactory;
|
||||
|
||||
function invokePromptFactory(
|
||||
opts: {
|
||||
completionId?: string;
|
||||
textDocument?: ITextDocument;
|
||||
position?: Position;
|
||||
separateContext?: boolean;
|
||||
} = {},
|
||||
factory: IPromptFactory = promptFactory,
|
||||
) {
|
||||
const textDocument = opts.textDocument ?? defaultTextDocument;
|
||||
const position = opts.position ?? textDocument.positionAt(textDocument.getText().indexOf('|'));
|
||||
const completionState = createCompletionState(textDocument, position);
|
||||
const separateContext = opts.separateContext ?? false;
|
||||
const completionId = opts.completionId ?? 'completion_id';
|
||||
const contextProviderBridge = accessor.get(ICompletionsContextProviderBridgeService);
|
||||
contextProviderBridge.schedule(completionState, completionId, 'opId', telemetryData);
|
||||
return factory.prompt(
|
||||
{ completionId, completionState, telemetryData, promptOpts: { separateContext } },
|
||||
cts.token
|
||||
);
|
||||
}
|
||||
|
||||
setup(function () {
|
||||
serviceCollection = createLibTestingContext();
|
||||
accessor = serviceCollection.createTestingAccessor();
|
||||
telemetryData = TelemetryWithExp.createEmptyConfigForTesting();
|
||||
cts = new CancellationTokenSource();
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, undefined, undefined);
|
||||
});
|
||||
|
||||
teardown(function () {
|
||||
clock?.restore();
|
||||
sinon.restore();
|
||||
NeighborSource.reset();
|
||||
});
|
||||
|
||||
test('prompt should include document marker', async function () {
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(result.prompt.prefix, `// Path: basename\n${longPrefix}\nfunction f`);
|
||||
assert.deepStrictEqual(result.prompt.prefixTokens, 427);
|
||||
assert.deepStrictEqual(result.prompt.suffix, 'const b = 2;');
|
||||
assert.deepStrictEqual(result.prompt.suffixTokens, 6);
|
||||
});
|
||||
|
||||
test('prompt should include neighboring files', async function () {
|
||||
const tdm = accessor.get(ICompletionsTextDocumentManagerService) as TestTextDocumentManager;
|
||||
tdm.setTextDocument('file:///something.ts', 'typescript', '// match function f\nfunction foo() {}');
|
||||
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(
|
||||
result.prompt.prefix,
|
||||
dedent`
|
||||
// Path: basename
|
||||
// Compare this snippet from something.ts:
|
||||
// // match function f
|
||||
// function foo() {}
|
||||
${longPrefix}
|
||||
function f
|
||||
`
|
||||
);
|
||||
assert.deepStrictEqual(result.prompt.prefixTokens, 446);
|
||||
assert.deepStrictEqual(result.prompt.suffix, 'const b = 2;');
|
||||
assert.deepStrictEqual(result.prompt.suffixTokens, 6);
|
||||
});
|
||||
|
||||
test('prompt should include recent edits', async function () {
|
||||
const serviceCollectionClone = serviceCollection.clone();
|
||||
const workspace = new CompletionsMutableObservableWorkspace();
|
||||
serviceCollectionClone.define(ICompletionsObservableWorkspace, workspace);
|
||||
|
||||
// TODO: figure out how to simulate real document update events
|
||||
const rep = new MockRecentEditsProvider(undefined, workspace);
|
||||
serviceCollectionClone.define(ICompletionsRecentEditsProviderService, rep);
|
||||
|
||||
const accessorClone = serviceCollectionClone.createTestingAccessor();
|
||||
const promptFactory = accessorClone.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, undefined, undefined);
|
||||
|
||||
// Ensure the document is open
|
||||
const tdm = accessorClone.get(ICompletionsTextDocumentManagerService) as TestTextDocumentManager;
|
||||
tdm.setTextDocument(defaultTextDocument.uri, defaultTextDocument.languageId, defaultTextDocument.getText());
|
||||
|
||||
// Update the distance setting to avoid having to create a huge document
|
||||
rep.config.activeDocDistanceLimitFromCursor = 10;
|
||||
|
||||
rep.testUpdateRecentEdits(defaultTextDocument.uri, defaultTextDocument.getText());
|
||||
rep.testUpdateRecentEdits(
|
||||
defaultTextDocument.uri,
|
||||
defaultTextDocument.getText().replace('const a0', 'const c1')
|
||||
);
|
||||
|
||||
const result = await invokePromptFactory({}, promptFactory);
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(
|
||||
result.prompt.prefix,
|
||||
dedent`
|
||||
// Path: basename
|
||||
// These are recently edited files. Do not suggest code that has been deleted.
|
||||
// File: basename
|
||||
// --- a/file:///path/basename
|
||||
// +++ b/file:///path/basename
|
||||
// @@ -1,4 +1,4 @@
|
||||
// +const c1 = 0;
|
||||
// -const a0 = 0; --- IGNORE ---
|
||||
// const a1 = 1;
|
||||
// const a2 = 2;
|
||||
// const a3 = 3;
|
||||
// End of recent edits
|
||||
${longPrefix}
|
||||
function f
|
||||
`
|
||||
);
|
||||
assert.deepStrictEqual(result.prompt.suffix, 'const b = 2;');
|
||||
});
|
||||
|
||||
test('recent edits are removed as a chunk', async function () {
|
||||
const serviceCollectionClone = serviceCollection.clone();
|
||||
const workspace = new CompletionsMutableObservableWorkspace();
|
||||
serviceCollectionClone.define(ICompletionsObservableWorkspace, workspace);
|
||||
// TODO: figure out how to simulate real document update events
|
||||
const rep = new MockRecentEditsProvider(undefined, workspace);
|
||||
serviceCollectionClone.define(ICompletionsRecentEditsProviderService, rep);
|
||||
|
||||
const accessorClone = serviceCollectionClone.createTestingAccessor();
|
||||
const promptFactory = accessorClone.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, undefined, undefined);
|
||||
const featuresService = accessorClone.get(ICompletionsFeaturesService);
|
||||
|
||||
// Ensure the document is open
|
||||
const tdm = accessorClone.get(ICompletionsTextDocumentManagerService) as TestTextDocumentManager;
|
||||
tdm.setTextDocument(defaultTextDocument.uri, defaultTextDocument.languageId, defaultTextDocument.getText());
|
||||
|
||||
// Update the distance setting to avoid having to create a huge document
|
||||
rep.config.activeDocDistanceLimitFromCursor = 10;
|
||||
|
||||
rep.testUpdateRecentEdits(defaultTextDocument.uri, defaultTextDocument.getText());
|
||||
rep.testUpdateRecentEdits(
|
||||
defaultTextDocument.uri,
|
||||
defaultTextDocument.getText().replace('const a0', 'const c1')
|
||||
);
|
||||
|
||||
featuresService.maxPromptCompletionTokens = () => 530 + DEFAULT_MAX_COMPLETION_LENGTH;
|
||||
featuresService.suffixPercent = () => 0;
|
||||
|
||||
const result = await invokePromptFactory({}, promptFactory);
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(
|
||||
result.prompt.prefix,
|
||||
dedent`
|
||||
// Path: basename
|
||||
${longPrefix}
|
||||
function f
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt should include context and prefix', async function () {
|
||||
const result = await invokePromptFactory({ separateContext: true });
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(result.prompt.prefix, `${longPrefix}\nfunction f`);
|
||||
assert.deepStrictEqual(result.prompt.context, ['Path: basename']);
|
||||
assert.deepStrictEqual(result.prompt.suffix, 'const b = 2;');
|
||||
});
|
||||
|
||||
test('prompt should include prefix and suffix tokens', async function () {
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(result.prompt.prefixTokens, 427);
|
||||
assert.deepStrictEqual(result.prompt.suffixTokens, 6);
|
||||
});
|
||||
|
||||
test('suffix should be cached if similar enough', async function () {
|
||||
telemetryData.filtersAndExp.exp.variables.copilotsuffixmatchthreshold = 20;
|
||||
// Call it once to cache
|
||||
await invokePromptFactory();
|
||||
|
||||
const textDocument = createTextDocument(
|
||||
'untitled:',
|
||||
'typescript',
|
||||
1,
|
||||
dedent`
|
||||
const a = 1;
|
||||
function f|
|
||||
const b = 1;
|
||||
`
|
||||
);
|
||||
|
||||
const result = await invokePromptFactory({ textDocument });
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(result.prompt.suffix, 'const b = 2;');
|
||||
});
|
||||
|
||||
test('produces timeout prompt if timeout is exceeded', async function () {
|
||||
clock = sinon.useFakeTimers();
|
||||
const TimeoutComponent = (_: PromptElementProps, context: ComponentContext) => {
|
||||
context.useData(isCompletionRequestData, async _ => {
|
||||
await clock?.tickAsync(DEFAULT_PROMPT_TIMEOUT + 1);
|
||||
});
|
||||
return <Text>A really cool prompt</Text>;
|
||||
};
|
||||
const virtualPrompt = new VirtualPrompt(
|
||||
(
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<TimeoutComponent />
|
||||
</CompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
)
|
||||
);
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, undefined);
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result.type, 'promptTimeout');
|
||||
});
|
||||
|
||||
test('produces valid prompts with multiple promises racing', async function () {
|
||||
const promises = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const textDocument = createTextDocument(`file:///path/basename${i}`, 'typescript', 0, `const a = ${i}|;`);
|
||||
const promise = invokePromptFactory({ textDocument });
|
||||
promises.push(promise);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = results[i];
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(result.prompt.prefix, `// Path: basename${i}\nconst a = ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('handles errors with multiple promises racing', async function () {
|
||||
sinon
|
||||
.stub(TestComponentsCompletionsPromptFactory.prototype, 'createPromptUnsafe')
|
||||
.callThrough()
|
||||
.onFirstCall()
|
||||
.throws(new Error('Intentional error'));
|
||||
|
||||
const doc = createTextDocument('file:///path/basename', 'typescript', 0, `const a = 1|;`);
|
||||
|
||||
const smallDoc = createTextDocument('file:///path/basename', 'typescript', 0, `c|`);
|
||||
|
||||
const errorPromise = invokePromptFactory({ textDocument: doc });
|
||||
const goodPromise = invokePromptFactory({ textDocument: doc });
|
||||
const shortContextPromise = invokePromptFactory({ textDocument: smallDoc });
|
||||
|
||||
const results = await Promise.all([errorPromise, goodPromise, shortContextPromise]);
|
||||
|
||||
assert.deepStrictEqual(results[0], _promptError);
|
||||
assert.deepStrictEqual(results[2], _contextTooShort);
|
||||
|
||||
const firstResult = results[1];
|
||||
assert.deepStrictEqual(firstResult.type, 'prompt');
|
||||
assert.deepStrictEqual(firstResult.prompt.prefix, `// Path: basename\nconst a = 1`);
|
||||
});
|
||||
|
||||
test('produces valid prompts with sequential context provider calls', async function () {
|
||||
const featuresService = accessor.get(ICompletionsFeaturesService);
|
||||
featuresService.contextProviders = () => ['traitsProvider'];
|
||||
|
||||
let id = 0;
|
||||
const traitsProvider: ContextProvider<Trait> = {
|
||||
id: 'traitsProvider',
|
||||
selector: [{ language: 'typescript' }],
|
||||
resolver: {
|
||||
resolve: () => {
|
||||
const traitId = id++;
|
||||
return Promise.resolve([
|
||||
{ name: `test_trait${traitId}`, value: 'test_value', id: `trait${traitId}` },
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
accessor.get(ICompletionsContextProviderRegistryService).registerContextProvider(traitsProvider);
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const textDocument = createTextDocument(`file:///path/basename${i}`, 'typescript', 0, `const a = ${i}|;`);
|
||||
const promise = invokePromptFactory({ textDocument, completionId: `completion_id_${i}` });
|
||||
promises.push(promise);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = results[i];
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(
|
||||
result.prompt.prefix,
|
||||
`// Path: basename${i}\n// Consider this related information:\n// test_trait${i}: test_value\nconst a = ${i}`
|
||||
);
|
||||
assert.deepStrictEqual(result.contextProvidersTelemetry?.length, 1);
|
||||
assert.deepStrictEqual(result.contextProvidersTelemetry?.[0].usageDetails?.length, 1);
|
||||
assert.deepStrictEqual(result.contextProvidersTelemetry?.[0].usageDetails?.[0].id, `trait${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('produces valid prompts with multiple promises racing, one blocking', async function () {
|
||||
clock = sinon.useFakeTimers();
|
||||
let timeoutMs = DEFAULT_PROMPT_TIMEOUT + 1;
|
||||
const TimeoutComponent = (_: PromptElementProps, context: ComponentContext) => {
|
||||
context.useData(isCompletionRequestData, async _ => {
|
||||
const timeoutPromise = clock?.tickAsync(timeoutMs);
|
||||
timeoutMs = 0;
|
||||
await timeoutPromise;
|
||||
});
|
||||
|
||||
return <Text>A really cool prompt</Text>;
|
||||
};
|
||||
const virtualPrompt = new VirtualPrompt(
|
||||
(
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<TimeoutComponent />
|
||||
</CompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
)
|
||||
);
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, undefined);
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const textDocument = createTextDocument(`file:///${i}`, 'typescript', 0, `const a = ${i}|;`);
|
||||
const promise = invokePromptFactory({ textDocument });
|
||||
promises.push(promise);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
assert.deepStrictEqual(results[0].type, 'promptTimeout');
|
||||
assert.deepStrictEqual(results[1].type, 'prompt');
|
||||
assert.deepStrictEqual(results[1].prompt.prefix, '// A really cool prompt\nconst a = 1');
|
||||
});
|
||||
|
||||
test('token limits can be controlled via EXP', async function () {
|
||||
const tokenizer = getTokenizer();
|
||||
const longText = Array.from({ length: 1000 }, (_, i) => `const a${i} = ${i};`).join('\n');
|
||||
const longTextDocument = createTextDocument(
|
||||
'file:///path/basename',
|
||||
'typescript',
|
||||
0,
|
||||
longText + 'function f|\nconst b = 2;'
|
||||
);
|
||||
const defaultLimitsPrompt = await invokePromptFactory({ textDocument: longTextDocument });
|
||||
|
||||
assert.deepStrictEqual(defaultLimitsPrompt.type, 'prompt');
|
||||
assert.deepStrictEqual(tokenizer.tokenLength(defaultLimitsPrompt.prompt.prefix), 7007);
|
||||
assert.deepStrictEqual(tokenizer.tokenLength(defaultLimitsPrompt.prompt.suffix), 6);
|
||||
|
||||
// 100 tokens are left for the prompt, 5 are used for the suffix token, so 95 are left
|
||||
telemetryData.filtersAndExp.exp.variables.maxpromptcompletionTokens =
|
||||
100 + // Prefix + suffix
|
||||
5 + // Suffix encoding
|
||||
DEFAULT_MAX_COMPLETION_LENGTH;
|
||||
telemetryData.filtersAndExp.exp.variables.CopilotSuffixPercent = 2;
|
||||
|
||||
const expLimitsPrompt = await invokePromptFactory({ textDocument: longTextDocument });
|
||||
|
||||
assert.deepStrictEqual(expLimitsPrompt.type, 'prompt');
|
||||
assert.deepStrictEqual(tokenizer.tokenLength(expLimitsPrompt.prompt.prefix), 98);
|
||||
assert.deepStrictEqual(tokenizer.tokenLength(expLimitsPrompt.prompt.suffix), 2);
|
||||
});
|
||||
|
||||
test('produces context too short', async function () {
|
||||
const tinyTextDocument = createTextDocument('file:///path/basename', 'typescript', 0, '');
|
||||
const result = await invokePromptFactory({ textDocument: tinyTextDocument });
|
||||
|
||||
assert.deepStrictEqual(result, _contextTooShort);
|
||||
});
|
||||
|
||||
test('errors when hitting fault barrier', async function () {
|
||||
const virtualPrompt = new VirtualPrompt(<></>);
|
||||
virtualPrompt.snapshot = sinon.stub().throws(new Error('Intentional snapshot error'));
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, undefined);
|
||||
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result, _promptError);
|
||||
});
|
||||
|
||||
test('recovers from error when hitting fault barrier', async function () {
|
||||
const virtualPrompt = new VirtualPrompt(<></>);
|
||||
virtualPrompt.snapshot = sinon.stub().throws(new Error('Intentional snapshot error'));
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, undefined);
|
||||
|
||||
let result = await invokePromptFactory();
|
||||
assert.deepStrictEqual(result, _promptError);
|
||||
|
||||
result = await invokePromptFactory();
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
});
|
||||
|
||||
test('errors on snapshot error', async function () {
|
||||
const virtualPrompt = new VirtualPrompt(<></>);
|
||||
virtualPrompt.snapshot = sinon
|
||||
.stub()
|
||||
.returns({ snapshot: undefined, status: 'error', error: new Error('Intentional snapshot error') });
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, undefined);
|
||||
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result, _promptError);
|
||||
});
|
||||
|
||||
test('recovers from error on snapshot error', async function () {
|
||||
const virtualPrompt = new VirtualPrompt(<></>);
|
||||
virtualPrompt.snapshot = sinon
|
||||
.stub()
|
||||
.returns({ snapshot: undefined, status: 'error', error: new Error('Intentional snapshot error') });
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, undefined);
|
||||
|
||||
let result = await invokePromptFactory();
|
||||
assert.deepStrictEqual(result, _promptError);
|
||||
|
||||
result = await invokePromptFactory();
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
});
|
||||
|
||||
test('handles cancellation', async function () {
|
||||
cts.cancel();
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result, _promptCancelled);
|
||||
});
|
||||
|
||||
test('handles cancellation during update data', async function () {
|
||||
const CancellationComponent = (_: PromptElementProps, context: ComponentContext) => {
|
||||
context.useData(isCompletionRequestData, _ => {
|
||||
cts.cancel();
|
||||
});
|
||||
return <Text>A really cool prompt</Text>;
|
||||
};
|
||||
const virtualPrompt = new VirtualPrompt(
|
||||
(
|
||||
<>
|
||||
<CancellationComponent />
|
||||
<CurrentFile />
|
||||
</>
|
||||
)
|
||||
);
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, undefined);
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result, _promptCancelled);
|
||||
});
|
||||
|
||||
test('error in snapshot leads to prompt error', async function () {
|
||||
let outerSetShouldThrowError: Dispatch<StateUpdater<boolean>> = () => { };
|
||||
const ErrorThrowingComponent = (_props: PromptElementProps, context: ComponentContext) => {
|
||||
const [shouldThrowError, setShouldThrowError] = context.useState(false);
|
||||
outerSetShouldThrowError = setShouldThrowError;
|
||||
|
||||
if (shouldThrowError) {
|
||||
throw new Error('Intentional error');
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
const virtualPrompt = new VirtualPrompt(<ErrorThrowingComponent />);
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, undefined);
|
||||
|
||||
outerSetShouldThrowError(true);
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result, _promptError);
|
||||
});
|
||||
|
||||
test('prompt should not include context provider info if the context provider API is not enabled', async function () {
|
||||
const configProvider = accessor.get(ICompletionsConfigProvider) as InMemoryConfigProvider;
|
||||
configProvider.setConfig(ConfigKey.ContextProviders, []);
|
||||
|
||||
telemetryData.filtersAndExp.exp.variables.copilotcontextproviders = '';
|
||||
|
||||
const result = await invokePromptFactory();
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.ok(result.prompt.prefix.includes('Consider this related information:') === false);
|
||||
});
|
||||
|
||||
test('prompt should include traits, diagnostics and code snippets if the context provider API is enabled', async function () {
|
||||
telemetryData.filtersAndExp.exp.variables.copilotcontextproviders = 'traitsProvider,diagnosticsProvider,codeSnippetsProvider';
|
||||
|
||||
const traitsProvider: ContextProvider<Trait> = {
|
||||
id: 'traitsProvider',
|
||||
selector: [{ language: 'typescript' }],
|
||||
resolver: {
|
||||
resolve: () => Promise.resolve([{ name: 'test_trait', value: 'test_value' }]),
|
||||
},
|
||||
};
|
||||
const diagnosticsProvider: ContextProvider<DiagnosticBag> = {
|
||||
id: 'diagnosticsProvider',
|
||||
selector: [{ language: 'typescript' }],
|
||||
resolver: {
|
||||
resolve: () => {
|
||||
const diag1 = new Diagnostic(new Range(0, 10, 0, 20), 'type exists', DiagnosticSeverity.Error);
|
||||
diag1.code = 1017;
|
||||
diag1.source = 'ts';
|
||||
const diag2 = new Diagnostic(new Range(0, 20, 0, 25), 'unknown type', DiagnosticSeverity.Warning);
|
||||
diag2.code = 2017;
|
||||
return Promise.resolve([{ uri: Uri.file('something.ts'), values: [diag1, diag2] }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const codeSnippetsProvider: ContextProvider<CodeSnippet> = {
|
||||
id: 'codeSnippetsProvider',
|
||||
selector: [{ language: 'typescript' }],
|
||||
resolver: {
|
||||
resolve: () => Promise.resolve([{ uri: 'file:///something.ts', value: 'function foo() { return 1; }' }]),
|
||||
},
|
||||
};
|
||||
const contextProviderRegistry = accessor.get(ICompletionsContextProviderRegistryService);
|
||||
contextProviderRegistry.registerContextProvider(traitsProvider);
|
||||
contextProviderRegistry.registerContextProvider(diagnosticsProvider);
|
||||
contextProviderRegistry.registerContextProvider(codeSnippetsProvider);
|
||||
|
||||
// Register the documents for content exclusion
|
||||
const tdm = accessor.get(ICompletionsTextDocumentManagerService) as TestTextDocumentManager;
|
||||
tdm.setTextDocument('file:///something.ts', 'typescript', 'does not matter');
|
||||
|
||||
const result = await invokePromptFactory();
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(
|
||||
result.prompt.prefix,
|
||||
dedent`
|
||||
// Path: basename
|
||||
// Consider this related information:
|
||||
// test_trait: test_value
|
||||
// Consider the following typescript diagnostics from something.ts:
|
||||
// 1:11 - error TS1017: type exists
|
||||
// 1:21 - warning 2017: unknown type
|
||||
// Compare this snippet from something.ts:
|
||||
// function foo() { return 1; }
|
||||
` + `\n${longPrefix}\nfunction f`
|
||||
);
|
||||
});
|
||||
|
||||
test('should still produce a prompt if a context provider errors', async function () {
|
||||
telemetryData.filtersAndExp.exp.variables.copilotcontextproviders = 'errorProvider,codeSnippetsProvider';
|
||||
|
||||
const errorProvider: ContextProvider<SupportedContextItem> = {
|
||||
id: 'errorProvider',
|
||||
selector: [{ language: 'typescript' }],
|
||||
resolver: {
|
||||
resolve: (): Promise<never> => Promise.reject(new Error('Intentional error')),
|
||||
},
|
||||
};
|
||||
const codeSnippetsProvider: ContextProvider<CodeSnippet> = {
|
||||
id: 'codeSnippetsProvider',
|
||||
selector: [{ language: 'typescript' }],
|
||||
resolver: {
|
||||
resolve: () => Promise.resolve([{ uri: 'file:///something.ts', value: 'function foo() { return 1; }' }]),
|
||||
},
|
||||
};
|
||||
const contextProviderRegistry = accessor.get(ICompletionsContextProviderRegistryService);
|
||||
contextProviderRegistry.registerContextProvider(errorProvider);
|
||||
contextProviderRegistry.registerContextProvider(codeSnippetsProvider);
|
||||
|
||||
// Register the documents for content exclusion
|
||||
const tdm = accessor.get(ICompletionsTextDocumentManagerService) as TestTextDocumentManager;
|
||||
tdm.setTextDocument('file:///something.ts', 'typescript', 'does not matter');
|
||||
|
||||
const result = await invokePromptFactory();
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(
|
||||
result.prompt.prefix,
|
||||
dedent`
|
||||
// Path: basename
|
||||
// Compare this snippet from something.ts:
|
||||
// function foo() { return 1; }
|
||||
` + `\n${longPrefix}\nfunction f`
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt should include compute time', async function () {
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.ok(result.computeTimeMs > 0);
|
||||
});
|
||||
|
||||
test('prompt should trim prefix and include trailingWs', async function () {
|
||||
const textDocument = createTextDocument(
|
||||
'file:///path/basename',
|
||||
'typescript',
|
||||
0,
|
||||
`const a = 1;\nfunction f\n const b = 2;\n `
|
||||
);
|
||||
const result = await invokePromptFactory({ textDocument, position: Position.create(3, 4) });
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(result.prompt.prefix, '// Path: basename\nconst a = 1;\nfunction f\n const b = 2;\n');
|
||||
assert.deepStrictEqual(result.trailingWs, ' ');
|
||||
});
|
||||
|
||||
test('prompt respects context blocks if separateContext is true', async function () {
|
||||
function splitContextPrompt() {
|
||||
return (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text>First context block</Text>
|
||||
</CompletionsContext>
|
||||
<CompletionsContext>
|
||||
<Text>Second context block</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(splitContextPrompt());
|
||||
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, PromptOrdering.SplitContext);
|
||||
const result = await invokePromptFactory({ separateContext: true });
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(result.prompt.context, ['First context block', 'Second context block']);
|
||||
});
|
||||
|
||||
test('prompt does not output separate context blocks if separateContext is not specified', async function () {
|
||||
function splitContextPrompt() {
|
||||
return (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text>First context block</Text>
|
||||
</CompletionsContext>
|
||||
<CompletionsContext>
|
||||
<Text>Second context block</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(splitContextPrompt());
|
||||
|
||||
promptFactory = accessor.get(IInstantiationService).createInstance(TestCompletionsPromptFactory, virtualPrompt, PromptOrdering.SplitContext);
|
||||
const result = await invokePromptFactory();
|
||||
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
assert.deepStrictEqual(result.prompt.context, undefined);
|
||||
});
|
||||
|
||||
test('produces metadata', async function () {
|
||||
const result = await invokePromptFactory();
|
||||
assert.deepStrictEqual(result.type, 'prompt');
|
||||
|
||||
const metadata = result.metadata;
|
||||
assert.ok(metadata);
|
||||
assert.ok(metadata.renderId === 0);
|
||||
assert.ok(metadata.elisionTimeMs > 0);
|
||||
assert.ok(metadata.renderTimeMs > 0);
|
||||
assert.ok(metadata.updateDataTimeMs > 0);
|
||||
assert.deepStrictEqual(metadata.rendererName, 'c');
|
||||
assert.deepStrictEqual(metadata.tokenizer, TokenizerName.o200k);
|
||||
|
||||
const componentsUpdateDataTimeMs = metadata.componentStatistics.reduce(
|
||||
(acc, { updateDataTimeMs }) => acc + (updateDataTimeMs ?? 0),
|
||||
0
|
||||
);
|
||||
assert.ok(componentsUpdateDataTimeMs > 0);
|
||||
const actualStatsFiltered = metadata.componentStatistics.map(stats => {
|
||||
if (stats.updateDataTimeMs) {
|
||||
stats.updateDataTimeMs = 42;
|
||||
}
|
||||
return stats;
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(actualStatsFiltered, [
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[0].DocumentMarker',
|
||||
updateDataTimeMs: 42,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[1].Traits',
|
||||
updateDataTimeMs: 42,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[2].Diagnostics',
|
||||
updateDataTimeMs: 42,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[3].CodeSnippets',
|
||||
updateDataTimeMs: 42,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[4].SimilarFiles',
|
||||
updateDataTimeMs: 42,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[5].RecentEdits',
|
||||
updateDataTimeMs: 42,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile',
|
||||
updateDataTimeMs: 42,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[0].DocumentMarker[0].PathMarker[0].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 5,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile[0].f[0].BeforeCursor[0].Text[0]',
|
||||
expectedTokens: 422,
|
||||
actualTokens: 422,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile[0].f[1].AfterCursor[0].Text[0]',
|
||||
expectedTokens: 6,
|
||||
actualTokens: 6,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('telemetry should include context providers', async function () {
|
||||
telemetryData.filtersAndExp.exp.variables.copilotcontextproviders = 'traitsProvider,codeSnippetsProvider';
|
||||
|
||||
const traitsContextProvider: ContextProvider<Trait> = {
|
||||
id: 'traitsProvider',
|
||||
selector: [{ language: 'typescript' }],
|
||||
resolver: {
|
||||
resolve: () => Promise.resolve([{ name: 'test_trait', value: 'test_value', id: 'trait1' }]),
|
||||
},
|
||||
};
|
||||
const codeSnippetsProvider: ContextProvider<CodeSnippet> = {
|
||||
id: 'codeSnippetsProvider',
|
||||
selector: [{ language: 'typescript' }],
|
||||
resolver: {
|
||||
resolve: (): Promise<CodeSnippet[]> =>
|
||||
Promise.resolve([
|
||||
{
|
||||
uri: 'file:///something.ts',
|
||||
value: dedent`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
id: 'cs1',
|
||||
},
|
||||
{
|
||||
uri: 'file:///somethingElse.ts',
|
||||
value: dedent`
|
||||
function bar() {
|
||||
return 'two';
|
||||
}
|
||||
`,
|
||||
id: 'cs2',
|
||||
origin: 'update',
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
// Register the documents for content exclusion
|
||||
const contextProviderRegistry = accessor.get(ICompletionsContextProviderRegistryService);
|
||||
const tdm = accessor.get(ICompletionsTextDocumentManagerService) as TestTextDocumentManager;
|
||||
tdm.setTextDocument('file:///something.ts', 'typescript', 'does not matter');
|
||||
|
||||
contextProviderRegistry.registerContextProvider(traitsContextProvider);
|
||||
contextProviderRegistry.registerContextProvider(codeSnippetsProvider);
|
||||
|
||||
const prompt = await invokePromptFactory();
|
||||
|
||||
const expectedTelemetry: ContextProviderTelemetry[] = [
|
||||
{
|
||||
providerId: 'traitsProvider',
|
||||
resolution: 'full',
|
||||
resolutionTimeMs: -1,
|
||||
usage: 'full',
|
||||
matched: true,
|
||||
numResolvedItems: 1,
|
||||
numUsedItems: 1,
|
||||
numPartiallyUsedItems: 0,
|
||||
usageDetails: [{ id: 'trait1', usage: 'full', expectedTokens: 7, actualTokens: 7, type: 'Trait' }],
|
||||
},
|
||||
{
|
||||
providerId: 'codeSnippetsProvider',
|
||||
resolution: 'full',
|
||||
resolutionTimeMs: -1,
|
||||
usage: 'full',
|
||||
matched: true,
|
||||
numResolvedItems: 2,
|
||||
numUsedItems: 2,
|
||||
numPartiallyUsedItems: 0,
|
||||
usageDetails: [
|
||||
{ id: 'cs1', usage: 'full', expectedTokens: 13, actualTokens: 13, type: 'CodeSnippet' },
|
||||
{ id: 'cs2', usage: 'full', expectedTokens: 13, actualTokens: 13, type: 'CodeSnippet', origin: 'update' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepStrictEqual(prompt.type, 'prompt');
|
||||
assert.deepStrictEqual(
|
||||
prompt.contextProvidersTelemetry?.map(pt => {
|
||||
pt.resolutionTimeMs = -1;
|
||||
return pt;
|
||||
}),
|
||||
expectedTelemetry
|
||||
);
|
||||
});
|
||||
|
||||
test('Test only sanctioned traits are included in telemetry', async function () {
|
||||
telemetryData.filtersAndExp.exp.variables.copilotcontextproviders = 'traitsProvider';
|
||||
|
||||
const traitsProvider: ContextProvider<Trait> = {
|
||||
id: 'traitsProvider',
|
||||
selector: [{ language: 'typescript' }],
|
||||
resolver: {
|
||||
resolve: () =>
|
||||
Promise.resolve([
|
||||
{ name: 'trait1', value: 'value1' },
|
||||
{ name: 'TargetFrameworks', value: 'framework value' },
|
||||
{ name: 'trait2', value: 'value2' },
|
||||
{ name: 'LanguageVersion', value: 'language version' },
|
||||
]),
|
||||
},
|
||||
};
|
||||
const contextProviderRegistry = accessor.get(ICompletionsContextProviderRegistryService);
|
||||
contextProviderRegistry.registerContextProvider(traitsProvider);
|
||||
|
||||
const { reporter } = await withInMemoryTelemetry(accessor, async _ => {
|
||||
const response = await invokePromptFactory();
|
||||
assert.deepStrictEqual(response.type, 'prompt');
|
||||
assert.deepStrictEqual(
|
||||
response.prompt.prefix,
|
||||
dedent`
|
||||
// Path: basename
|
||||
// Consider this related information:
|
||||
// trait1: value1
|
||||
// TargetFrameworks: framework value
|
||||
// trait2: value2
|
||||
// LanguageVersion: language version
|
||||
` + `\n${longPrefix}\nfunction f`
|
||||
);
|
||||
});
|
||||
|
||||
// the event should only contains sanctioned trait with expected property names.
|
||||
assert.strictEqual(reporter.hasEvent, true);
|
||||
assert.strictEqual(reporter.events.length, 1);
|
||||
|
||||
assert.strictEqual(reporter.events[0].name, 'contextProvider.traits');
|
||||
assert.strictEqual(reporter.events[0].properties['targetFrameworks'], 'framework value');
|
||||
assert.strictEqual(reporter.events[0].properties['languageVersion'], 'language version');
|
||||
assert.strictEqual(reporter.events[0].properties['languageId'], 'typescript');
|
||||
|
||||
assert.strictEqual(reporter.events[0].properties['trait1'], undefined);
|
||||
assert.strictEqual(reporter.events[0].properties['trait2'], undefined);
|
||||
|
||||
assert.strictEqual(reporter.hasException, false);
|
||||
});
|
||||
});
|
||||
|
||||
class MockRecentEditsProvider extends FullRecentEditsProvider {
|
||||
testUpdateRecentEdits(docId: string, newContents: string): void {
|
||||
return this.updateRecentEdits(docId, newContents);
|
||||
}
|
||||
}
|
||||
|
||||
export class CompletionsMutableObservableWorkspace extends MutableObservableWorkspace implements ICompletionsObservableWorkspace {
|
||||
declare _serviceBrand: undefined;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { Chunk, ComponentContext, PromptElementProps, Text } from '../../../../prompt/src/components/components';
|
||||
import { ICompletionsTextDocumentManagerService } from '../../textDocumentManager';
|
||||
import {
|
||||
CompletionRequestDocument,
|
||||
isCompletionRequestData,
|
||||
} from '../completionsPromptFactory/componentsCompletionsPromptFactory';
|
||||
import { addRelativePathToCodeSnippets, CodeSnippetWithRelativePath } from '../contextProviders/codeSnippets';
|
||||
import { CodeSnippetWithId } from '../contextProviders/contextItemSchemas';
|
||||
|
||||
type CodeSnippetsProps = {
|
||||
tdms: ICompletionsTextDocumentManagerService;
|
||||
} & PromptElementProps;
|
||||
|
||||
export const CodeSnippets = (props: CodeSnippetsProps, context: ComponentContext) => {
|
||||
const [snippets, setSnippets] = context.useState<CodeSnippetWithId[]>();
|
||||
const [document, setDocument] = context.useState<CompletionRequestDocument>();
|
||||
|
||||
context.useData(isCompletionRequestData, request => {
|
||||
if (request.codeSnippets !== snippets) {
|
||||
setSnippets(request.codeSnippets);
|
||||
}
|
||||
if (request.document.uri !== document?.uri) {
|
||||
setDocument(request.document);
|
||||
}
|
||||
});
|
||||
|
||||
if (!snippets || snippets.length === 0 || !document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const codeSnippetsWithRelativePath = addRelativePathToCodeSnippets(props.tdms, snippets);
|
||||
|
||||
// Snippets with the same URI should appear together as a single snippet.
|
||||
const snippetsByUri = new Map<string, CodeSnippetWithRelativePath[]>();
|
||||
|
||||
for (const snippet of codeSnippetsWithRelativePath) {
|
||||
const uri = snippet.relativePath ?? snippet.snippet.uri;
|
||||
let groupedSnippets = snippetsByUri.get(uri);
|
||||
if (groupedSnippets === undefined) {
|
||||
groupedSnippets = [];
|
||||
snippetsByUri.set(uri, groupedSnippets);
|
||||
}
|
||||
groupedSnippets.push(snippet);
|
||||
}
|
||||
|
||||
const codeSnippetChunks: {
|
||||
chunkElements: CodeSnippetWithId[];
|
||||
importance: number;
|
||||
uri: string;
|
||||
}[] = [];
|
||||
for (const [uri, snippets] of snippetsByUri.entries()) {
|
||||
const validSnippets = snippets.filter(s => s.snippet.value.length > 0);
|
||||
if (validSnippets.length > 0) {
|
||||
codeSnippetChunks.push({
|
||||
chunkElements: validSnippets.map(s => s.snippet),
|
||||
// The importance is the maximum importance of the snippets in this group.
|
||||
importance: Math.max(...validSnippets.map(snippet => snippet.snippet.importance ?? 0)),
|
||||
uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (codeSnippetChunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by importance, with the most important first
|
||||
codeSnippetChunks.sort((a, b) => b.importance - a.importance);
|
||||
// Reverse the order so the most important snippet is last. Note, that we don't directly
|
||||
// sort in ascending order to handle importance 0 correctly.
|
||||
codeSnippetChunks.reverse();
|
||||
return codeSnippetChunks.map(chunk => {
|
||||
const elements = [];
|
||||
|
||||
elements.push(
|
||||
<Text>
|
||||
{`Compare ${chunk.chunkElements.length > 1 ? 'these snippets' : 'this snippet'} from ${chunk.uri}:`}
|
||||
</Text>
|
||||
);
|
||||
|
||||
chunk.chunkElements.forEach((element, index) => {
|
||||
elements.push(
|
||||
<Text source={element} key={element.id}>
|
||||
{element.value}
|
||||
</Text>
|
||||
);
|
||||
if (chunk.chunkElements.length > 1 && index < chunk.chunkElements.length - 1) {
|
||||
elements.push(<Text>---</Text>);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: change Chunk for KeepTogether
|
||||
return <Chunk>{elements}</Chunk>;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
//** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { PromptElementProps, PromptSnapshotNode } from '../../../../prompt/src/components/components';
|
||||
|
||||
/**
|
||||
* A component that marks the context part of the prompt
|
||||
*/
|
||||
export function CompletionsContext(props: PromptElementProps) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that marks the context part of the prompt that is stable across requests,
|
||||
* and should be located earlier in the prompt to maximize cache hits.
|
||||
*/
|
||||
export function StableCompletionsContext(props: PromptElementProps) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that marks the context part of the prompt that is subject to change quickly across requests,
|
||||
* and should be located further down in the prompt.
|
||||
*/
|
||||
export function AdditionalCompletionsContext(props: PromptElementProps) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
export function isContextNode(node: PromptSnapshotNode): boolean {
|
||||
return (
|
||||
node.name === CompletionsContext.name ||
|
||||
node.name === StableCompletionsContext.name ||
|
||||
node.name === AdditionalCompletionsContext.name
|
||||
);
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { CancellationToken } from 'vscode-languageserver-protocol';
|
||||
import {
|
||||
ComponentStatistics,
|
||||
PromptOk,
|
||||
PromptRenderer,
|
||||
PromptRenderOptions,
|
||||
PromptSnapshotNode,
|
||||
StatusNotOk,
|
||||
} from '../../../../prompt/src/components/components';
|
||||
import { defaultTransformers, SnapshotWalker, WalkContextTransformer } from '../../../../prompt/src/components/walker';
|
||||
import { commentBlockAsSingles, isShebangLine } from '../../../../prompt/src/languageMarker';
|
||||
import { getTokenizer, TokenizerName } from '../../../../prompt/src/tokenization';
|
||||
import { isContextNode } from './completionsContext';
|
||||
import { AfterCursor, BeforeCursor, CurrentFile } from './currentFile';
|
||||
import { ElidedBlock, makePrompt, WeightedBlock, WishlistElision } from './elision';
|
||||
|
||||
const TOKENS_RESERVED_FOR_SUFFIX_ENCODING = 5;
|
||||
|
||||
export type CompletionsPromptOk = PromptOk & {
|
||||
prefix: string;
|
||||
prefixTokens: number;
|
||||
suffix: string;
|
||||
suffixTokens: number;
|
||||
context: string[] | undefined;
|
||||
};
|
||||
type CompletionsPrompt = CompletionsPromptOk | StatusNotOk;
|
||||
|
||||
export interface CompletionsPromptRenderOptions extends PromptRenderOptions {
|
||||
promptTokenLimit: number;
|
||||
suffixPercent: number;
|
||||
languageId: string;
|
||||
delimiter?: string;
|
||||
}
|
||||
|
||||
export class CompletionsPromptRenderer implements PromptRenderer<CompletionsPrompt, CompletionsPromptRenderOptions> {
|
||||
private renderId = 0;
|
||||
|
||||
/**
|
||||
* Function used to format the prefix blocks into a string.
|
||||
* If implementing a renderer subclass, override this to control how the prefix is formatted, otherwise defaults to `makePrompt`.
|
||||
*/
|
||||
protected formatPrefix: (elidedBlocks: ElidedBlock[]) => string = makePrompt;
|
||||
|
||||
/**
|
||||
* Function used to format the context blocks into a string array.
|
||||
* Context is optional, so leave this as `undefined` if you do not want to include context in the rendered prompt.
|
||||
* If implementing a renderer subclass, override this to control how the context is formatted, otherwise defaults to `undefined`.
|
||||
*/
|
||||
protected formatContext: undefined | ((elidedBlocks: ElidedBlock[]) => string[]);
|
||||
|
||||
render(
|
||||
snapshot: PromptSnapshotNode,
|
||||
options: CompletionsPromptRenderOptions,
|
||||
cancellationToken?: CancellationToken
|
||||
): CompletionsPrompt {
|
||||
const id = this.renderId++;
|
||||
const renderStart = performance.now();
|
||||
try {
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return { status: 'cancelled' };
|
||||
}
|
||||
// Default options
|
||||
const delimiter = options.delimiter ?? '';
|
||||
const tokenizer = options.tokenizer ?? TokenizerName.o200k;
|
||||
// Process the snapshot to get the prefix and suffix and adjust the token limits accordingly
|
||||
const { prefixBlocks, suffixBlock, componentStatistics } = this.processSnapshot(
|
||||
snapshot,
|
||||
delimiter,
|
||||
options.languageId
|
||||
);
|
||||
|
||||
const { prefixTokenLimit, suffixTokenLimit } = this.getPromptLimits(suffixBlock, options);
|
||||
const elisionStart = performance.now();
|
||||
const elisionStrategy = new WishlistElision();
|
||||
// The first element is always the suffix
|
||||
const {
|
||||
blocks: [elidedSuffix, ...elidedPrefix],
|
||||
} = elisionStrategy.elide(
|
||||
prefixBlocks,
|
||||
prefixTokenLimit,
|
||||
suffixBlock,
|
||||
suffixTokenLimit,
|
||||
getTokenizer(tokenizer)
|
||||
);
|
||||
const elisionEnd = performance.now();
|
||||
|
||||
const prefix = this.formatPrefix(elidedPrefix);
|
||||
const context = this.formatContext ? this.formatContext(elidedPrefix) : undefined;
|
||||
const suffix = elidedSuffix.elidedValue;
|
||||
const prefixTokens = elidedPrefix.reduce((acc, block) => acc + block.elidedTokens, 0);
|
||||
|
||||
componentStatistics.push(...computeComponentStatistics([...elidedPrefix, elidedSuffix]));
|
||||
return {
|
||||
prefix,
|
||||
prefixTokens,
|
||||
suffix,
|
||||
suffixTokens: elidedSuffix.elidedTokens,
|
||||
context,
|
||||
status: 'ok',
|
||||
metadata: {
|
||||
renderId: id,
|
||||
rendererName: 'c',
|
||||
tokenizer: tokenizer,
|
||||
elisionTimeMs: elisionEnd - elisionStart,
|
||||
renderTimeMs: performance.now() - renderStart,
|
||||
componentStatistics,
|
||||
updateDataTimeMs: componentStatistics.reduce(
|
||||
(acc, component) => acc + (component.updateDataTimeMs ?? 0),
|
||||
0
|
||||
),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return { status: 'error', error: e as Error };
|
||||
}
|
||||
}
|
||||
|
||||
// Defaults are hardcoded for now, but we can use EXP flags like PromptOptions does
|
||||
// by passing the context
|
||||
private getPromptLimits(suffixBlock: WeightedBlock | undefined, options: CompletionsPromptRenderOptions) {
|
||||
const suffix = suffixBlock?.value ?? '';
|
||||
|
||||
let availableTokens = options.promptTokenLimit;
|
||||
const suffixPercent = options.suffixPercent;
|
||||
|
||||
if (suffix.length === 0 || suffixPercent === 0) {
|
||||
return { prefixTokenLimit: availableTokens, suffixTokenLimit: 0 };
|
||||
}
|
||||
|
||||
// If there is a suffix, we need to reserve some tokens for the suffix encoding
|
||||
availableTokens = suffix.length > 0 ? availableTokens - TOKENS_RESERVED_FOR_SUFFIX_ENCODING : availableTokens;
|
||||
|
||||
const suffixTokenLimit = Math.ceil(availableTokens * (suffixPercent / 100));
|
||||
const prefixTokenLimit = availableTokens - suffixTokenLimit;
|
||||
|
||||
return {
|
||||
prefixTokenLimit,
|
||||
suffixTokenLimit,
|
||||
};
|
||||
}
|
||||
|
||||
protected processSnapshot(
|
||||
snapshot: PromptSnapshotNode,
|
||||
delimiter: string,
|
||||
languageId: string
|
||||
): {
|
||||
prefixBlocks: WeightedBlock[];
|
||||
suffixBlock: WeightedBlock;
|
||||
componentStatistics: ComponentStatistics[];
|
||||
} {
|
||||
const prefixBlocks: WeightedBlock[] = [];
|
||||
const suffixBlocks: WeightedBlock[] = [];
|
||||
const componentStatistics: ComponentStatistics[] = [];
|
||||
// Store the status of the required nodes
|
||||
let foundDocument = false;
|
||||
|
||||
const walker = new SnapshotWalker(snapshot, transformers);
|
||||
walker.walkSnapshot((node, _parent, context) => {
|
||||
if (node === snapshot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for the presence of required node
|
||||
if (node.name === CurrentFile.name) {
|
||||
foundDocument = true;
|
||||
}
|
||||
|
||||
if (node.statistics.updateDataTimeMs && node.statistics.updateDataTimeMs > 0) {
|
||||
componentStatistics.push({
|
||||
componentPath: node.path,
|
||||
updateDataTimeMs: node.statistics.updateDataTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (node.value === undefined || node.value === '') {
|
||||
// No need to process this node as it only adds whitespace
|
||||
return true;
|
||||
}
|
||||
|
||||
const chunks = context.chunks as Set<string> | undefined;
|
||||
if (context.type === 'suffix') {
|
||||
// Everything after the cursor is part of the suffix
|
||||
suffixBlocks.push({
|
||||
value: normalizeLineEndings(node.value),
|
||||
type: 'suffix',
|
||||
weight: context.weight as number,
|
||||
componentPath: node.path,
|
||||
nodeStatistics: node.statistics,
|
||||
chunks,
|
||||
source: context.source,
|
||||
});
|
||||
} else {
|
||||
// Add a delimiter for all nodes, that are not the beforeCursor if not already present
|
||||
const nodeValueWithDelimiter = node.value.endsWith(delimiter) ? node.value : node.value + delimiter;
|
||||
let value = nodeValueWithDelimiter;
|
||||
if (context.type === 'prefix') {
|
||||
value = node.value;
|
||||
} else if (isShebangLine(node.value)) {
|
||||
value = nodeValueWithDelimiter;
|
||||
} else {
|
||||
value = commentBlockAsSingles(nodeValueWithDelimiter, languageId);
|
||||
}
|
||||
prefixBlocks.push({
|
||||
type: context.type === 'prefix' ? 'prefix' : 'context',
|
||||
value: normalizeLineEndings(value),
|
||||
weight: context.weight as number,
|
||||
componentPath: node.path,
|
||||
nodeStatistics: node.statistics,
|
||||
chunks,
|
||||
source: context.source,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!foundDocument) {
|
||||
throw new Error(`Node of type ${CurrentFile.name} not found`);
|
||||
}
|
||||
if (suffixBlocks.length > 1) {
|
||||
throw new Error(`Only one suffix is allowed`);
|
||||
}
|
||||
|
||||
const suffixBlock: WeightedBlock =
|
||||
suffixBlocks.length === 1
|
||||
? suffixBlocks[0]
|
||||
: {
|
||||
componentPath: '',
|
||||
value: '',
|
||||
weight: 1,
|
||||
nodeStatistics: {},
|
||||
type: 'suffix',
|
||||
};
|
||||
return { prefixBlocks, suffixBlock, componentStatistics };
|
||||
}
|
||||
}
|
||||
|
||||
export const transformers: WalkContextTransformer[] = [
|
||||
...defaultTransformers(),
|
||||
// Context transformer
|
||||
(node, _, context) => {
|
||||
if (isContextNode(node)) {
|
||||
return { ...context, type: 'context' };
|
||||
}
|
||||
return context;
|
||||
},
|
||||
// Prefix transformer
|
||||
(node, _, context) => {
|
||||
if (node.name === BeforeCursor.name) {
|
||||
return {
|
||||
...context,
|
||||
type: 'prefix',
|
||||
};
|
||||
}
|
||||
return context;
|
||||
},
|
||||
// Suffix transformer
|
||||
(node, _, context) => {
|
||||
if (node.name === AfterCursor.name) {
|
||||
return {
|
||||
...context,
|
||||
type: 'suffix',
|
||||
};
|
||||
}
|
||||
return context;
|
||||
},
|
||||
];
|
||||
|
||||
function computeComponentStatistics(elidedBlocks: ElidedBlock[]) {
|
||||
return elidedBlocks.map(block => {
|
||||
const result: ComponentStatistics = {
|
||||
componentPath: block.componentPath,
|
||||
};
|
||||
if (block.tokens !== 0) {
|
||||
result.expectedTokens = block.tokens;
|
||||
result.actualTokens = block.elidedTokens;
|
||||
}
|
||||
if (block.nodeStatistics.updateDataTimeMs !== undefined) {
|
||||
result.updateDataTimeMs = block.nodeStatistics.updateDataTimeMs;
|
||||
}
|
||||
if (block.source) {
|
||||
result.source = block.source;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLineEndings(text: string) {
|
||||
return text.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { createServiceIdentifier } from '../../../../../../../util/common/services';
|
||||
import { CancellationToken } from '../../../../types/src';
|
||||
import { CompletionState } from '../../completionState';
|
||||
import { LRUCacheMap } from '../../helpers/cache';
|
||||
import { TelemetryWithExp } from '../../telemetry';
|
||||
import { ICompletionsContextProviderRegistryService, ResolvedContextItem } from '../contextProviderRegistry';
|
||||
|
||||
export const ICompletionsContextProviderBridgeService = createServiceIdentifier<ICompletionsContextProviderBridgeService>('ICompletionsContextProviderBridgeService');
|
||||
export interface ICompletionsContextProviderBridgeService {
|
||||
readonly _serviceBrand: undefined;
|
||||
schedule(
|
||||
completionState: CompletionState,
|
||||
completionId: string,
|
||||
opportunityId: string,
|
||||
telemetryData: TelemetryWithExp,
|
||||
cancellationToken?: CancellationToken,
|
||||
options?: { data?: unknown }
|
||||
): void;
|
||||
|
||||
resolution(id: string): Promise<ResolvedContextItem[]>;
|
||||
}
|
||||
|
||||
export class ContextProviderBridge implements ICompletionsContextProviderBridgeService {
|
||||
declare _serviceBrand: undefined;
|
||||
private scheduledResolutions = new LRUCacheMap<string, Promise<ResolvedContextItem[]>>(25);
|
||||
|
||||
constructor(@ICompletionsContextProviderRegistryService private readonly contextProviderRegistry: ICompletionsContextProviderRegistryService) { }
|
||||
|
||||
schedule(
|
||||
completionState: CompletionState,
|
||||
completionId: string,
|
||||
opportunityId: string,
|
||||
telemetryData: TelemetryWithExp,
|
||||
cancellationToken?: CancellationToken,
|
||||
options?: { data?: unknown }
|
||||
) {
|
||||
const { textDocument, originalPosition, originalOffset, originalVersion, editsWithPosition } = completionState;
|
||||
|
||||
const resolutionPromise = this.contextProviderRegistry.resolveAllProviders(
|
||||
completionId,
|
||||
opportunityId,
|
||||
{
|
||||
uri: textDocument.uri,
|
||||
languageId: textDocument.detectedLanguageId,
|
||||
version: originalVersion,
|
||||
offset: originalOffset,
|
||||
position: originalPosition,
|
||||
proposedEdits: editsWithPosition.length > 0 ? editsWithPosition : undefined,
|
||||
},
|
||||
telemetryData,
|
||||
cancellationToken,
|
||||
options?.data
|
||||
);
|
||||
|
||||
this.scheduledResolutions.set(completionId, resolutionPromise);
|
||||
// intentionally not awaiting to avoid blocking
|
||||
}
|
||||
|
||||
async resolution(id: string): Promise<ResolvedContextItem[]> {
|
||||
const resolutionPromise = this.scheduledResolutions.get(id);
|
||||
if (resolutionPromise) {
|
||||
return await resolutionPromise;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { Position } from 'vscode-languageserver-protocol';
|
||||
import { ComponentContext, PromptElementProps, Text } from '../../../../prompt/src/components/components';
|
||||
import { DEFAULT_SUFFIX_MATCH_THRESHOLD } from '../../../../prompt/src/prompt';
|
||||
import { findEditDistanceScore } from '../../../../prompt/src/suffixMatchCriteria';
|
||||
import { getTokenizer, TokenizerName } from '../../../../prompt/src/tokenization';
|
||||
import {
|
||||
CompletionRequestDocument,
|
||||
isCompletionRequestData,
|
||||
} from '../completionsPromptFactory/componentsCompletionsPromptFactory';
|
||||
|
||||
/** The maximum number of tokens that is used for calculate edit distance. */
|
||||
export const MAX_EDIT_DISTANCE_LENGTH = 50;
|
||||
|
||||
function approximateMaxCharacters(maxPromptLength: number): number {
|
||||
const maxCharsInPrompt = maxPromptLength * 4; // approximate 4 chars per token
|
||||
const compensation = maxPromptLength * 0.1; // 10% overflow to compensate the token approximation
|
||||
return Math.floor(maxCharsInPrompt + compensation);
|
||||
}
|
||||
|
||||
/**
|
||||
* A required component for the CompletionsPromptRenderer. It represents the document and position where completions should be shown.
|
||||
*/
|
||||
export function CurrentFile(_props: PromptElementProps, context: ComponentContext) {
|
||||
const [document, setDocument] = context.useState<CompletionRequestDocument>();
|
||||
const [position, setPosition] = context.useState<Position>();
|
||||
const [maxPromptLength, setMaxPromptLength] = context.useState<number>(0);
|
||||
const [suffixMatchThreshold, setSuffixMatchThreshold] = context.useState<number>();
|
||||
const [tokenizer, setTokenizer] = context.useState<TokenizerName>();
|
||||
|
||||
context.useData(isCompletionRequestData, request => {
|
||||
const requestDocument = request.document;
|
||||
if (request.document.uri !== document?.uri || requestDocument.getText() !== document?.getText()) {
|
||||
setDocument(requestDocument);
|
||||
}
|
||||
|
||||
if (request.position !== position) {
|
||||
setPosition(request.position);
|
||||
}
|
||||
|
||||
if (request.suffixMatchThreshold !== suffixMatchThreshold) {
|
||||
setSuffixMatchThreshold(request.suffixMatchThreshold);
|
||||
}
|
||||
|
||||
if (request.maxPromptTokens !== maxPromptLength) {
|
||||
setMaxPromptLength(request.maxPromptTokens);
|
||||
}
|
||||
|
||||
if (request.tokenizer !== tokenizer) {
|
||||
setTokenizer(request.tokenizer);
|
||||
}
|
||||
});
|
||||
|
||||
const maxCharacters = approximateMaxCharacters(maxPromptLength);
|
||||
return (
|
||||
<>
|
||||
<BeforeCursor document={document} position={position} maxCharacters={maxCharacters} />
|
||||
<AfterCursor
|
||||
document={document}
|
||||
position={position}
|
||||
suffixMatchThreshold={suffixMatchThreshold}
|
||||
maxCharacters={maxCharacters}
|
||||
tokenizer={tokenizer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BeforeCursor(props: {
|
||||
document: CompletionRequestDocument | undefined;
|
||||
position: Position | undefined;
|
||||
maxCharacters: number;
|
||||
}) {
|
||||
if (props.document === undefined || props.position === undefined) {
|
||||
return <Text />;
|
||||
}
|
||||
|
||||
let text = props.document.getText({ start: { line: 0, character: 0 }, end: props.position });
|
||||
if (text.length > props.maxCharacters) {
|
||||
text = text.slice(-props.maxCharacters);
|
||||
}
|
||||
return <Text>{text}</Text>;
|
||||
}
|
||||
|
||||
export function AfterCursor(
|
||||
props: {
|
||||
document: CompletionRequestDocument | undefined;
|
||||
position: Position | undefined;
|
||||
maxCharacters: number;
|
||||
suffixMatchThreshold?: number;
|
||||
tokenizer?: TokenizerName;
|
||||
},
|
||||
context: ComponentContext
|
||||
) {
|
||||
const [cachedSuffix, setCachedSuffix] = context.useState<string>('');
|
||||
|
||||
if (props.document === undefined || props.position === undefined) {
|
||||
return <Text />;
|
||||
}
|
||||
|
||||
let suffix = props.document.getText({
|
||||
start: props.position,
|
||||
end: { line: Number.MAX_VALUE, character: Number.MAX_VALUE },
|
||||
});
|
||||
if (suffix.length > props.maxCharacters) {
|
||||
suffix = suffix.slice(0, props.maxCharacters);
|
||||
}
|
||||
|
||||
// Start the suffix at the beginning of the next line. This allows for consistent reconciliation of trailing punctuation.
|
||||
const trimmedSuffix = suffix.replace(/^.*/, '').trimStart();
|
||||
if (trimmedSuffix === '') {
|
||||
return <Text />;
|
||||
}
|
||||
|
||||
// Cache hit
|
||||
if (cachedSuffix === trimmedSuffix) {
|
||||
return <Text>{cachedSuffix}</Text>;
|
||||
}
|
||||
|
||||
let suffixToUse = trimmedSuffix;
|
||||
if (cachedSuffix !== '') {
|
||||
const tokenizer = getTokenizer(props.tokenizer);
|
||||
const firstSuffixTokens = tokenizer.takeFirstTokens(trimmedSuffix, MAX_EDIT_DISTANCE_LENGTH);
|
||||
// Check if the suffix is similar to the cached suffix.
|
||||
// See docs/suffix_caching.md for some background about why we do this.
|
||||
if (firstSuffixTokens.tokens.length > 0) {
|
||||
// Calculate the distance between the computed and cached suffixed using Levenshtein distance.
|
||||
// Only compare the first MAX_EDIT_DISTANCE_LENGTH tokens to speed up.
|
||||
const dist = findEditDistanceScore(
|
||||
firstSuffixTokens.tokens,
|
||||
tokenizer.takeFirstTokens(cachedSuffix, MAX_EDIT_DISTANCE_LENGTH).tokens
|
||||
)?.score;
|
||||
if (
|
||||
100 * dist <
|
||||
(props.suffixMatchThreshold ?? DEFAULT_SUFFIX_MATCH_THRESHOLD) * firstSuffixTokens.tokens.length
|
||||
) {
|
||||
suffixToUse = cachedSuffix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only set the suffix if it's different from the cached one, otherwise we rerender this component all the time
|
||||
if (suffixToUse !== cachedSuffix) {
|
||||
setCachedSuffix(suffixToUse);
|
||||
}
|
||||
|
||||
return <Text>{suffixToUse}</Text>;
|
||||
}
|
||||
|
||||
export function DocumentPrefix(_props: PromptElementProps, context: ComponentContext) {
|
||||
const [document, setDocument] = context.useState<CompletionRequestDocument>();
|
||||
const [position, setPosition] = context.useState<Position>();
|
||||
const [maxPromptLength, setMaxPromptLength] = context.useState<number>(0);
|
||||
|
||||
context.useData(isCompletionRequestData, request => {
|
||||
const requestDocument = request.document;
|
||||
if (request.document.uri !== document?.uri || requestDocument.getText() !== document?.getText()) {
|
||||
setDocument(requestDocument);
|
||||
}
|
||||
|
||||
if (request.position !== position) {
|
||||
setPosition(request.position);
|
||||
}
|
||||
|
||||
if (request.maxPromptTokens !== maxPromptLength) {
|
||||
setMaxPromptLength(request.maxPromptTokens);
|
||||
}
|
||||
});
|
||||
|
||||
const maxCharacters = approximateMaxCharacters(maxPromptLength);
|
||||
|
||||
return <BeforeCursor document={document} position={position} maxCharacters={maxCharacters} />;
|
||||
}
|
||||
|
||||
export function DocumentSuffix(_props: PromptElementProps, context: ComponentContext) {
|
||||
const [document, setDocument] = context.useState<CompletionRequestDocument>();
|
||||
const [position, setPosition] = context.useState<Position>();
|
||||
const [maxPromptLength, setMaxPromptLength] = context.useState<number>(0);
|
||||
const [suffixMatchThreshold, setSuffixMatchThreshold] = context.useState<number>();
|
||||
const [tokenizer, setTokenizer] = context.useState<TokenizerName>();
|
||||
|
||||
context.useData(isCompletionRequestData, request => {
|
||||
const requestDocument = request.document;
|
||||
if (request.document.uri !== document?.uri || requestDocument.getText() !== document?.getText()) {
|
||||
setDocument(requestDocument);
|
||||
}
|
||||
|
||||
if (request.position !== position) {
|
||||
setPosition(request.position);
|
||||
}
|
||||
|
||||
if (request.suffixMatchThreshold !== suffixMatchThreshold) {
|
||||
setSuffixMatchThreshold(request.suffixMatchThreshold);
|
||||
}
|
||||
|
||||
if (request.maxPromptTokens !== maxPromptLength) {
|
||||
setMaxPromptLength(request.maxPromptTokens);
|
||||
}
|
||||
|
||||
if (request.tokenizer !== tokenizer) {
|
||||
setTokenizer(request.tokenizer);
|
||||
}
|
||||
});
|
||||
const maxCharacters = approximateMaxCharacters(maxPromptLength);
|
||||
return (
|
||||
<AfterCursor
|
||||
document={document}
|
||||
position={position}
|
||||
suffixMatchThreshold={suffixMatchThreshold}
|
||||
maxCharacters={maxCharacters}
|
||||
tokenizer={tokenizer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { DiagnosticSeverity, type Diagnostic } from 'vscode';
|
||||
import { Chunk, ComponentContext, PromptElementProps, Text } from '../../../../prompt/src/components/components';
|
||||
import { normalizeLanguageId } from '../../../../prompt/src/prompt';
|
||||
import type { ICompletionsTextDocumentManagerService } from '../../textDocumentManager';
|
||||
import {
|
||||
CompletionRequestData,
|
||||
isCompletionRequestData,
|
||||
type CompletionRequestDocument,
|
||||
} from '../completionsPromptFactory/componentsCompletionsPromptFactory';
|
||||
import { type DiagnosticBagWithId } from '../contextProviders/contextItemSchemas';
|
||||
|
||||
|
||||
function getCode(diagnostic: Diagnostic): string | undefined {
|
||||
if (diagnostic.code === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof diagnostic.code === 'string') {
|
||||
return diagnostic.code;
|
||||
}
|
||||
if (typeof diagnostic.code === 'number') {
|
||||
return diagnostic.code.toString();
|
||||
}
|
||||
if (typeof diagnostic.code === 'object' && diagnostic.code !== null && diagnostic.code.value) {
|
||||
return diagnostic.code.value.toString();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRelativePath(tdm: ICompletionsTextDocumentManagerService, item: DiagnosticBagWithId): string {
|
||||
return tdm.getRelativePath({ uri: item.uri.toString() }) ?? item.uri.path;
|
||||
}
|
||||
|
||||
type DiagnosticsProps = {
|
||||
tdms: ICompletionsTextDocumentManagerService;
|
||||
} & PromptElementProps;
|
||||
|
||||
|
||||
export const Diagnostics = (props: DiagnosticsProps, context: ComponentContext) => {
|
||||
const [diagnostics, setDiagnostics] = context.useState<DiagnosticBagWithId[]>();
|
||||
const [languageId, setLanguageId] = context.useState<string>();
|
||||
const [position, setPosition] = context.useState<{ line: number; character: number }>();
|
||||
const [document, setDocument] = context.useState<CompletionRequestDocument>();
|
||||
|
||||
|
||||
context.useData(isCompletionRequestData, (data: CompletionRequestData) => {
|
||||
if (data.diagnostics !== diagnostics) {
|
||||
setDiagnostics(data.diagnostics);
|
||||
}
|
||||
|
||||
const normalizedLanguageId = normalizeLanguageId(data.document.detectedLanguageId);
|
||||
if (normalizedLanguageId !== languageId) {
|
||||
setLanguageId(normalizedLanguageId);
|
||||
}
|
||||
|
||||
if (data.position !== position) {
|
||||
setPosition(data.position);
|
||||
}
|
||||
|
||||
if (data.document.uri !== document?.uri) {
|
||||
setDocument(data.document);
|
||||
}
|
||||
});
|
||||
|
||||
if (!diagnostics || diagnostics.length === 0 || !languageId) {
|
||||
return;
|
||||
}
|
||||
const validChunks = diagnostics.filter(diagnostic => diagnostic.values.length > 0);
|
||||
if (validChunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by importance, with the most important first
|
||||
validChunks.sort((a, b) => (b.importance ?? 0) - (a.importance ?? 0));
|
||||
// Reverse the order so the most important snippet is last. Note, that we don't directly
|
||||
// sort in ascending order to handle importance 0 correctly.
|
||||
validChunks.reverse();
|
||||
|
||||
return validChunks.map(diagnosticBag => {
|
||||
const elements = [];
|
||||
elements.push(
|
||||
<Text key={diagnosticBag.id} source={diagnosticBag}>
|
||||
{`Consider the following ${languageId} diagnostics from ${getRelativePath(props.tdms, diagnosticBag)}:`}
|
||||
</Text>
|
||||
);
|
||||
let values: Diagnostic[] = diagnosticBag.values;
|
||||
if (document !== undefined && document.uri.toString() === diagnosticBag.uri.toString() && position !== undefined) {
|
||||
// Create a copy of the diagnostics to avoid mutating the original array in the context item in case it is used elsewhere.
|
||||
values = diagnosticBag.values.slice();
|
||||
values.sort((a, b) => {
|
||||
const aDist = Math.abs(a.range.start.line - position.line);
|
||||
const bDist = Math.abs(b.range.start.line - position.line);
|
||||
return aDist - bDist;
|
||||
});
|
||||
}
|
||||
values.forEach(diagnostic => {
|
||||
let codeStr = '';
|
||||
const code = getCode(diagnostic);
|
||||
if (code !== undefined) {
|
||||
const source = diagnostic.source ? diagnostic.source.toUpperCase() : '';
|
||||
codeStr = ` ${source}${code}`;
|
||||
}
|
||||
const start = diagnostic.range.start;
|
||||
elements.push(
|
||||
<Text>
|
||||
{`${start.line + 1}:${start.character + 1} - ${DiagnosticSeverity[diagnostic.severity].toLowerCase()}${codeStr}: ${diagnostic.message}`}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
return <Chunk>{elements}</Chunk>;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,381 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { PromptSnapshotNodeStatistics } from '../../../../prompt/src/components/components';
|
||||
import { Tokenizer } from '../../../../prompt/src/tokenization';
|
||||
|
||||
export interface WeightedBlock {
|
||||
/**
|
||||
* Paths use a syntax similar to JSON path, but with a few differences:
|
||||
* - Fragments, string and number components are ignored
|
||||
* - The same identifier can exist at the same level, and is represented as an array index ([i])
|
||||
* For example this prompts:
|
||||
* <>
|
||||
* <ComponentA />
|
||||
* <ComponentB />
|
||||
* <ComponentA />
|
||||
* </>
|
||||
* Would have the following paths:
|
||||
* $.ComponentA[0]
|
||||
* $.ComponentB
|
||||
* $.ComponentA[1]
|
||||
*/
|
||||
componentPath: string;
|
||||
type: 'prefix' | 'suffix' | 'context';
|
||||
// The original text value of the block
|
||||
value: string;
|
||||
weight: number;
|
||||
index?: number; // Optional block index, used to group context items
|
||||
nodeStatistics: PromptSnapshotNodeStatistics;
|
||||
chunks?: Set<string>;
|
||||
source?: unknown;
|
||||
}
|
||||
interface ElidableBlock extends WeightedBlock {
|
||||
// The number of tokens of the original value
|
||||
tokens: number;
|
||||
markedForRemoval: boolean;
|
||||
}
|
||||
|
||||
interface LineWithPathAndTokens {
|
||||
line: string;
|
||||
componentPath: string;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
interface PrefixElidableBlock extends ElidableBlock {
|
||||
originalIndex: number;
|
||||
lines: LineWithPathAndTokens[];
|
||||
}
|
||||
|
||||
export interface ElidedBlock extends WeightedBlock {
|
||||
tokens: number;
|
||||
elidedValue: string;
|
||||
elidedTokens: number;
|
||||
}
|
||||
|
||||
interface ElisionStrategy {
|
||||
elide(
|
||||
prefixBlocks: WeightedBlock[],
|
||||
prefixTokenLimit: number,
|
||||
suffixBlock: WeightedBlock,
|
||||
suffixTokenLimit: number,
|
||||
tokenizer: Tokenizer
|
||||
): { blocks: ElidedBlock[]; cycles: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* The wishlist strategy does a two-pass elision, based on prompt/src/wishlist.ts
|
||||
* - Removes blocks (of lines) with the lowest weight, ignoring blocks with a weight of 1.
|
||||
* - Adjust the total token count to fit within the limit line by line, top to bottom.
|
||||
*
|
||||
* Notice the extra `suffix*` arguments in the constructor and elide method.
|
||||
*/
|
||||
export class WishlistElision implements ElisionStrategy {
|
||||
elide(
|
||||
prefixBlocks: WeightedBlock[],
|
||||
prefixTokenLimit: number,
|
||||
suffixBlock: WeightedBlock,
|
||||
suffixTokenLimit: number,
|
||||
tokenizer: Tokenizer
|
||||
) {
|
||||
if (prefixTokenLimit <= 0) {
|
||||
throw new Error('Prefix limit must be greater than 0');
|
||||
}
|
||||
|
||||
const [elidablePrefixBlocks, maxPrefixTokens] = this.preparePrefixBlocks(prefixBlocks, tokenizer);
|
||||
const { elidedSuffix, adjustedPrefixTokenLimit } = this.elideSuffix(
|
||||
suffixBlock,
|
||||
suffixTokenLimit,
|
||||
prefixTokenLimit,
|
||||
maxPrefixTokens,
|
||||
tokenizer
|
||||
);
|
||||
const elidedPrefix = this.elidePrefix(
|
||||
elidablePrefixBlocks,
|
||||
adjustedPrefixTokenLimit,
|
||||
maxPrefixTokens,
|
||||
tokenizer
|
||||
);
|
||||
|
||||
return { blocks: [elidedSuffix, ...elidedPrefix], cycles: 1 };
|
||||
}
|
||||
|
||||
private preparePrefixBlocks(blocks: WeightedBlock[], tokenizer: Tokenizer): [PrefixElidableBlock[], number] {
|
||||
let maxPrefixTokens = 0;
|
||||
// Create a set to keep track of component paths
|
||||
const componentPaths = new Set<string>();
|
||||
|
||||
const elidableBlocks = blocks.map((block, index) => {
|
||||
let blockTokens = 0;
|
||||
// Update the total tokens by approximating the length of a block with the sum
|
||||
// of the lengths of its lines. Lines are split by newlines, and the newline
|
||||
// value is kept together with the line (and hence counted as a token).
|
||||
const blockLines = block.value.split(/([^\n]*\n+)/).filter(l => l !== '');
|
||||
const processedBlockLines = blockLines.map(line => {
|
||||
const tokens = tokenizer.tokenLength(line);
|
||||
blockTokens += tokens;
|
||||
maxPrefixTokens += tokens;
|
||||
return { line, componentPath: block.componentPath, tokens };
|
||||
});
|
||||
// Check if the component path is unique
|
||||
const componentPath = block.componentPath;
|
||||
if (componentPaths.has(componentPath)) {
|
||||
throw new Error(`Duplicate component path in prefix blocks: ${componentPath}`);
|
||||
}
|
||||
componentPaths.add(componentPath);
|
||||
return {
|
||||
...block,
|
||||
tokens: blockTokens,
|
||||
markedForRemoval: false,
|
||||
originalIndex: index,
|
||||
lines: processedBlockLines,
|
||||
};
|
||||
});
|
||||
|
||||
return [elidableBlocks, maxPrefixTokens];
|
||||
}
|
||||
|
||||
/**
|
||||
* Special handling for the suffix, adapted from PromptWishlist.fulfill
|
||||
* Some behaviors are different from the original implementation:
|
||||
* - If the token limit is less than the edit distance, we don't error but just return the first tokens of the new suffix.
|
||||
* - When using the cached suffix, we check and enforce the limit.
|
||||
* - Remaining tokens are returned and handled by the caller, so we don't need to check the prefix nor modify limits in place.
|
||||
*/
|
||||
private elideSuffix(
|
||||
suffixBlock: WeightedBlock,
|
||||
suffixTokenLimit: number,
|
||||
prefixTokenLimit: number,
|
||||
maxPrefixTokens: number,
|
||||
tokenizer: Tokenizer
|
||||
) {
|
||||
const suffix = suffixBlock.value;
|
||||
if (suffix.length === 0 || suffixTokenLimit <= 0) {
|
||||
const elidedSuffix: ElidedBlock = {
|
||||
...suffixBlock,
|
||||
tokens: 0,
|
||||
elidedValue: '',
|
||||
elidedTokens: 0,
|
||||
};
|
||||
return {
|
||||
elidedSuffix,
|
||||
adjustedPrefixTokenLimit: prefixTokenLimit + Math.max(0, suffixTokenLimit),
|
||||
};
|
||||
}
|
||||
|
||||
// Check the maximum (approximate) length of the prefix.
|
||||
// If everything fits, we give the remaining budget to the suffix instead.
|
||||
if (maxPrefixTokens < prefixTokenLimit) {
|
||||
suffixTokenLimit = suffixTokenLimit + (prefixTokenLimit - maxPrefixTokens);
|
||||
prefixTokenLimit = maxPrefixTokens;
|
||||
}
|
||||
|
||||
const shortenedSuffix = tokenizer.takeFirstTokens(suffix, suffixTokenLimit);
|
||||
const elidedSuffix: ElidedBlock = {
|
||||
...suffixBlock,
|
||||
// Update the original value and tokens
|
||||
value: suffix,
|
||||
tokens: tokenizer.tokenLength(suffix),
|
||||
elidedValue: shortenedSuffix.text,
|
||||
elidedTokens: shortenedSuffix.tokens.length,
|
||||
};
|
||||
|
||||
return {
|
||||
elidedSuffix,
|
||||
adjustedPrefixTokenLimit: prefixTokenLimit + Math.max(0, suffixTokenLimit - shortenedSuffix.tokens.length),
|
||||
};
|
||||
}
|
||||
|
||||
private elidePrefix(
|
||||
elidablePrefixBlocks: PrefixElidableBlock[],
|
||||
tokenLimit: number,
|
||||
maxPrefixTokens: number,
|
||||
tokenizer: Tokenizer
|
||||
): ElidedBlock[] {
|
||||
const prefixBlocks = this.removeLowWeightPrefixBlocks(elidablePrefixBlocks, tokenLimit, maxPrefixTokens);
|
||||
|
||||
// The nodes that are not marked for removal are split into lines, but we keep
|
||||
// track of the block they came from
|
||||
const prefixLines = prefixBlocks.filter(block => !block.markedForRemoval).flatMap(block => block.lines);
|
||||
|
||||
if (prefixLines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [trimmedLines, prefixTokens] = this.trimPrefixLinesToFit(prefixLines, tokenLimit, tokenizer);
|
||||
// Populate the final elidable blocks
|
||||
let currentPrefixTokens = prefixTokens;
|
||||
return prefixBlocks.map(block => {
|
||||
if (block.markedForRemoval) {
|
||||
// Try to re-include blocks if there's space left and they are not part of a chunk
|
||||
if (currentPrefixTokens + block.tokens <= tokenLimit && !block.chunks) {
|
||||
// This is an approximation, but we don't want to add more token operations.
|
||||
// In the wishlist, this is done using the priority list, but for simplicity we just
|
||||
// do it in order.
|
||||
currentPrefixTokens += block.tokens;
|
||||
return { ...block, elidedValue: block.value, elidedTokens: block.tokens };
|
||||
}
|
||||
return { ...block, elidedValue: '', elidedTokens: 0 };
|
||||
}
|
||||
|
||||
const elidedValue = trimmedLines
|
||||
.filter(l => l.componentPath === block.componentPath && l.line !== '')
|
||||
.map(l => l.line)
|
||||
.join('');
|
||||
let elidedTokens = block.tokens;
|
||||
if (elidedValue !== block.value) {
|
||||
elidedTokens = elidedValue !== '' ? tokenizer.tokenLength(elidedValue) : 0;
|
||||
}
|
||||
|
||||
return { ...block, elidedValue, elidedTokens };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks blocks for removal based on their weight and the total token limit.
|
||||
* If a block has a chunk identifier, all blocks with the same chunk will be removed together.
|
||||
* Blocks with a weight of 1 are protected from removal.
|
||||
*/
|
||||
private removeLowWeightPrefixBlocks(
|
||||
elidablePrefixBlocks: PrefixElidableBlock[],
|
||||
tokenLimit: number,
|
||||
maxPrefixTokens: number
|
||||
): PrefixElidableBlock[] {
|
||||
let totalPrefixTokens = maxPrefixTokens;
|
||||
|
||||
// Sort the blocks by weight ascending
|
||||
elidablePrefixBlocks.sort((a, b) => a.weight - b.weight);
|
||||
// Remove blocks with the lowest weight until total tokens are within the limit
|
||||
// If a block has a weight of 1, it is skipped in this step
|
||||
for (const block of elidablePrefixBlocks) {
|
||||
if (totalPrefixTokens <= tokenLimit) { break; }
|
||||
if (block.weight === 1) { continue; }
|
||||
|
||||
// If block has a chunk that's already been processed, skip it
|
||||
if (block.chunks && block.markedForRemoval) { continue; }
|
||||
|
||||
if (block.chunks && block.chunks.size > 0) {
|
||||
// Mark all blocks with the same chunk for removal
|
||||
for (const relatedBlock of elidablePrefixBlocks) {
|
||||
if (
|
||||
!relatedBlock.markedForRemoval &&
|
||||
relatedBlock.chunks &&
|
||||
// For nested chunks: if removing outer chunk, remove all inner chunks
|
||||
// by checking if the related block contains ALL chunk IDs from current block
|
||||
[...block.chunks].every(id => relatedBlock.chunks?.has(id))
|
||||
) {
|
||||
relatedBlock.markedForRemoval = true;
|
||||
totalPrefixTokens -= relatedBlock.tokens;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular case: just mark this block for removal
|
||||
block.markedForRemoval = true;
|
||||
totalPrefixTokens -= block.tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the nodes by their original index
|
||||
return elidablePrefixBlocks.sort((a, b) => a.originalIndex - b.originalIndex);
|
||||
}
|
||||
|
||||
private trimPrefixLinesToFit(
|
||||
linesWithComponentPath: LineWithPathAndTokens[],
|
||||
tokenLimit: number,
|
||||
tokenizer: Tokenizer
|
||||
): [LineWithPathAndTokens[], number] {
|
||||
let currentPrefixTokens = 0;
|
||||
|
||||
// Create a new array to store lines that fit within the limit
|
||||
const fittingLines: typeof linesWithComponentPath = [];
|
||||
|
||||
// Iterate from the end of the array
|
||||
for (let i = linesWithComponentPath.length - 1; i >= 0; i--) {
|
||||
const currentLine = linesWithComponentPath[i];
|
||||
const lineTokens = currentLine.tokens;
|
||||
|
||||
// Check if adding this line would exceed the limit
|
||||
if (currentPrefixTokens + lineTokens <= tokenLimit) {
|
||||
fittingLines.unshift(currentLine); // Add to front to maintain order
|
||||
currentPrefixTokens += lineTokens;
|
||||
} else {
|
||||
break; // Stop once we exceed the limit
|
||||
}
|
||||
}
|
||||
|
||||
if (fittingLines.length === 0) {
|
||||
// This can still mean that the last line (the cursor line) is too long.
|
||||
// So we try to fit the last line up to the limit.
|
||||
const lastLine = linesWithComponentPath[linesWithComponentPath.length - 1];
|
||||
if (lastLine && lastLine.line.length > 0) {
|
||||
const prompt = tokenizer.takeLastTokens(lastLine.line, tokenLimit);
|
||||
fittingLines.push({
|
||||
line: prompt.text,
|
||||
componentPath: lastLine.componentPath,
|
||||
tokens: prompt.tokens.length,
|
||||
});
|
||||
return [fittingLines, prompt.tokens.length];
|
||||
}
|
||||
|
||||
const errorMsg = `Cannot fit prefix within limit of ${tokenLimit} tokens`;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
return [fittingLines, currentPrefixTokens];
|
||||
}
|
||||
}
|
||||
|
||||
export function makePrompt(elidedBlocks: ElidedBlock[]): string {
|
||||
return elidedBlocks.map(block => block.elidedValue).join('');
|
||||
}
|
||||
|
||||
export function makePrefixPrompt(elidedBlocks: ElidedBlock[]): string {
|
||||
return elidedBlocks
|
||||
.filter(b => b.type === 'prefix')
|
||||
.map(block => block.elidedValue)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return context items grouped in blocks reflecting the prompt structure.
|
||||
*/
|
||||
export function makeContextPrompt(elidedBlocks: ElidedBlock[]): string[] {
|
||||
if (elidedBlocks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group context items by index
|
||||
const contextGroups = new Map<number, string[]>();
|
||||
for (const block of elidedBlocks) {
|
||||
// Only consider context blocks with an index
|
||||
if (block.type === 'context' && block.index !== undefined) {
|
||||
// Initialize the group
|
||||
if (!contextGroups.has(block.index)) {
|
||||
contextGroups.set(block.index, []);
|
||||
}
|
||||
// Add the trimmed value
|
||||
const trimmed = block.elidedValue.trim();
|
||||
if (trimmed.length > 0) {
|
||||
contextGroups.get(block.index)!.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxIndex = Math.max(...Array.from(contextGroups.keys()), -1);
|
||||
|
||||
// Create context blocks
|
||||
const contextBlocks = [];
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
const group = contextGroups.get(i);
|
||||
if (group && group.length > 0) {
|
||||
const value = group.join('\n').trim();
|
||||
contextBlocks.push(value);
|
||||
} else {
|
||||
// If there are no items for this index, add an empty string to maintain ordering
|
||||
contextBlocks.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return contextBlocks;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { ComponentContext, PromptElementProps, Text } from '../../../../prompt/src/components/components';
|
||||
import { getLanguageMarker, getPathMarker } from '../../../../prompt/src/languageMarker';
|
||||
import { DocumentInfo } from '../../../../prompt/src/prompt';
|
||||
import { ICompletionsTextDocumentManagerService } from '../../textDocumentManager';
|
||||
import {
|
||||
CompletionRequestDocument,
|
||||
isCompletionRequestData,
|
||||
} from '../completionsPromptFactory/componentsCompletionsPromptFactory';
|
||||
|
||||
type DocumentMarkerProps = {
|
||||
tdms: ICompletionsTextDocumentManagerService;
|
||||
} & PromptElementProps;
|
||||
|
||||
export const DocumentMarker = (props: DocumentMarkerProps, context: ComponentContext) => {
|
||||
const [document, setDocument] = context.useState<CompletionRequestDocument>();
|
||||
|
||||
context.useData(isCompletionRequestData, request => {
|
||||
if (request.document.uri !== document?.uri) {
|
||||
setDocument(request.document);
|
||||
}
|
||||
});
|
||||
|
||||
if (document) {
|
||||
const relativePath = props.tdms.getRelativePath(document);
|
||||
const docInfo: DocumentInfo = {
|
||||
uri: document.uri,
|
||||
source: document.getText(),
|
||||
relativePath,
|
||||
languageId: document.detectedLanguageId,
|
||||
};
|
||||
const notebook = props.tdms.findNotebook(document);
|
||||
if (docInfo.relativePath && !notebook) {
|
||||
return <PathMarker docInfo={docInfo} />;
|
||||
}
|
||||
return <LanguageMarker docInfo={docInfo} />;
|
||||
}
|
||||
};
|
||||
|
||||
const PathMarker = (props: { docInfo: DocumentInfo }) => {
|
||||
return <Text>{getPathMarker(props.docInfo)}</Text>;
|
||||
};
|
||||
|
||||
const LanguageMarker = (props: { docInfo: DocumentInfo }) => {
|
||||
return <Text>{getLanguageMarker(props.docInfo)}</Text>;
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { Chunk, ComponentContext, PromptElementProps, Text } from '../../../../prompt/src/components/components';
|
||||
import { newLineEnded } from '../../../../prompt/src/languageMarker';
|
||||
import { ICompletionsTextDocumentManagerService } from '../../textDocumentManager';
|
||||
import {
|
||||
CompletionRequestData,
|
||||
isCompletionRequestData,
|
||||
} from '../completionsPromptFactory/componentsCompletionsPromptFactory';
|
||||
import { FullRecentEditsProvider, ICompletionsRecentEditsProviderService } from '../recentEdits/recentEditsProvider';
|
||||
import { RecentEdit } from '../recentEdits/recentEditsReducer';
|
||||
|
||||
export function editIsTooCloseToCursor(
|
||||
edit: RecentEdit,
|
||||
filterByCursorLine: boolean = false,
|
||||
cursorLine: number | undefined = undefined,
|
||||
activeDocDistanceLimitFromCursor: number | undefined
|
||||
): boolean {
|
||||
if (filterByCursorLine) {
|
||||
if (cursorLine === undefined || activeDocDistanceLimitFromCursor === undefined) {
|
||||
throw new Error(
|
||||
'cursorLine and activeDocDistanceLimitFromCursor are required when filterByCursorLine is true'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const startLineNumber = edit.startLine - 1;
|
||||
const endLineNumber = edit.endLine - 1;
|
||||
|
||||
if (
|
||||
filterByCursorLine &&
|
||||
(Math.abs(startLineNumber - cursorLine!) <= activeDocDistanceLimitFromCursor! ||
|
||||
Math.abs(endLineNumber - cursorLine!) <= activeDocDistanceLimitFromCursor!)
|
||||
) {
|
||||
// skip over a diff that's too close to the cursor
|
||||
// this isn't cached since the cursor moves
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type RecentEditsProps = {
|
||||
tdms: ICompletionsTextDocumentManagerService;
|
||||
recentEditsProvider: ICompletionsRecentEditsProviderService;
|
||||
} & PromptElementProps;
|
||||
|
||||
/**
|
||||
* Render the most recent edits in the prompt.
|
||||
* @param props
|
||||
* @param context
|
||||
* @returns a <Text> element containing recent edit summaries, or undefined if there are no recent edits
|
||||
*/
|
||||
export const RecentEdits = (props: RecentEditsProps, context: ComponentContext) => {
|
||||
const [prompt, setPrompt] = context.useState<string | undefined>();
|
||||
|
||||
context.useData(isCompletionRequestData, async (request: CompletionRequestData) => {
|
||||
if (!request.document) { return; }
|
||||
|
||||
const recentEditProvider = props.recentEditsProvider;
|
||||
|
||||
if (recentEditProvider.isEnabled()) {
|
||||
recentEditProvider.start();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const recentEditsConfig = (recentEditProvider as FullRecentEditsProvider).config;
|
||||
const recentEdits = recentEditProvider.getRecentEdits();
|
||||
|
||||
const filesIncluded = new Set<string>();
|
||||
const tdm = props.tdms;
|
||||
const editSummaries: string[] = [];
|
||||
|
||||
// Walk backwards through the recent edits (most recent first) until we hit the max files or max edits, whichever comes first
|
||||
for (let i = recentEdits.length - 1; i >= 0; i--) {
|
||||
// if we've hit the max edits, stop
|
||||
if (editSummaries.length >= recentEditsConfig.maxEdits) { break; }
|
||||
|
||||
const edit = recentEdits[i];
|
||||
|
||||
// If the file is excluded, skip it
|
||||
if (!(await tdm.getTextDocument({ uri: edit.file }))) { continue; }
|
||||
|
||||
// If adding an edit from this file would exceed the max files, skip it
|
||||
const isNewFile = !filesIncluded.has(edit.file);
|
||||
const projectedFileCount = filesIncluded.size + (isNewFile ? 1 : 0);
|
||||
if (projectedFileCount > recentEditsConfig.maxFiles) { break; }
|
||||
|
||||
const filterByCursorLine = edit.file === request.document?.uri;
|
||||
const activeDocCursorLine = filterByCursorLine ? request.position.line : undefined;
|
||||
|
||||
// Check if the edit is too close to the cursor line, if applicable, in which case we skip it
|
||||
const editTooClose = editIsTooCloseToCursor(
|
||||
edit,
|
||||
filterByCursorLine,
|
||||
activeDocCursorLine,
|
||||
recentEditsConfig.activeDocDistanceLimitFromCursor
|
||||
);
|
||||
if (editTooClose) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const summarizedEdit = recentEditProvider.getEditSummary(edit);
|
||||
if (summarizedEdit) {
|
||||
filesIncluded.add(edit.file);
|
||||
const relativePathOrUri = tdm.getRelativePath({ uri: edit.file });
|
||||
editSummaries.unshift(newLineEnded(`File: ${relativePathOrUri}`) + newLineEnded(summarizedEdit));
|
||||
}
|
||||
}
|
||||
|
||||
if (editSummaries.length === 0) {
|
||||
setPrompt(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const newPrompt =
|
||||
newLineEnded('These are recently edited files. Do not suggest code that has been deleted.') +
|
||||
editSummaries.join('') +
|
||||
newLineEnded('End of recent edits');
|
||||
|
||||
setPrompt(newPrompt);
|
||||
});
|
||||
|
||||
return prompt ? (
|
||||
<Chunk>
|
||||
<Text>{prompt}</Text>
|
||||
</Chunk>
|
||||
) : undefined;
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { IInstantiationService } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { Chunk, ComponentContext, PromptElementProps, Text } from '../../../../prompt/src/components/components';
|
||||
import { DocumentInfoWithOffset, PromptOptions } from '../../../../prompt/src/prompt';
|
||||
import { getSimilarSnippets } from '../../../../prompt/src/snippetInclusion/similarFiles';
|
||||
import { announceSnippet } from '../../../../prompt/src/snippetInclusion/snippets';
|
||||
import { getSimilarFilesOptions } from '../../experiments/similarFileOptionsProvider';
|
||||
import { TelemetryWithExp } from '../../telemetry';
|
||||
import { TextDocumentContents } from '../../textDocument';
|
||||
import { ICompletionsTextDocumentManagerService } from '../../textDocumentManager';
|
||||
import {
|
||||
CompletionRequestData,
|
||||
CompletionRequestDocument,
|
||||
isCompletionRequestData,
|
||||
} from '../completionsPromptFactory/componentsCompletionsPromptFactory';
|
||||
import { getPromptOptions } from '../prompt';
|
||||
import { NeighborsCollection, NeighborSource } from '../similarFiles/neighborFiles';
|
||||
|
||||
type SimilarFilesProps = {
|
||||
instantiationService: IInstantiationService;
|
||||
tdms: ICompletionsTextDocumentManagerService;
|
||||
} & PromptElementProps;
|
||||
|
||||
type SimilarFileSnippet = {
|
||||
headline: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export const SimilarFiles = (props: SimilarFilesProps, context: ComponentContext) => {
|
||||
const [document, setDocument] = context.useState<CompletionRequestDocument>();
|
||||
const [similarFiles, setSimilarFiles] = context.useState<SimilarFileSnippet[]>([]);
|
||||
|
||||
context.useData(isCompletionRequestData, async (requestData: CompletionRequestData) => {
|
||||
if (requestData.document.uri !== document?.uri) {
|
||||
setSimilarFiles([]);
|
||||
}
|
||||
setDocument(requestData.document);
|
||||
|
||||
let files: { docs: NeighborsCollection } = NeighborSource.defaultEmptyResult();
|
||||
if (!requestData.turnOffSimilarFiles) {
|
||||
files = await props.instantiationService.invokeFunction(async acc => await NeighborSource.getNeighborFilesAndTraits(
|
||||
acc,
|
||||
requestData.document.uri,
|
||||
requestData.document.detectedLanguageId,
|
||||
requestData.telemetryData,
|
||||
requestData.cancellationToken,
|
||||
requestData.data
|
||||
));
|
||||
}
|
||||
|
||||
const similarFiles = await produceSimilarFiles(
|
||||
requestData.telemetryData,
|
||||
requestData.document,
|
||||
requestData,
|
||||
files
|
||||
);
|
||||
setSimilarFiles(similarFiles);
|
||||
});
|
||||
|
||||
async function produceSimilarFiles(
|
||||
telemetryData: TelemetryWithExp,
|
||||
doc: TextDocumentContents,
|
||||
requestData: CompletionRequestData,
|
||||
files: {
|
||||
docs: NeighborsCollection;
|
||||
}
|
||||
): Promise<SimilarFileSnippet[]> {
|
||||
const promptOptions = props.instantiationService.invokeFunction(getPromptOptions, telemetryData, doc.detectedLanguageId);
|
||||
const similarSnippets = await findSimilarSnippets(promptOptions, telemetryData, doc, requestData, files);
|
||||
return similarSnippets
|
||||
.filter(s => s.snippet.length > 0)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.map(s => {
|
||||
return { ...announceSnippet(s), score: s.score };
|
||||
});
|
||||
}
|
||||
|
||||
async function findSimilarSnippets(
|
||||
promptOptions: PromptOptions,
|
||||
telemetryData: TelemetryWithExp,
|
||||
doc: TextDocumentContents,
|
||||
requestData: CompletionRequestData,
|
||||
files: { docs: NeighborsCollection }
|
||||
) {
|
||||
const similarFilesOptions =
|
||||
promptOptions.similarFilesOptions ||
|
||||
props.instantiationService.invokeFunction(getSimilarFilesOptions, telemetryData, doc.detectedLanguageId);
|
||||
const tdm = props.tdms;
|
||||
const relativePath = tdm.getRelativePath(doc);
|
||||
const docInfo: DocumentInfoWithOffset = {
|
||||
uri: doc.uri,
|
||||
source: doc.getText(),
|
||||
offset: doc.offsetAt(requestData.position),
|
||||
relativePath,
|
||||
languageId: doc.detectedLanguageId,
|
||||
};
|
||||
return await getSimilarSnippets(docInfo, Array.from(files.docs.values()), similarFilesOptions);
|
||||
}
|
||||
|
||||
return <>{...similarFiles.map((file, index) => <SimilarFile snippet={file} />)}</>;
|
||||
};
|
||||
|
||||
// TODO: change Chunk for KeepTogether
|
||||
const SimilarFile = (props: { snippet: SimilarFileSnippet }, context: ComponentContext) => {
|
||||
return (
|
||||
<Chunk>
|
||||
<Text>{props.snippet.headline}</Text>
|
||||
<Text>{props.snippet.snippet}</Text>
|
||||
</Chunk>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ICompletionsTextDocumentManagerService } from '../../textDocumentManager';
|
||||
import { ICompletionsRecentEditsProviderService } from '../recentEdits/recentEditsProvider';
|
||||
import { CodeSnippets } from './codeSnippets';
|
||||
import { AdditionalCompletionsContext, StableCompletionsContext } from './completionsContext';
|
||||
import { DocumentPrefix, DocumentSuffix } from './currentFile';
|
||||
import { Diagnostics } from './diagnostics';
|
||||
import { DocumentMarker } from './marker';
|
||||
import { RecentEdits } from './recentEdits';
|
||||
import { SimilarFiles } from './similarFiles';
|
||||
import { Traits } from './traits';
|
||||
|
||||
/**
|
||||
* Function that returns the prompt structure for a code completion request following the split context prompt design
|
||||
* that optimizes for cache hits.
|
||||
*/
|
||||
export function splitContextCompletionsPrompt(accessor: ServicesAccessor) {
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const tdms = accessor.get(ICompletionsTextDocumentManagerService);
|
||||
const recentEditsProvider = accessor.get(ICompletionsRecentEditsProviderService);
|
||||
return (
|
||||
<>
|
||||
<StableCompletionsContext>
|
||||
<DocumentMarker tdms={tdms} weight={0.7} />
|
||||
<Traits weight={0.6} />
|
||||
<Diagnostics tdms={tdms} weight={0.65} />
|
||||
<CodeSnippets tdms={tdms} weight={0.9} />
|
||||
<SimilarFiles tdms={tdms} instantiationService={instantiationService} weight={0.8} />
|
||||
</StableCompletionsContext>
|
||||
<DocumentSuffix weight={1} />
|
||||
<AdditionalCompletionsContext>
|
||||
<RecentEdits tdms={tdms} recentEditsProvider={recentEditsProvider} weight={0.99} />
|
||||
</AdditionalCompletionsContext>
|
||||
<DocumentPrefix weight={1} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { ComponentStatistics, PromptSnapshotNode } from '../../../../prompt/src/components/components';
|
||||
import { SnapshotWalker, WalkContextTransformer } from '../../../../prompt/src/components/walker';
|
||||
import { isContextNode } from './completionsContext';
|
||||
import {
|
||||
CompletionsPromptRenderer,
|
||||
normalizeLineEndings,
|
||||
transformers,
|
||||
} from './completionsPromptRenderer';
|
||||
import { BeforeCursor } from './currentFile';
|
||||
import { ElidedBlock, makeContextPrompt, makePrefixPrompt, WeightedBlock } from './elision';
|
||||
|
||||
let contextIndex = 0;
|
||||
function resetContextIndex() {
|
||||
contextIndex = 0;
|
||||
}
|
||||
|
||||
function getNextContextIndex() {
|
||||
return contextIndex++;
|
||||
}
|
||||
|
||||
export class SplitContextPromptRenderer extends CompletionsPromptRenderer {
|
||||
protected override formatPrefix: (elidedBlocks: ElidedBlock[]) => string = makePrefixPrompt;
|
||||
protected override formatContext: ((elidedBlocks: ElidedBlock[]) => string[]) | undefined = makeContextPrompt;
|
||||
|
||||
override processSnapshot(
|
||||
snapshot: PromptSnapshotNode,
|
||||
delimiter: string
|
||||
): {
|
||||
prefixBlocks: WeightedBlock[];
|
||||
suffixBlock: WeightedBlock;
|
||||
componentStatistics: ComponentStatistics[];
|
||||
} {
|
||||
const prefixBlocks: WeightedBlock[] = [];
|
||||
const suffixBlocks: WeightedBlock[] = [];
|
||||
const componentStatistics: ComponentStatistics[] = [];
|
||||
|
||||
// Store the status of the required prefix node
|
||||
let foundPrefix = false;
|
||||
|
||||
resetContextIndex();
|
||||
const walker = new SnapshotWalker(snapshot, splitContextTransformers);
|
||||
walker.walkSnapshot((node, _parent, context) => {
|
||||
if (node === snapshot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.statistics.updateDataTimeMs && node.statistics.updateDataTimeMs > 0) {
|
||||
componentStatistics.push({
|
||||
componentPath: node.path,
|
||||
updateDataTimeMs: node.statistics.updateDataTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for the presence of required prefix node
|
||||
if (node.name === BeforeCursor.name) {
|
||||
foundPrefix = true;
|
||||
}
|
||||
|
||||
if (node.value === undefined || node.value === '') {
|
||||
// No need to process this node as it only adds whitespace
|
||||
return true;
|
||||
}
|
||||
|
||||
const chunks = context.chunks as Set<string> | undefined;
|
||||
const type = context.type as string | undefined;
|
||||
if (type === 'suffix') {
|
||||
// Suffix handling: Mark the child node with content as suffix
|
||||
suffixBlocks.push({
|
||||
value: normalizeLineEndings(node.value),
|
||||
type: 'suffix',
|
||||
weight: context.weight as number,
|
||||
componentPath: node.path,
|
||||
nodeStatistics: node.statistics,
|
||||
chunks,
|
||||
source: context.source,
|
||||
});
|
||||
} else {
|
||||
const isPrefix = type === 'prefix';
|
||||
|
||||
// Add delimiter to non-prefix nodes
|
||||
const nodeValueWithDelimiter =
|
||||
isPrefix || node.value.endsWith(delimiter) ? node.value : node.value + delimiter;
|
||||
prefixBlocks.push({
|
||||
type: isPrefix ? 'prefix' : 'context',
|
||||
value: normalizeLineEndings(nodeValueWithDelimiter),
|
||||
weight: context.weight as number,
|
||||
componentPath: node.path,
|
||||
nodeStatistics: node.statistics,
|
||||
chunks,
|
||||
source: context.source,
|
||||
index: isPrefix ? undefined : (context.index as number), // index only set for context nodes
|
||||
});
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!foundPrefix) {
|
||||
throw new Error(`Node of type ${BeforeCursor.name} not found`);
|
||||
}
|
||||
if (suffixBlocks.length > 1) {
|
||||
throw new Error(`Only one suffix is allowed`);
|
||||
}
|
||||
|
||||
const suffixBlock: WeightedBlock =
|
||||
suffixBlocks.length === 1
|
||||
? suffixBlocks[0]
|
||||
: {
|
||||
componentPath: '',
|
||||
value: '',
|
||||
weight: 1,
|
||||
nodeStatistics: {},
|
||||
type: 'suffix',
|
||||
};
|
||||
|
||||
return { prefixBlocks, suffixBlock, componentStatistics };
|
||||
}
|
||||
}
|
||||
|
||||
const splitContextTransformers: WalkContextTransformer[] = [
|
||||
...transformers,
|
||||
(node, _, context) => {
|
||||
if (isContextNode(node)) {
|
||||
return { ...context, index: getNextContextIndex() };
|
||||
}
|
||||
return context;
|
||||
},
|
||||
];
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import { CompletionRequestData } from '../../completionsPromptFactory/componentsCompletionsPromptFactory';
|
||||
import { CodeSnippetWithId } from '../../contextProviders/contextItemSchemas';
|
||||
import { CodeSnippets } from '../codeSnippets';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import dedent from 'ts-dedent';
|
||||
import { CancellationTokenSource } from 'vscode-languageserver-protocol';
|
||||
import { ServicesAccessor } from '../../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { PromptSnapshotNode } from '../../../../../prompt/src/components/components';
|
||||
import { VirtualPrompt } from '../../../../../prompt/src/components/virtualPrompt';
|
||||
import { extractNodesWitPath } from '../../../../../prompt/src/test/components/testHelpers';
|
||||
import { TelemetryWithExp } from '../../../telemetry';
|
||||
import { createLibTestingContext } from '../../../test/context';
|
||||
import { querySnapshot } from '../../../test/snapshot';
|
||||
import { createTextDocument, TestTextDocumentManager } from '../../../test/textDocument';
|
||||
import { ICompletionsTextDocumentManagerService } from '../../../textDocumentManager';
|
||||
|
||||
suite('Code Snippets Component', function () {
|
||||
let accessor: ServicesAccessor;
|
||||
|
||||
setup(function () {
|
||||
accessor = createLibTestingContext().createTestingAccessor();
|
||||
});
|
||||
|
||||
test('Renders nothing if there are no code snippets', async function () {
|
||||
try {
|
||||
const snapshot = await renderCodeSnippets(accessor);
|
||||
querySnapshot(snapshot.snapshot!, 'CodeSnippets');
|
||||
} catch (e) {
|
||||
assert.ok((e as Error).message.startsWith('No children found at path segment '));
|
||||
}
|
||||
});
|
||||
|
||||
test('Renders nothing if the code snippets array is empty', async function () {
|
||||
try {
|
||||
const snapshot = await renderCodeSnippets(accessor, []);
|
||||
querySnapshot(snapshot.snapshot!, 'CodeSnippets');
|
||||
} catch (e) {
|
||||
assert.ok((e as Error).message.startsWith('No children found at path segment '));
|
||||
}
|
||||
});
|
||||
|
||||
test('Renders a single code snippet', async function () {
|
||||
const codeSnippets: CodeSnippetWithId[] = [
|
||||
{
|
||||
uri: 'file:///path/something.ts',
|
||||
value: dedent`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
id: '1',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
];
|
||||
|
||||
const snapshot = await renderCodeSnippets(accessor, codeSnippets);
|
||||
|
||||
const chunks = querySnapshot(snapshot.snapshot!, 'CodeSnippets[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(chunks.length, 1);
|
||||
const chunk = querySnapshot(snapshot.snapshot!, 'CodeSnippets[0].Chunk[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(chunk.length, 2);
|
||||
assert.deepStrictEqual(chunk[1].props?.key, '1');
|
||||
assert.deepStrictEqual(chunk[1].props?.source, codeSnippets[0]);
|
||||
// Assert content
|
||||
assert.deepStrictEqual(
|
||||
querySnapshot(snapshot.snapshot!, 'CodeSnippets[0].Chunk[0].Text'),
|
||||
'Compare this snippet from something.ts:'
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
querySnapshot(snapshot.snapshot!, 'CodeSnippets[0].Chunk["1"].Text'),
|
||||
'function foo() {\n\treturn 1;\n}'
|
||||
);
|
||||
});
|
||||
|
||||
test('Renders snippet from subfolder', async function () {
|
||||
const codeSnippets: CodeSnippetWithId[] = [
|
||||
{
|
||||
uri: 'file:///c%3A/root/same.ts',
|
||||
value: dedent`
|
||||
function bar() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
id: '1',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
{
|
||||
uri: 'file:///c%3A/root/subfolder/something.ts',
|
||||
value: dedent`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
id: '2',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
];
|
||||
|
||||
const tdm = accessor.get(ICompletionsTextDocumentManagerService) as TestTextDocumentManager;
|
||||
tdm.init([{ uri: 'file:///c:/root' }]);
|
||||
|
||||
const snapshot = await renderCodeSnippets(accessor, codeSnippets);
|
||||
const chunks = querySnapshot(snapshot.snapshot!, 'CodeSnippets[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(chunks.length, 2);
|
||||
|
||||
const firstChunk = querySnapshot(snapshot.snapshot!, 'CodeSnippets[0].Chunk[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(firstChunk.length, 2);
|
||||
assert.deepStrictEqual(firstChunk[0].children?.[0].value, 'Compare this snippet from subfolder/something.ts:');
|
||||
assert.deepStrictEqual(firstChunk[1].props?.key, '2');
|
||||
assert.deepStrictEqual(firstChunk[1].props?.source, codeSnippets[1]);
|
||||
|
||||
const secondChunk = querySnapshot(snapshot.snapshot!, 'CodeSnippets[1].Chunk[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(secondChunk.length, 2);
|
||||
assert.deepStrictEqual(secondChunk[0].children?.[0].value, 'Compare this snippet from same.ts:');
|
||||
assert.deepStrictEqual(secondChunk[1].props?.key, '1');
|
||||
assert.deepStrictEqual(secondChunk[1].props?.source, codeSnippets[0]);
|
||||
});
|
||||
|
||||
test('Renders multiple code snippets', async function () {
|
||||
const codeSnippets: CodeSnippetWithId[] = [
|
||||
{
|
||||
uri: 'file:///something.ts',
|
||||
value: dedent`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
id: '1',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
{
|
||||
uri: 'file:///somethingElse.ts',
|
||||
value: dedent`
|
||||
function bar() {
|
||||
return 'two';
|
||||
}
|
||||
`,
|
||||
id: '2',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
];
|
||||
|
||||
const snapshot = await renderCodeSnippets(accessor, codeSnippets);
|
||||
|
||||
const snippets = querySnapshot(snapshot.snapshot!, 'CodeSnippets[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(snippets.length, 2);
|
||||
|
||||
const firstChunk = querySnapshot(snapshot.snapshot!, 'CodeSnippets[0].Chunk[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(firstChunk[0].children?.[0].value, 'Compare this snippet from somethingElse.ts:');
|
||||
assert.deepStrictEqual(firstChunk[1].props?.key, '2');
|
||||
assert.deepStrictEqual(firstChunk[1].props?.source, codeSnippets[1]);
|
||||
|
||||
const secondChunk = querySnapshot(snapshot.snapshot!, 'CodeSnippets[1].Chunk[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(secondChunk[0].children?.[0].value, 'Compare this snippet from something.ts:');
|
||||
assert.deepStrictEqual(secondChunk[1].props?.key, '1');
|
||||
assert.deepStrictEqual(secondChunk[1].props?.source, codeSnippets[0]);
|
||||
});
|
||||
|
||||
test('Merges together snippets with the same URI', async function () {
|
||||
const codeSnippets: CodeSnippetWithId[] = [
|
||||
{
|
||||
uri: 'file:///something.ts',
|
||||
value: dedent`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
id: '1',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
{
|
||||
uri: 'file:///something.ts',
|
||||
value: dedent`
|
||||
function bar() {
|
||||
return 'two';
|
||||
}
|
||||
`,
|
||||
id: '2',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
];
|
||||
|
||||
const snapshot = await renderCodeSnippets(accessor, codeSnippets);
|
||||
const result = querySnapshot(snapshot.snapshot!, 'CodeSnippets[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(result.length, 1);
|
||||
|
||||
const chunk = querySnapshot(snapshot.snapshot!, 'CodeSnippets[0].Chunk[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(chunk.length, 4);
|
||||
assert.deepStrictEqual(chunk[0].children?.[0].value, 'Compare these snippets from something.ts:');
|
||||
assert.deepStrictEqual(chunk[1].props?.key, '1');
|
||||
assert.deepStrictEqual(chunk[1].props?.source, codeSnippets[0]);
|
||||
assert.deepStrictEqual(chunk[2].children?.[0].value, '---');
|
||||
assert.deepStrictEqual(chunk[3].props?.key, '2');
|
||||
assert.deepStrictEqual(chunk[3].props?.source, codeSnippets[1]);
|
||||
});
|
||||
|
||||
test('Sorts snippets by ascending score of importance', async function () {
|
||||
const codeSnippets: CodeSnippetWithId[] = [
|
||||
{
|
||||
uri: 'file:///something.ts',
|
||||
value: dedent`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
importance: 10,
|
||||
id: '1',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
{
|
||||
uri: 'file:///something.ts',
|
||||
value: dedent`
|
||||
function bar() {
|
||||
return 'two';
|
||||
}
|
||||
`,
|
||||
importance: 5,
|
||||
id: '2',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
{
|
||||
uri: 'file:///somethingElse.ts',
|
||||
value: dedent`
|
||||
function baz() {
|
||||
return 'three';
|
||||
}
|
||||
`,
|
||||
importance: 7,
|
||||
id: '3',
|
||||
type: 'CodeSnippet',
|
||||
},
|
||||
];
|
||||
|
||||
const snapshot = await renderCodeSnippets(accessor, codeSnippets);
|
||||
|
||||
const result = querySnapshot(snapshot.snapshot!, 'CodeSnippets[*]') as PromptSnapshotNode[];
|
||||
assert.deepStrictEqual(result.length, 2);
|
||||
|
||||
assert.deepStrictEqual(extractNodesWitPath(snapshot.snapshot!), [
|
||||
'$[0].CodeSnippets',
|
||||
'$[0].CodeSnippets[0].Chunk',
|
||||
'$[0].CodeSnippets[0].Chunk[0].Text',
|
||||
'$[0].CodeSnippets[0].Chunk[0].Text[0]',
|
||||
'$[0].CodeSnippets[0].Chunk["3"].Text',
|
||||
'$[0].CodeSnippets[0].Chunk["3"].Text[0]',
|
||||
'$[0].CodeSnippets[1].Chunk',
|
||||
'$[0].CodeSnippets[1].Chunk[0].Text',
|
||||
'$[0].CodeSnippets[1].Chunk[0].Text[0]',
|
||||
'$[0].CodeSnippets[1].Chunk["1"].Text',
|
||||
'$[0].CodeSnippets[1].Chunk["1"].Text[0]',
|
||||
'$[0].CodeSnippets[1].Chunk[2].Text',
|
||||
'$[0].CodeSnippets[1].Chunk[2].Text[0]',
|
||||
'$[0].CodeSnippets[1].Chunk["2"].Text',
|
||||
'$[0].CodeSnippets[1].Chunk["2"].Text[0]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
async function renderCodeSnippets(accessor: ServicesAccessor, codeSnippets?: CodeSnippetWithId[]) {
|
||||
const document = createTextDocument(
|
||||
'file:///path/foo.ts',
|
||||
'typescript',
|
||||
0,
|
||||
dedent`
|
||||
const a = 1;
|
||||
function f|
|
||||
const b = 2;
|
||||
`
|
||||
);
|
||||
const position = document.positionAt(document.getText().indexOf('|'));
|
||||
|
||||
const tdms = accessor.get(ICompletionsTextDocumentManagerService);
|
||||
const virtualPrompt = new VirtualPrompt(<CodeSnippets tdms={tdms} />);
|
||||
const pipe = virtualPrompt.createPipe();
|
||||
|
||||
const completionRequestData: CompletionRequestData = {
|
||||
document,
|
||||
position,
|
||||
telemetryData: TelemetryWithExp.createEmptyConfigForTesting(),
|
||||
cancellationToken: new CancellationTokenSource().token,
|
||||
maxPromptTokens: 1000,
|
||||
data: undefined,
|
||||
codeSnippets,
|
||||
};
|
||||
|
||||
await pipe.pump(completionRequestData);
|
||||
return virtualPrompt.snapshot();
|
||||
}
|
||||
+994
@@ -0,0 +1,994 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
/** @jsxRuntime automatic */
|
||||
/** @jsxImportSource ../../../../../prompt/jsx-runtime/ */
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { CancellationTokenSource, Position } from 'vscode-languageserver-protocol';
|
||||
import { ServicesAccessor } from '../../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { Chunk, PromptElementProps, PromptSnapshotNode, Text } from '../../../../../prompt/src/components/components';
|
||||
import { VirtualPrompt } from '../../../../../prompt/src/components/virtualPrompt';
|
||||
import { TokenizerName } from '../../../../../prompt/src/tokenization';
|
||||
import { createCompletionRequestData } from '../../../test/completionsPrompt';
|
||||
import { createLibTestingContext } from '../../../test/context';
|
||||
import { createTextDocument } from '../../../test/textDocument';
|
||||
import { CodeSnippetWithId, TraitWithId } from '../../contextProviders/contextItemSchemas';
|
||||
import { CompletionsContext, StableCompletionsContext } from '../completionsContext';
|
||||
import {
|
||||
CompletionsPromptRenderer,
|
||||
CompletionsPromptRenderOptions,
|
||||
} from '../completionsPromptRenderer';
|
||||
import { CurrentFile } from '../currentFile';
|
||||
|
||||
const MyNestedComponent = () => {
|
||||
return (
|
||||
<>
|
||||
<Text weight={0.5}>This goes first</Text>
|
||||
<Text weight={0.6}>This goes last</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AnotherComponent = (props: PromptElementProps & { number: number }) => {
|
||||
return <Text>This is a number {props.number ?? 0}</Text>;
|
||||
};
|
||||
|
||||
const renderingOptions: CompletionsPromptRenderOptions = {
|
||||
promptTokenLimit: 70,
|
||||
suffixPercent: 20,
|
||||
delimiter: '\n',
|
||||
tokenizer: TokenizerName.o200k,
|
||||
languageId: 'typescript',
|
||||
};
|
||||
|
||||
const fullExpectedPrefix =
|
||||
'// This is a number 1\n// This goes first\n// This goes last\n// This is a number 2\n// Raw text\n// Another raw text\nconst a = 1;\nfunction f';
|
||||
const fullExpectedSuffix = 'const b = 2;\nconst c = 3;';
|
||||
|
||||
for (const lineEnding of ['\n', '\r\n']) {
|
||||
const fileUri = 'file:///path/basename.ts';
|
||||
const source = `const a = 1;${lineEnding}function f|${lineEnding}const b = 2;${lineEnding}const c = 3;`;
|
||||
const textDocument = createTextDocument(fileUri, 'typescript', 0, source);
|
||||
const position: Position = textDocument.positionAt(textDocument.getText().indexOf('|'));
|
||||
suite(`Completions Prompt Renderer (line ending: ${JSON.stringify(lineEnding)})`, function () {
|
||||
let accessor: ServicesAccessor;
|
||||
let renderer: CompletionsPromptRenderer;
|
||||
let snapshot: PromptSnapshotNode | undefined;
|
||||
|
||||
setup(async function () {
|
||||
accessor = createLibTestingContext().createTestingAccessor();
|
||||
renderer = new CompletionsPromptRenderer();
|
||||
const vPrompt = new VirtualPrompt(
|
||||
(
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<AnotherComponent number={1} />
|
||||
<MyNestedComponent />
|
||||
{/* This is intentionally placed here so that it's far from the other AnotherComponent*/}
|
||||
<AnotherComponent number={2} />
|
||||
<>
|
||||
{/* This is intentionally in a fragment to check that it's skipped */}
|
||||
<Text>Raw text</Text>
|
||||
</>
|
||||
<>
|
||||
<Text>Another raw text</Text>
|
||||
</>
|
||||
</CompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
)
|
||||
);
|
||||
const pipe = vPrompt.createPipe();
|
||||
await pipe.pump(createCompletionRequestData(accessor, textDocument, position));
|
||||
({ snapshot } = vPrompt.snapshot());
|
||||
});
|
||||
|
||||
test('renders prefix and suffix based on completions doc position', function () {
|
||||
const prompt = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(prompt.status, 'ok');
|
||||
assert.deepStrictEqual(prompt.prefix, fullExpectedPrefix);
|
||||
assert.deepStrictEqual(prompt.prefixTokens, 43);
|
||||
assert.deepStrictEqual(prompt.suffix, fullExpectedSuffix);
|
||||
assert.deepStrictEqual(prompt.suffixTokens, 12);
|
||||
assert.deepStrictEqual(prompt.context, undefined);
|
||||
});
|
||||
|
||||
test('single context with comments', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text>This is context</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const rendered = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(rendered.status, 'ok');
|
||||
assert.deepStrictEqual(rendered.prefix, '// This is context\n');
|
||||
assert.deepStrictEqual(rendered.context, undefined);
|
||||
});
|
||||
|
||||
test('multiple context with comments', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text>This is context</Text>
|
||||
<Text>This is more context</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const rendered = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(rendered.status, 'ok');
|
||||
assert.deepStrictEqual(rendered.prefix, '// This is context\n// This is more context\n');
|
||||
assert.deepStrictEqual(rendered.context, undefined);
|
||||
});
|
||||
|
||||
test('multiple context blocks', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text>This is context</Text>
|
||||
<Text>This is more context</Text>
|
||||
</CompletionsContext>
|
||||
<CompletionsContext>
|
||||
<Text>This is other context</Text>
|
||||
<Text>This is extra context</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const rendered = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(rendered.status, 'ok');
|
||||
assert.deepStrictEqual(
|
||||
rendered.prefix,
|
||||
'// This is context\n// This is more context\n// This is other context\n// This is extra context\n'
|
||||
);
|
||||
assert.deepStrictEqual(rendered.context, undefined);
|
||||
});
|
||||
|
||||
test('multiple types of context blocks ', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text>This is context</Text>
|
||||
<Text>This is more context</Text>
|
||||
</CompletionsContext>
|
||||
<StableCompletionsContext>
|
||||
<Text>This is other context</Text>
|
||||
<Text>This is extra context</Text>
|
||||
</StableCompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const rendered = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(rendered.status, 'ok');
|
||||
assert.deepStrictEqual(
|
||||
rendered.prefix,
|
||||
'// This is context\n// This is more context\n// This is other context\n// This is extra context\n'
|
||||
);
|
||||
assert.deepStrictEqual(rendered.context, undefined);
|
||||
});
|
||||
|
||||
test('renders prefix and suffix using configured delimiter', function () {
|
||||
const expectedPrefix =
|
||||
'// This is a number 1?// This goes first?// This goes last?// This is a number 2?// Raw text?// Another raw text?const a = 1;\nfunction f';
|
||||
|
||||
const prompt = renderer.render(snapshot!, { ...renderingOptions, delimiter: '?' });
|
||||
|
||||
assert.deepStrictEqual(prompt.status, 'ok');
|
||||
assert.deepStrictEqual(prompt.prefix, expectedPrefix);
|
||||
assert.deepStrictEqual(prompt.suffix, fullExpectedSuffix);
|
||||
assert.deepStrictEqual(prompt.prefixTokens, 43);
|
||||
assert.deepStrictEqual(prompt.suffixTokens, 12);
|
||||
});
|
||||
|
||||
test('renders delimiter only if components do not already end with delimiter', function () {
|
||||
const expectedPrefix =
|
||||
'// This is a number 1text// This goes firsttext// This goes lasttext// This is a number 2text// Raw text// Another raw textconst a = 1;\nfunction f';
|
||||
|
||||
const prompt = renderer.render(snapshot!, { ...renderingOptions, delimiter: 'text' });
|
||||
|
||||
assert.deepStrictEqual(prompt.status, 'ok');
|
||||
assert.deepStrictEqual(prompt.prefix, expectedPrefix);
|
||||
assert.deepStrictEqual(prompt.suffix, fullExpectedSuffix);
|
||||
assert.deepStrictEqual(prompt.prefixTokens, 41);
|
||||
assert.deepStrictEqual(prompt.suffixTokens, 12);
|
||||
});
|
||||
|
||||
test('uses configured tokenizer', function () {
|
||||
const prompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
tokenizer: TokenizerName.cl100k,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(prompt.status, 'ok');
|
||||
assert.deepStrictEqual(prompt.prefixTokens, 43);
|
||||
assert.deepStrictEqual(prompt.suffixTokens, 12);
|
||||
});
|
||||
|
||||
test('computes metadata with stable updateDataTimeMs tolerance', function () {
|
||||
const prompt1 = renderer.render(snapshot!, renderingOptions);
|
||||
const prompt2 = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(prompt1.status, 'ok');
|
||||
assert.deepStrictEqual(prompt2.status, 'ok');
|
||||
|
||||
const metadata1 = prompt1.metadata;
|
||||
const metadata2 = prompt2.metadata;
|
||||
|
||||
assert.deepStrictEqual(metadata1.renderId, 0);
|
||||
assert.deepStrictEqual(metadata2.renderId, 1);
|
||||
assert.ok(metadata1.renderTimeMs > 0);
|
||||
assert.ok(metadata1.elisionTimeMs > 0);
|
||||
const expectedComponents = [
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile',
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[0].AnotherComponent[0].Text[0]',
|
||||
expectedTokens: 8,
|
||||
actualTokens: 8,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[1].MyNestedComponent[0].f[0].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 5,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[1].MyNestedComponent[0].f[1].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 5,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[2].AnotherComponent[0].Text[0]',
|
||||
expectedTokens: 8,
|
||||
actualTokens: 8,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[3].f[0].Text[0]',
|
||||
expectedTokens: 4,
|
||||
actualTokens: 4,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[4].f[0].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 5,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile[0].f[0].BeforeCursor[0].Text[0]',
|
||||
expectedTokens: 8,
|
||||
actualTokens: 8,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile[0].f[1].AfterCursor[0].Text[0]',
|
||||
expectedTokens: 12,
|
||||
actualTokens: 12,
|
||||
},
|
||||
];
|
||||
|
||||
expectedComponents.forEach(expected => {
|
||||
const actual = metadata1.componentStatistics.find(s => s.componentPath === expected.componentPath);
|
||||
assert.ok(actual, `Component ${expected.componentPath} not found`);
|
||||
assert.strictEqual(
|
||||
actual.expectedTokens,
|
||||
expected.expectedTokens,
|
||||
`Expected tokens for ${expected.componentPath} do not match`
|
||||
);
|
||||
assert.strictEqual(actual.actualTokens, expected.actualTokens);
|
||||
// Instead of a fixed number, just ensure updateDataTimeMs is a non-negative number.
|
||||
if (actual.updateDataTimeMs) {
|
||||
assert.ok(
|
||||
typeof actual.updateDataTimeMs === 'number' && actual.updateDataTimeMs >= 0,
|
||||
`Expected updateDataTimeMs for ${expected.componentPath} to be a non-negative number`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('computes usage statistics ignoring updateDataTimeMs field', function () {
|
||||
const rendered = renderer.render(snapshot!, renderingOptions);
|
||||
assert.deepStrictEqual(rendered.status, 'ok');
|
||||
const metadata = rendered.metadata;
|
||||
// Make updateDataTimeMs a constant value to ensure it doesn't affect the test.
|
||||
const actualStatsFiltered = metadata.componentStatistics.map(stats => {
|
||||
if (stats.updateDataTimeMs) {
|
||||
stats.updateDataTimeMs = 42;
|
||||
}
|
||||
return stats;
|
||||
});
|
||||
const expectedStatsFiltered = [
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile',
|
||||
updateDataTimeMs: 42,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[0].AnotherComponent[0].Text[0]',
|
||||
expectedTokens: 8,
|
||||
actualTokens: 8,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[1].MyNestedComponent[0].f[0].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 5,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[1].MyNestedComponent[0].f[1].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 5,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[2].AnotherComponent[0].Text[0]',
|
||||
expectedTokens: 8,
|
||||
actualTokens: 8,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[3].f[0].Text[0]',
|
||||
expectedTokens: 4,
|
||||
actualTokens: 4,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[4].f[0].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 5,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile[0].f[0].BeforeCursor[0].Text[0]',
|
||||
expectedTokens: 8,
|
||||
actualTokens: 8,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile[0].f[1].AfterCursor[0].Text[0]',
|
||||
expectedTokens: 12,
|
||||
actualTokens: 12,
|
||||
},
|
||||
];
|
||||
assert.deepStrictEqual(actualStatsFiltered, expectedStatsFiltered);
|
||||
});
|
||||
|
||||
test('propagates source via statistics', function () {
|
||||
const trait: TraitWithId = {
|
||||
name: 'trait',
|
||||
value: 'value',
|
||||
id: 'traitid',
|
||||
type: 'Trait',
|
||||
};
|
||||
const codeSnippet: CodeSnippetWithId = {
|
||||
uri: 'file://foo.ts',
|
||||
value: 'value',
|
||||
id: 'traitid',
|
||||
type: 'CodeSnippet',
|
||||
};
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text source={trait}>This is a trait</Text>
|
||||
<Chunk source={codeSnippet}>
|
||||
<Text>This is a code snippet</Text>
|
||||
</Chunk>
|
||||
</CompletionsContext>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
|
||||
const renderedPrompt = renderer.render(snapshot!, renderingOptions);
|
||||
assert.deepStrictEqual(renderedPrompt.status, 'ok');
|
||||
assert.ok(renderedPrompt.metadata.componentStatistics.find(s => s.source === trait));
|
||||
assert.ok(renderedPrompt.metadata.componentStatistics.find(s => s.source === codeSnippet));
|
||||
});
|
||||
|
||||
test('elides prefix', function () {
|
||||
const prompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 20,
|
||||
suffixPercent: 0,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(prompt.status, 'ok');
|
||||
assert.deepStrictEqual(prompt.prefix, '// Raw text\n// Another raw text\nconst a = 1;\nfunction f');
|
||||
assert.deepStrictEqual(prompt.suffix, '');
|
||||
});
|
||||
|
||||
test('elides suffix (from the end!)', function () {
|
||||
const prompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 30,
|
||||
suffixPercent: 10,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(prompt.status, 'ok');
|
||||
assert.deepStrictEqual(prompt.suffix, 'const b =');
|
||||
});
|
||||
|
||||
test('elides both prefix and suffix partially', function () {
|
||||
// Use tighter token limits to force partial elision on both sides.
|
||||
const prompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 20,
|
||||
suffixPercent: 10,
|
||||
});
|
||||
// We don't have the exact expected strings, but we verify that both prefix and suffix
|
||||
// have been elided compared to the full expectations.
|
||||
assert.strictEqual(prompt.status, 'ok');
|
||||
// The elided prefix should be shorter than the full expected one.
|
||||
assert.ok(prompt.prefix.length < fullExpectedPrefix.length, 'Expected prefix to be elided');
|
||||
// The elided suffix should also be shorter than the full expected suffix, if any elision took place.
|
||||
if (fullExpectedSuffix.length > 0) {
|
||||
assert.ok(prompt.suffix.length < fullExpectedSuffix.length, 'Expected suffix to be elided');
|
||||
}
|
||||
});
|
||||
|
||||
test('generates prompt metadata', function () {
|
||||
const rendered = renderer.render(snapshot!, renderingOptions);
|
||||
assert.deepStrictEqual(rendered.status, 'ok');
|
||||
const metadata = rendered.metadata;
|
||||
assert.ok(metadata.renderId === 0);
|
||||
assert.ok(metadata.elisionTimeMs > 0);
|
||||
assert.ok(metadata.renderTimeMs > 0);
|
||||
assert.ok(metadata.updateDataTimeMs > 0);
|
||||
assert.deepStrictEqual(metadata.tokenizer, TokenizerName.o200k);
|
||||
});
|
||||
|
||||
test('computes usage statistics after elision', function () {
|
||||
const rendered = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 40,
|
||||
suffixPercent: 10,
|
||||
});
|
||||
assert.deepStrictEqual(rendered.status, 'ok');
|
||||
const metadata = rendered.metadata;
|
||||
const actualStatsFiltered = metadata.componentStatistics.map(stats => {
|
||||
if (stats.updateDataTimeMs) {
|
||||
stats.updateDataTimeMs = 42;
|
||||
}
|
||||
return stats;
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
actualStatsFiltered.reduce((acc, curr) => acc + (curr.actualTokens ?? 0), 0),
|
||||
34
|
||||
);
|
||||
assert.deepStrictEqual(actualStatsFiltered, [
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile',
|
||||
updateDataTimeMs: 42,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[0].AnotherComponent[0].Text[0]',
|
||||
expectedTokens: 8,
|
||||
actualTokens: 0,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[1].MyNestedComponent[0].f[0].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 5,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[1].MyNestedComponent[0].f[1].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 0,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[2].AnotherComponent[0].Text[0]',
|
||||
expectedTokens: 8,
|
||||
actualTokens: 8,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[3].f[0].Text[0]',
|
||||
expectedTokens: 4,
|
||||
actualTokens: 4,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[0].CompletionsContext[4].f[0].Text[0]',
|
||||
expectedTokens: 5,
|
||||
actualTokens: 5,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile[0].f[0].BeforeCursor[0].Text[0]',
|
||||
expectedTokens: 8,
|
||||
actualTokens: 8,
|
||||
},
|
||||
{
|
||||
componentPath: '$.f[1].CurrentFile[0].f[1].AfterCursor[0].Text[0]',
|
||||
expectedTokens: 12,
|
||||
actualTokens: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
function createStringWithNLines(n: number, baseText: string): string {
|
||||
let result = '';
|
||||
for (let i = 1; i <= n; i++) {
|
||||
result += `${baseText}${i}\n`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
test('uses cached suffix if similar enough', async function () {
|
||||
const firstSuffix = createStringWithNLines(15, 'a') + createStringWithNLines(10, 'b');
|
||||
const secondSuffix = createStringWithNLines(15, 'a') + createStringWithNLines(10, 'c');
|
||||
const renderOptionsWithSuffix: CompletionsPromptRenderOptions = {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 205,
|
||||
suffixPercent: 50,
|
||||
};
|
||||
const textDocumentWithFirstSuffix = createTextDocument(
|
||||
fileUri,
|
||||
'typescript',
|
||||
0,
|
||||
'function f|\n' + firstSuffix
|
||||
);
|
||||
const position = textDocumentWithFirstSuffix.positionAt(textDocumentWithFirstSuffix.getText().indexOf('|'));
|
||||
const prompt = (
|
||||
<>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const pipe = virtualPrompt.createPipe();
|
||||
await pipe.pump(createCompletionRequestData(accessor, textDocumentWithFirstSuffix, position));
|
||||
// Snapshot caches the suffix
|
||||
virtualPrompt.snapshot();
|
||||
|
||||
// The position is the same, since the start of the document doesn't change
|
||||
const textDocumentWithSecondSuffix = createTextDocument(
|
||||
fileUri,
|
||||
'typescript',
|
||||
1,
|
||||
'function f|\n' + secondSuffix
|
||||
);
|
||||
await pipe.pump(createCompletionRequestData(accessor, textDocumentWithSecondSuffix, position));
|
||||
const { snapshot: snapshotWithDefaultThreshold } = virtualPrompt.snapshot();
|
||||
|
||||
// the first suffix is used, since they are similar enough
|
||||
const renderedWithDefaultThreshold = renderer.render(
|
||||
snapshotWithDefaultThreshold!,
|
||||
renderOptionsWithSuffix
|
||||
);
|
||||
assert.deepStrictEqual(renderedWithDefaultThreshold.status, 'ok');
|
||||
assert.deepStrictEqual(renderedWithDefaultThreshold.suffix, firstSuffix);
|
||||
|
||||
await pipe.pump(
|
||||
createCompletionRequestData(
|
||||
accessor,
|
||||
textDocumentWithSecondSuffix,
|
||||
position,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
3
|
||||
)
|
||||
);
|
||||
const { snapshot: snapshotWithLowerThreshold } = virtualPrompt.snapshot();
|
||||
|
||||
// The second suffix is used, since the matching threshold is lower
|
||||
const renderedWithLowerThreshold = renderer.render(snapshotWithLowerThreshold!, renderOptionsWithSuffix);
|
||||
assert.deepStrictEqual(renderedWithLowerThreshold.status, 'ok');
|
||||
assert.deepStrictEqual(renderedWithLowerThreshold.suffix, secondSuffix);
|
||||
});
|
||||
|
||||
test('does not use cached suffix if not similar enough', async function () {
|
||||
const firstSuffix = createStringWithNLines(15, 'a') + createStringWithNLines(10, 'b');
|
||||
const secondSuffix = createStringWithNLines(3, 'a') + createStringWithNLines(22, 'c');
|
||||
const renderOptionsWithSuffix: CompletionsPromptRenderOptions = {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 205,
|
||||
suffixPercent: 50,
|
||||
};
|
||||
const textDocumentWithFirstSuffix = createTextDocument(
|
||||
fileUri,
|
||||
'typescript',
|
||||
0,
|
||||
'function f|\n' + firstSuffix
|
||||
);
|
||||
const position = textDocumentWithFirstSuffix.positionAt(textDocumentWithFirstSuffix.getText().indexOf('|'));
|
||||
const prompt = (
|
||||
<>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const pipe = virtualPrompt.createPipe();
|
||||
await pipe.pump(createCompletionRequestData(accessor, textDocumentWithFirstSuffix, position));
|
||||
// Snapshot caches the suffix
|
||||
virtualPrompt.snapshot();
|
||||
|
||||
// The position is the same, since the start of the document doesn't change
|
||||
const textDocumentWithSecondSuffix = createTextDocument(
|
||||
fileUri,
|
||||
'typescript',
|
||||
1,
|
||||
'function f|\n' + secondSuffix
|
||||
);
|
||||
await pipe.pump(createCompletionRequestData(accessor, textDocumentWithSecondSuffix, position));
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
|
||||
// the second suffix is used, since they are not similar enough
|
||||
const rendered = renderer.render(snapshot!, renderOptionsWithSuffix);
|
||||
assert.deepStrictEqual(rendered.status, 'ok');
|
||||
assert.deepStrictEqual(rendered.suffix, secondSuffix);
|
||||
});
|
||||
|
||||
test('suffix can be empty', async function () {
|
||||
const textDocumentWithoutSuffix = createTextDocument(fileUri, 'typescript', 0, 'function f|');
|
||||
const position = textDocumentWithoutSuffix.positionAt(textDocumentWithoutSuffix.getText().indexOf('|'));
|
||||
const prompt = (
|
||||
<>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const pipe = virtualPrompt.createPipe();
|
||||
await pipe.pump(createCompletionRequestData(accessor, textDocumentWithoutSuffix, position));
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const promptWithoutSuffix = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(promptWithoutSuffix.status, 'ok');
|
||||
assert.deepStrictEqual(promptWithoutSuffix.suffix, '');
|
||||
assert.deepStrictEqual(promptWithoutSuffix.prefix, 'function f');
|
||||
});
|
||||
|
||||
test('prefix can be empty', async function () {
|
||||
const emptyTextDocument = createTextDocument(fileUri, 'typescript', 0, '|\nconst b = 2;');
|
||||
const position = emptyTextDocument.positionAt(emptyTextDocument.getText().indexOf('|'));
|
||||
const prompt = (
|
||||
<>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const pipe = virtualPrompt.createPipe();
|
||||
await pipe.pump(createCompletionRequestData(accessor, emptyTextDocument, position));
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const emptyPrompt = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(emptyPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(emptyPrompt.prefix, '');
|
||||
assert.deepStrictEqual(emptyPrompt.suffix, 'const b = 2;');
|
||||
});
|
||||
|
||||
test('prefix and suffix can be empty', async function () {
|
||||
const emptyTextDocument = createTextDocument(fileUri, 'typescript', 0, '');
|
||||
const position = emptyTextDocument.positionAt(0);
|
||||
const prompt = (
|
||||
<>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const pipe = virtualPrompt.createPipe();
|
||||
await pipe.pump(createCompletionRequestData(accessor, emptyTextDocument, position));
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const emptyPrompt = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(emptyPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(emptyPrompt.prefix, '');
|
||||
assert.deepStrictEqual(emptyPrompt.suffix, '');
|
||||
});
|
||||
|
||||
test('cancels rendering when token has been cancelled', function () {
|
||||
const cts = new CancellationTokenSource();
|
||||
|
||||
cts.cancel();
|
||||
const prompt = renderer.render(snapshot!, renderingOptions, cts.token);
|
||||
|
||||
assert.deepStrictEqual(prompt.status, 'cancelled');
|
||||
});
|
||||
|
||||
test('throws error when tree does not contain completions document component', function () {
|
||||
const promptCompletionsDocument = (
|
||||
<>
|
||||
<Text>Whatever</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(promptCompletionsDocument);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const prompt = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.strictEqual(prompt.status, 'error');
|
||||
assert.strictEqual(prompt.error.message, `Node of type ${CurrentFile.name} not found`);
|
||||
});
|
||||
|
||||
test('renders empty prefix and suffix if no data is sent', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const emptyPrompt = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(emptyPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(emptyPrompt.prefix, '');
|
||||
assert.deepStrictEqual(emptyPrompt.suffix, '');
|
||||
});
|
||||
|
||||
test('does not re-render if no data matching the expected structure is sent', async function () {
|
||||
const textDocument = createTextDocument(
|
||||
fileUri,
|
||||
'typescript',
|
||||
0,
|
||||
`import * from './foo.ts'\n|\nfunction f`
|
||||
);
|
||||
const position = textDocument.positionAt(textDocument.getText().indexOf('|'));
|
||||
const prompt = (
|
||||
<>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const pipe = virtualPrompt.createPipe();
|
||||
|
||||
// First render
|
||||
await pipe.pump(createCompletionRequestData(accessor, textDocument, position));
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const renderedPrompt = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
// Second render
|
||||
const { snapshot: snapshotTwo } = virtualPrompt.snapshot();
|
||||
const renderedPromptTwo = renderer.render(snapshotTwo!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(renderedPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(renderedPromptTwo.status, 'ok');
|
||||
assert.deepStrictEqual(renderedPrompt.prefix, `import * from './foo.ts'\n`);
|
||||
assert.deepStrictEqual(renderedPrompt.prefix, renderedPromptTwo.prefix);
|
||||
assert.deepStrictEqual(renderedPrompt.suffix, 'function f');
|
||||
assert.deepStrictEqual(renderedPrompt.suffix, renderedPromptTwo.suffix);
|
||||
});
|
||||
|
||||
test('re-renders if new data matching the expected structure is sent', async function () {
|
||||
const textDocument = createTextDocument(
|
||||
fileUri,
|
||||
'typescript',
|
||||
0,
|
||||
`import * from './foo.ts'\n|\nfunction f`
|
||||
);
|
||||
const position = textDocument.positionAt(textDocument.getText().indexOf('|'));
|
||||
const prompt = (
|
||||
<>
|
||||
<CurrentFile />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const pipe = virtualPrompt.createPipe();
|
||||
|
||||
// First render
|
||||
await pipe.pump(createCompletionRequestData(accessor, textDocument, position));
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
const renderedPrompt = renderer.render(snapshot!, renderingOptions);
|
||||
|
||||
// Second render
|
||||
const updatedTextDocument = createTextDocument(
|
||||
fileUri,
|
||||
'typescript',
|
||||
1, // Notice version change
|
||||
`import * from './bar.ts'\n|\nfunction g`
|
||||
);
|
||||
const updatedPosition = updatedTextDocument.positionAt(updatedTextDocument.getText().indexOf('|'));
|
||||
|
||||
await pipe.pump(createCompletionRequestData(accessor, updatedTextDocument, updatedPosition));
|
||||
const { snapshot: snapshotTwo } = virtualPrompt.snapshot();
|
||||
const renderedPromptTwo = renderer.render(snapshotTwo!, renderingOptions);
|
||||
|
||||
assert.deepStrictEqual(renderedPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(renderedPromptTwo.status, 'ok');
|
||||
assert.deepStrictEqual(renderedPrompt.prefix, `import * from './foo.ts'\n`);
|
||||
assert.deepStrictEqual(renderedPromptTwo.prefix, `import * from './bar.ts'\n`);
|
||||
assert.deepStrictEqual(renderedPrompt.suffix, 'function f');
|
||||
assert.deepStrictEqual(renderedPromptTwo.suffix, 'function g');
|
||||
});
|
||||
|
||||
test('Elides Chunk completely', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Chunk weight={0.5}>
|
||||
<Text>Chunk Text 1</Text>
|
||||
<Text>Chunk Text 2</Text>
|
||||
</Chunk>
|
||||
<Text>Outside Text</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile weight={0.9} />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
|
||||
const renderedPrompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 10,
|
||||
suffixPercent: 0,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(renderedPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(renderedPrompt.prefix, '// Outside Text\n');
|
||||
assert.deepStrictEqual(renderedPrompt.suffix, '');
|
||||
});
|
||||
|
||||
test('Elides Chunk completely while respecting lower weights', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text weight={0.7}>Outside Text 1</Text>
|
||||
<Chunk weight={0.5}>
|
||||
<Text>Chunk Text 1</Text>
|
||||
<Text>Chunk Text 2</Text>
|
||||
</Chunk>
|
||||
<Text weight={0.7}>Outside Text 2</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile weight={0.9} />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
|
||||
const renderedPrompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 16,
|
||||
suffixPercent: 0,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(renderedPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(renderedPrompt.prefix, '// Outside Text 1\n// Outside Text 2\n');
|
||||
assert.deepStrictEqual(renderedPrompt.suffix, '');
|
||||
});
|
||||
|
||||
test('Elides Chunk completely in case of exceeding the limit even with higher weight', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text weight={0.5}>Outside Text 1</Text>
|
||||
<Chunk weight={0.7}>
|
||||
<Text>Chunk Text 1</Text>
|
||||
<Text>Chunk Text 2</Text>
|
||||
</Chunk>
|
||||
<Text weight={0.8}>Outside Text 2</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile weight={0.9} />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
|
||||
const renderedPrompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 14,
|
||||
suffixPercent: 0,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(renderedPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(renderedPrompt.prefix, '// Outside Text 1\n// Outside Text 2\n');
|
||||
assert.deepStrictEqual(renderedPrompt.suffix, '');
|
||||
});
|
||||
|
||||
test('Prefers higher weighted Chunk over lower weighted separate components', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text weight={0.7}>Outside Text 1</Text>
|
||||
<Chunk weight={0.8}>
|
||||
<Text>Chunk Text 1</Text>
|
||||
<Text>Chunk Text 2</Text>
|
||||
</Chunk>
|
||||
<Text weight={0.7}>Outside Text 2</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile weight={0.9} />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
|
||||
const renderedPrompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 14,
|
||||
suffixPercent: 0,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(renderedPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(renderedPrompt.prefix, '// Chunk Text 1\n// Chunk Text 2\n');
|
||||
assert.deepStrictEqual(renderedPrompt.suffix, '');
|
||||
});
|
||||
|
||||
test('If a nested chunk is elided first, the outer chunks is kept', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text weight={0.7}>Outside Text 1</Text>
|
||||
<Chunk weight={0.5}>
|
||||
<Text>Chunk Text 1</Text>
|
||||
<Chunk weight={0.5}>
|
||||
<Text>Nested Chunk Text 1</Text>
|
||||
<Text>Nested Chunk Text 2</Text>
|
||||
</Chunk>
|
||||
<Text>Chunk Text 2</Text>
|
||||
</Chunk>
|
||||
<Text weight={0.7}>Outside Text 2</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile weight={0.9} />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
|
||||
const renderedPrompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 35,
|
||||
suffixPercent: 0,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(renderedPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(
|
||||
renderedPrompt.prefix,
|
||||
'// Outside Text 1\n// Chunk Text 1\n// Chunk Text 2\n// Outside Text 2\n'
|
||||
);
|
||||
assert.deepStrictEqual(renderedPrompt.suffix, '');
|
||||
});
|
||||
|
||||
test('If the outer chunk is elided first, the inner chunk is also elided', function () {
|
||||
const prompt = (
|
||||
<>
|
||||
<CompletionsContext>
|
||||
<Text weight={0.7}>Outside Text 1</Text>
|
||||
<Chunk weight={0.5}>
|
||||
<Text weight={0.5}>Chunk Text 1</Text>
|
||||
<Chunk>
|
||||
<Text>Nested Chunk Text 1</Text>
|
||||
<Text>Nested Chunk Text 2</Text>
|
||||
</Chunk>
|
||||
<Text>Chunk Text 2</Text>
|
||||
</Chunk>
|
||||
<Text weight={0.7}>Outside Text 2</Text>
|
||||
</CompletionsContext>
|
||||
<CurrentFile weight={0.9} />
|
||||
</>
|
||||
);
|
||||
|
||||
const virtualPrompt = new VirtualPrompt(prompt);
|
||||
const { snapshot } = virtualPrompt.snapshot();
|
||||
|
||||
const renderedPrompt = renderer.render(snapshot!, {
|
||||
...renderingOptions,
|
||||
promptTokenLimit: 37,
|
||||
suffixPercent: 0,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(renderedPrompt.status, 'ok');
|
||||
assert.deepStrictEqual(renderedPrompt.prefix, '// Outside Text 1\n// Outside Text 2\n');
|
||||
assert.deepStrictEqual(renderedPrompt.suffix, '');
|
||||
});
|
||||
});
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../../../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { CodeSnippet, ContextProvider, ContextResolver, SupportedContextItem, Trait, type DiagnosticBag } from '../../../../../types/src';
|
||||
import { createCompletionState } from '../../../completionState';
|
||||
import { ICompletionsFeaturesService } from '../../../experiments/featuresService';
|
||||
import { TelemetryWithExp } from '../../../telemetry';
|
||||
import { createLibTestingContext } from '../../../test/context';
|
||||
import { createTextDocument } from '../../../test/textDocument';
|
||||
import { LocationFactory } from '../../../textDocument';
|
||||
import { ICompletionsContextProviderRegistryService } from '../../contextProviderRegistry';
|
||||
import { ContextProviderBridge } from './../contextProviderBridge';
|
||||
|
||||
suite('Context Provider Bridge', function () {
|
||||
let accessor: ServicesAccessor;
|
||||
let bridge: ContextProviderBridge;
|
||||
|
||||
setup(function () {
|
||||
accessor = createLibTestingContext().createTestingAccessor();
|
||||
const featuresService = accessor.get(ICompletionsFeaturesService);
|
||||
accessor.get(ICompletionsContextProviderRegistryService).registerContextProvider(new TestContextProvider());
|
||||
featuresService.contextProviders = () => ['testContextProvider'];
|
||||
bridge = accessor.get(IInstantiationService).createInstance(ContextProviderBridge);
|
||||
});
|
||||
|
||||
test('await context resolution by id', async function () {
|
||||
const state = testCompletionState();
|
||||
|
||||
bridge.schedule(state, 'id', 'opId', TelemetryWithExp.createEmptyConfigForTesting());
|
||||
const items = await bridge.resolution('id');
|
||||
|
||||
assert.deepStrictEqual(items.length, 1);
|
||||
assert.deepStrictEqual(items[0].providerId, 'testContextProvider');
|
||||
assert.deepStrictEqual((items[0].data[0] as Trait).name, 'test');
|
||||
assert.deepStrictEqual((items[0].data[0] as Trait).value, 'test');
|
||||
});
|
||||
|
||||
test('await context resolution by id twice', async function () {
|
||||
const state = testCompletionState();
|
||||
bridge.schedule(state, 'id', 'opId', TelemetryWithExp.createEmptyConfigForTesting());
|
||||
|
||||
const items1 = await bridge.resolution('id');
|
||||
const items2 = await bridge.resolution('id');
|
||||
|
||||
assert.deepStrictEqual(items1.length, 1);
|
||||
assert.deepStrictEqual(items1[0].providerId, 'testContextProvider');
|
||||
assert.deepStrictEqual((items1[0].data[0] as Trait).name, 'test');
|
||||
assert.deepStrictEqual((items1[0].data[0] as Trait).value, 'test');
|
||||
assert.deepStrictEqual(items1, items2);
|
||||
});
|
||||
|
||||
test('no schedule called returns empty array', async function () {
|
||||
const items = await bridge.resolution('unknown-id');
|
||||
|
||||
assert.deepStrictEqual(items, []);
|
||||
});
|
||||
|
||||
test('error in context resolution', async function () {
|
||||
const featuresService = accessor.get(ICompletionsFeaturesService);
|
||||
accessor.get(ICompletionsContextProviderRegistryService).registerContextProvider(
|
||||
new TestContextProvider({ shouldThrow: true, id: 'errorProvider' })
|
||||
);
|
||||
featuresService.contextProviders = () => ['errorProvider'];
|
||||
const errorBridge = accessor.get(IInstantiationService).createInstance(ContextProviderBridge);
|
||||
const state = testCompletionState();
|
||||
|
||||
errorBridge.schedule(state, 'err-id', 'opId', TelemetryWithExp.createEmptyConfigForTesting());
|
||||
const items = await errorBridge.resolution('err-id');
|
||||
|
||||
const errorItem = items.find(i => i.providerId === 'errorProvider');
|
||||
assert.deepStrictEqual(errorItem?.resolution, 'error');
|
||||
});
|
||||
|
||||
test('multiple schedules and resolutions', async function () {
|
||||
const state1 = testCompletionState();
|
||||
const state2 = testCompletionState();
|
||||
|
||||
bridge.schedule(state1, 'id1', 'opId', TelemetryWithExp.createEmptyConfigForTesting());
|
||||
bridge.schedule(state2, 'id2', 'opId', TelemetryWithExp.createEmptyConfigForTesting());
|
||||
|
||||
const items1 = await bridge.resolution('id1');
|
||||
const items2 = await bridge.resolution('id2');
|
||||
|
||||
assert.deepStrictEqual(items1.length, 1);
|
||||
assert.deepStrictEqual(items2.length, 1);
|
||||
});
|
||||
|
||||
test('empty provider list returns empty array', async function () {
|
||||
const featuresService = accessor.get(ICompletionsFeaturesService);
|
||||
featuresService.contextProviders = () => [];
|
||||
const instantiationService = createLibTestingContext().createTestingAccessor().get(IInstantiationService);
|
||||
bridge = instantiationService.createInstance(ContextProviderBridge);
|
||||
const state = testCompletionState();
|
||||
|
||||
bridge.schedule(state, 'empty-id', 'opId', TelemetryWithExp.createEmptyConfigForTesting());
|
||||
const items = await bridge.resolution('empty-id');
|
||||
|
||||
assert.deepStrictEqual(items, []);
|
||||
});
|
||||
|
||||
function testCompletionState() {
|
||||
const doc = createTextDocument('file:///fizzbuzz.go', 'go', 1, 'code');
|
||||
const position = LocationFactory.position(3, 0);
|
||||
return createCompletionState(doc, position);
|
||||
}
|
||||
});
|
||||
|
||||
class TestContextResolver implements ContextResolver<SupportedContextItem> {
|
||||
private shouldThrow: boolean;
|
||||
constructor(opts?: { shouldThrow?: boolean }) {
|
||||
this.shouldThrow = opts?.shouldThrow ?? false;
|
||||
}
|
||||
|
||||
async *resolve(): AsyncIterable<SupportedContextItem> {
|
||||
if (this.shouldThrow) {
|
||||
throw new Error('Test error');
|
||||
}
|
||||
yield Promise.resolve({ name: 'test', value: 'test' });
|
||||
}
|
||||
}
|
||||
|
||||
class TestContextProvider implements ContextProvider<Trait | CodeSnippet | DiagnosticBag> {
|
||||
id: string;
|
||||
selector: string[];
|
||||
resolver: ContextResolver<CodeSnippet | Trait | DiagnosticBag>;
|
||||
|
||||
constructor(opts?: { shouldThrow?: boolean; id?: string }) {
|
||||
this.id = opts?.id ?? 'testContextProvider';
|
||||
this.selector = ['*'];
|
||||
this.resolver = new TestContextResolver({ shouldThrow: opts?.shouldThrow });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user