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:
2026-01-17 14:18:08 +08:00
parent 4de3dfdd8d
commit d9ab341223
381 changed files with 125356 additions and 0 deletions
@@ -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
);
}
@@ -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
@@ -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;
}
@@ -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);
}
@@ -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 { }
@@ -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'))
);
}
@@ -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
);
}
@@ -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');
}
@@ -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} />
</>
);
}
@@ -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;
},
];
@@ -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();
}
@@ -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, '');
});
});
}
@@ -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