Skip to content
Draft
32 changes: 18 additions & 14 deletions chat-lib/test/nesProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ import * as dotenv from 'dotenv';
dotenv.config({ path: '../.env' });

import { promises as fs } from 'fs';
import { outdent } from 'outdent';
import * as path from 'path';
import * as stream from 'stream';
import { outdent } from 'outdent';
import { assert, describe, expect, it } from 'vitest';
import { CopilotToken } from '../src/_internal/platform/authentication/common/copilotToken';
import { ICopilotTokenManager } from '../src/_internal/platform/authentication/common/copilotTokenManager';
import { DocumentId } from '../src/_internal/platform/inlineEdits/common/dataTypes/documentId';
import { MutableObservableWorkspace } from '../src/_internal/platform/inlineEdits/common/observableWorkspace';
import { FetchOptions, IAbortController, IHeaders, PaginationOptions, Response } from '../src/_internal/platform/networking/common/fetcherService';
import { IFetcher } from '../src/_internal/platform/networking/common/networking';
import { CancellationToken } from '../src/_internal/util/vs/base/common/cancellation';
import { Emitter } from '../src/_internal/util/vs/base/common/event';
import { URI } from '../src/_internal/util/vs/base/common/uri';
import { StringEdit, StringReplacement } from '../src/_internal/util/vs/editor/common/core/edits/stringEdit';
import { OffsetRange } from '../src/_internal/util/vs/editor/common/core/ranges/offsetRange';
import { createNESProvider, ILogTarget, ITelemetrySender, LogLevel } from '../src/main';
import { ICopilotTokenManager } from '../src/_internal/platform/authentication/common/copilotTokenManager';
import { Emitter } from '../src/_internal/util/vs/base/common/event';
import { CopilotToken } from '../src/_internal/platform/authentication/common/copilotToken';


class TestFetcher implements IFetcher {
Expand Down Expand Up @@ -92,17 +92,17 @@ class TestFetcher implements IFetcher {
}

class TestCopilotTokenManager implements ICopilotTokenManager {
_serviceBrand: undefined;
_serviceBrand: undefined;

onDidCopilotTokenRefresh = new Emitter<void>().event;
onDidCopilotTokenRefresh = new Emitter<void>().event;

async getCopilotToken(force?: boolean): Promise<CopilotToken> {
return new CopilotToken({ token: 'fixedToken', expires_at: 0, refresh_in: 0, username: 'fixedTokenManager', isVscodeTeamMember: false, copilot_plan: 'unknown' });
}
async getCopilotToken(force?: boolean): Promise<CopilotToken> {
return new CopilotToken({ token: 'fixedToken', expires_at: 0, refresh_in: 0, username: 'fixedTokenManager', isVscodeTeamMember: false, copilot_plan: 'unknown' });
}

resetCopilotToken(httpError?: number): void {
// nothing
}
resetCopilotToken(httpError?: number): void {
// nothing
}
}

class TestTelemetrySender implements ITelemetrySender {
Expand Down Expand Up @@ -163,7 +163,11 @@ describe('NESProvider Facade', () => {
assert.strictEqual(fetcher.requests.length, 2, `Unexpected requests: ${JSON.stringify(fetcher.requests, null, 2)}`);
assert.ok(fetcher.requests[0].url.endsWith('/models'), `Unexpected URL: ${fetcher.requests[0].url}`);
assert.ok(fetcher.requests[1].url.endsWith('/chat/completions'), `Unexpected URL: ${fetcher.requests[1].url}`);
assert.strictEqual(fetcher.requests[1].options.json?.model, 'xtab-test');

assert(fetcher.requests[1].options.json);
assert(typeof fetcher.requests[1].options.json === 'object');
assert('model' in fetcher.requests[1].options.json);
assert(fetcher.requests[1].options.json.model === 'xtab-test');

assert(result.result);

Expand Down Expand Up @@ -199,4 +203,4 @@ describe('NESProvider Facade', () => {
const errorLogs = logTarget.logs.filter(l => l.level === LogLevel.Error);
assert.strictEqual(errorLogs.length, 0, `Unexpected error logs: ${JSON.stringify(errorLogs, null, 2)}`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { contextProviderMatch } from './extension/src/contextProviderMatch';
import { registerPanelSupport } from './extension/src/copilotPanel/common';
import { CopilotExtensionStatus, ICompletionsExtensionStatus } from './extension/src/extensionStatus';
import { extensionFileSystem } from './extension/src/fileSystem';
import { registerGhostTextDependencies } from './extension/src/ghostText/ghostText';
import { exception } from './extension/src/inlineCompletion';
import { ModelPickerManager } from './extension/src/modelPicker';
import { CopilotStatusBar } from './extension/src/statusBar';
Expand Down Expand Up @@ -132,9 +131,6 @@ export function setup(serviceAccessor: ServicesAccessor, disposables: Disposable
// CodeQuote needs to listen for the initial token notification event.
disposables.add(serviceAccessor.get(ICompletionsCitationManager).register());

// Send telemetry when ghost text is accepted
disposables.add(registerGhostTextDependencies(serviceAccessor));

// Register to listen for changes to the active document to keep track
// of last access time
disposables.add(registerDocumentTracker(serviceAccessor));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,43 @@

import {
CancellationToken,
commands,
InlineCompletionContext,
InlineCompletionEndOfLifeReason,
InlineCompletionEndOfLifeReasonKind,
InlineCompletionItem,
InlineCompletionItemProvider,
InlineCompletionList,
InlineCompletionTriggerKind,
PartialAcceptInfo,
Position,
Range,
TextDocument,
window,
window
} from 'vscode';
import { IInstantiationService, ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
import { ISurveyService } from '../../../../../../platform/survey/common/surveyService';
import { assertNever } from '../../../../../../util/vs/base/common/assert';
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
import { createCorrelationId } from '../../../../../inlineEdits/common/correlationId';
import { CopilotCompletion } from '../../../lib/src/ghostText/copilotCompletion';
import { handleGhostTextPostInsert, handleGhostTextShown, handlePartialGhostTextPostInsert } from '../../../lib/src/ghostText/last';
import { GhostText } from '../../../lib/src/inlineCompletion';
import { telemetry } from '../../../lib/src/telemetry';
import { wrapDoc } from '../textDocumentManager';

const postInsertCmdName = '_github.copilot.ghostTextPostInsert2';
export interface GhostTextCompletionList extends InlineCompletionList {
items: GhostTextCompletionItem[];
}

export interface GhostTextCompletionItem extends InlineCompletionItem {
copilotCompletion: CopilotCompletion;
}

export class GhostTextProvider implements InlineCompletionItemProvider {
export class GhostTextProvider {

private readonly ghostText: GhostText;

constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ISurveyService private readonly _surveyService: ISurveyService,
) {
this.ghostText = this.instantiationService.createInstance(GhostText);
}
Expand All @@ -44,7 +51,7 @@ export class GhostTextProvider implements InlineCompletionItemProvider {
position: Position,
context: InlineCompletionContext,
token: CancellationToken
): Promise<InlineCompletionList | undefined> {
): Promise<GhostTextCompletionList | undefined> {
const textDocument = wrapDoc(vscodeDoc);
if (!textDocument) {
return;
Expand All @@ -69,60 +76,52 @@ export class GhostTextProvider implements InlineCompletionItemProvider {
return;
}

const items = rawCompletions.map(completion => {
const items: GhostTextCompletionItem[] = rawCompletions.map(completion => {
const { start, end } = completion.range;
const newRange = new Range(start.line, start.character, end.line, end.character);
return {
insertText: completion.insertText,
range: newRange,
command: {
title: 'Completion Accepted', // Unused
command: postInsertCmdName,
arguments: [completion],
},
copilotCompletion: completion,
correlationId: createCorrelationId('completions'),
};
} satisfies GhostTextCompletionItem;
});

return { items };
}

handleDidShowCompletionItem(item: InlineCompletionItem) {
const cmp = item.command!.arguments![0] as CopilotCompletion;
this.instantiationService.invokeFunction(handleGhostTextShown, cmp);
handleDidShowCompletionItem(item: GhostTextCompletionItem) {
this.instantiationService.invokeFunction(handleGhostTextShown, item.copilotCompletion);
}

handleDidPartiallyAcceptCompletionItem(item: InlineCompletionItem, info: number | PartialAcceptInfo) {
handleDidPartiallyAcceptCompletionItem(item: GhostTextCompletionItem, info: number | PartialAcceptInfo) {
if (typeof info === 'number') {
return; // deprecated API
}
const cmp = item.command!.arguments![0] as CopilotCompletion;
this.instantiationService.invokeFunction(handlePartialGhostTextPostInsert, cmp, info.acceptedLength, info.kind);
this.instantiationService.invokeFunction(handlePartialGhostTextPostInsert, item.copilotCompletion, info.acceptedLength, info.kind);
}

handleEndOfLifetime(completionItem: InlineCompletionItem, reason: InlineCompletionEndOfLifeReason) {
// Send telemetry event when a completion is explicitly dismissed
if (reason.kind !== InlineCompletionEndOfLifeReasonKind.Rejected) {
return;
async handleEndOfLifetime(completionItem: GhostTextCompletionItem, reason: InlineCompletionEndOfLifeReason) {
const copilotCompletion = completionItem.copilotCompletion;
switch (reason.kind) {
case InlineCompletionEndOfLifeReasonKind.Accepted: {
this.instantiationService.invokeFunction(handleGhostTextPostInsert, copilotCompletion);
this._surveyService.signalUsage('completions').catch(() => {
// Ignore errors from the survey command execution
});
return;
}
case InlineCompletionEndOfLifeReasonKind.Rejected: {
this.instantiationService.invokeFunction(telemetry, 'ghostText.dismissed', copilotCompletion.telemetry);
return;
}
case InlineCompletionEndOfLifeReasonKind.Ignored: {
// @ulugbekna: no-op ?
return;
}
default: {
assertNever(reason);
}
}
const cmp = completionItem.command?.arguments?.[0] as CopilotCompletion | undefined;
if (!cmp) {
return;
}
this.instantiationService.invokeFunction(telemetry, 'ghostText.dismissed', cmp.telemetry);
}
}

/** Registers the commands necessary to use GhostTextProvider (but not GhostTextProvider itself) */
export function registerGhostTextDependencies(accessor: ServicesAccessor) {
const instantiationService = accessor.get(IInstantiationService);
const postCmdHandler = commands.registerCommand(postInsertCmdName, async (e: CopilotCompletion) => {
instantiationService.invokeFunction(handleGhostTextPostInsert, e);
try {
await commands.executeCommand('github.copilot.survey.signalUsage', 'completions');
} catch (e) {
// Ignore errors from the survey command execution
}
});
return postCmdHandler;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ import {
CancellationToken,
InlineCompletionContext,
InlineCompletionEndOfLifeReason,
InlineCompletionItem,
InlineCompletionItemProvider,
InlineCompletionList,
InlineCompletionTriggerKind,
PartialAcceptInfo,
Position,
TextDocument,
workspace,
workspace
} from 'vscode';
import { Disposable } from '../../../../../util/vs/base/common/lifecycle';
import { LineEdit } from '../../../../../util/vs/editor/common/core/edits/lineEdit';
Expand All @@ -32,7 +31,7 @@ import { Logger } from '../../lib/src/logger';
import { isCompletionEnabledForDocument } from './config';
import { CopilotCompletionFeedbackTracker, sendCompletionFeedbackCommand } from './copilotCompletionFeedbackTracker';
import { ICompletionsExtensionStatus } from './extensionStatus';
import { GhostTextProvider } from './ghostText/ghostText';
import { GhostTextCompletionItem, GhostTextCompletionList, GhostTextProvider } from './ghostText/ghostText';

const logger = new Logger('inlineCompletionItemProvider');

Expand All @@ -58,7 +57,7 @@ export function exception(accessor: ServicesAccessor, error: unknown, origin: st
/** @public */
export class CopilotInlineCompletionItemProvider extends Disposable implements InlineCompletionItemProvider {
private readonly copilotCompletionFeedbackTracker: CopilotCompletionFeedbackTracker;
private readonly ghostTextProvider: InlineCompletionItemProvider;
private readonly ghostTextProvider: GhostTextProvider;
private readonly inlineEditLogger: InlineEditLogger;

public onDidChange = undefined;
Expand All @@ -80,7 +79,7 @@ export class CopilotInlineCompletionItemProvider extends Disposable implements I
position: Position,
context: InlineCompletionContext,
token: CancellationToken
): Promise<InlineCompletionList | undefined> {
): Promise<GhostTextCompletionList | undefined> {
const logContext = new GhostTextContext(doc.uri.toString(), doc.version, context);
try {
return await this._provideInlineCompletionItems(doc, position, context, logContext, token);
Expand All @@ -98,7 +97,7 @@ export class CopilotInlineCompletionItemProvider extends Disposable implements I
context: InlineCompletionContext,
logContext: GhostTextContext,
token: CancellationToken
): Promise<InlineCompletionList | undefined> {
): Promise<GhostTextCompletionList | undefined> {
if (context.triggerKind === InlineCompletionTriggerKind.Automatic) {
if (!this.instantiationService.invokeFunction(isCompletionEnabledForDocument, doc)) {
return;
Expand Down Expand Up @@ -139,34 +138,34 @@ export class CopilotInlineCompletionItemProvider extends Disposable implements I
commands: [sendCompletionFeedbackCommand],
};
} catch (e) {
this.instantiationService.invokeFunction(exception, e, '.provideInlineCompletionItems', logger);
this.instantiationService.invokeFunction(exception, e, '._provideInlineCompletionItems', logger);
logContext.setError(e);
}
}

handleDidShowCompletionItem(item: InlineCompletionItem, updatedInsertText: string) {
handleDidShowCompletionItem(item: GhostTextCompletionItem) {
try {
this.copilotCompletionFeedbackTracker.trackItem(item);
return this.ghostTextProvider.handleDidShowCompletionItem?.(item, updatedInsertText);
return this.ghostTextProvider.handleDidShowCompletionItem(item);
} catch (e) {
this.instantiationService.invokeFunction(exception, e, '.provideInlineCompletionItems', logger);
this.instantiationService.invokeFunction(exception, e, '.handleDidShowCompletionItem', logger);
}
}

handleDidPartiallyAcceptCompletionItem(
item: InlineCompletionItem,
acceptedLengthOrInfo: number & PartialAcceptInfo
item: GhostTextCompletionItem,
acceptedLengthOrInfo: number | PartialAcceptInfo
) {
try {
return this.ghostTextProvider.handleDidPartiallyAcceptCompletionItem?.(item, acceptedLengthOrInfo);
return this.ghostTextProvider.handleDidPartiallyAcceptCompletionItem(item, acceptedLengthOrInfo);
} catch (e) {
this.instantiationService.invokeFunction(exception, e, '.provideInlineCompletionItems', logger);
this.instantiationService.invokeFunction(exception, e, '.handleDidPartiallyAcceptCompletionItem', logger);
}
}

handleEndOfLifetime(completionItem: InlineCompletionItem, reason: InlineCompletionEndOfLifeReason) {
handleEndOfLifetime(completionItem: GhostTextCompletionItem, reason: InlineCompletionEndOfLifeReason) {
try {
return this.ghostTextProvider.handleEndOfLifetime?.(completionItem, reason);
return this.ghostTextProvider.handleEndOfLifetime(completionItem, reason);
} catch (e) {
this.instantiationService.invokeFunction(exception, e, '.handleEndOfLifetime', logger);
}
Expand Down
Loading