From 0e53358b3ec15c581aaa0bc719b9f342751dc8b6 Mon Sep 17 00:00:00 2001 From: Quinn Slack Date: Fri, 28 Jul 2023 22:30:03 -0700 Subject: [PATCH 1/2] rm completions cache --- lib/shared/src/configuration.ts | 1 - vscode/package.json | 5 - vscode/src/completions/cache.test.ts | 97 ---------------- vscode/src/completions/cache.ts | 104 ----------------- .../src/completions/request-manager.test.ts | 25 +---- vscode/src/completions/request-manager.ts | 106 +----------------- ...vscodeInlineCompletionItemProvider.test.ts | 21 +--- .../vscodeInlineCompletionItemProvider.ts | 78 +------------ vscode/src/configuration.test.ts | 4 - vscode/src/configuration.ts | 1 - vscode/src/main.ts | 2 - .../test/completions/completions-dataset.ts | 2 - .../run-code-completions-on-dataset.ts | 1 - 13 files changed, 12 insertions(+), 435 deletions(-) delete mode 100644 vscode/src/completions/cache.test.ts delete mode 100644 vscode/src/completions/cache.ts diff --git a/lib/shared/src/configuration.ts b/lib/shared/src/configuration.ts index 076a28ae14b6..efc29a398e09 100644 --- a/lib/shared/src/configuration.ts +++ b/lib/shared/src/configuration.ts @@ -24,7 +24,6 @@ export interface Configuration { | 'unstable-azure-openai' autocompleteAdvancedServerEndpoint: string | null autocompleteAdvancedAccessToken: string | null - autocompleteAdvancedCache: boolean autocompleteAdvancedEmbeddings: boolean autocompleteExperimentalCompleteSuggestWidgetSelection?: boolean pluginsEnabled?: boolean diff --git a/vscode/package.json b/vscode/package.json index 7c54db4d3910..7740c067f0ae 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -885,11 +885,6 @@ "type": "string", "markdownDescription": "Overwrite the access token used for code autocomplete. This is only supported with a provider other than `anthropic`." }, - "cody.autocomplete.advanced.cache": { - "type": "boolean", - "default": true, - "markdownDescription": "Enables caching of code autocomplete." - }, "cody.autocomplete.advanced.embeddings": { "order": 99, "type": "boolean", diff --git a/vscode/src/completions/cache.test.ts b/vscode/src/completions/cache.test.ts deleted file mode 100644 index a78879ec9b8c..000000000000 --- a/vscode/src/completions/cache.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { CompletionsCache } from './cache' - -describe('CompletionsCache', () => { - it('returns the cached completion items', () => { - const cache = new CompletionsCache() - cache.add('id1', [{ prefix: 'foo\n', content: 'bar' }]) - - expect(cache.get('foo\n')).toEqual({ - logId: 'id1', - isExactPrefix: true, - completions: [{ prefix: 'foo\n', content: 'bar' }], - }) - }) - - it('returns the cached items when the prefix includes characters from the completion', () => { - const cache = new CompletionsCache() - cache.add('id1', [{ prefix: 'foo\n', content: 'bar' }]) - - expect(cache.get('foo\nb')).toEqual({ - logId: 'id1', - isExactPrefix: false, - completions: [{ prefix: 'foo\nb', content: 'ar' }], - }) - expect(cache.get('foo\nba')).toEqual({ - logId: 'id1', - isExactPrefix: false, - completions: [{ prefix: 'foo\nba', content: 'r' }], - }) - }) - - it('trims trailing whitespace on empty line', () => { - const cache = new CompletionsCache() - cache.add('id1', [{ prefix: 'foo \n ', content: 'bar' }]) - - expect(cache.get('foo \n ', true)).toEqual({ - logId: 'id1', - isExactPrefix: false, - completions: [{ prefix: 'foo \n ', content: 'bar' }], - }) - expect(cache.get('foo \n ', true)).toEqual({ - logId: 'id1', - isExactPrefix: false, - completions: [{ prefix: 'foo \n ', content: 'bar' }], - }) - expect(cache.get('foo \n', true)).toEqual({ - logId: 'id1', - isExactPrefix: false, - completions: [{ prefix: 'foo \n', content: 'bar' }], - }) - expect(cache.get('foo ', true)).toEqual(undefined) - }) - - it('does not trim trailing whitespace on non-empty line', () => { - const cache = new CompletionsCache() - cache.add('id1', [{ prefix: 'foo', content: 'bar' }]) - - expect(cache.get('foo', true)).toEqual({ - logId: 'id1', - isExactPrefix: true, - completions: [{ prefix: 'foo', content: 'bar' }], - }) - expect(cache.get('foo ', true)).toEqual(undefined) - expect(cache.get('foo ', true)).toEqual(undefined) - expect(cache.get('foo \n', true)).toEqual(undefined) - expect(cache.get('foo\n', true)).toEqual(undefined) - expect(cache.get('foo\t', true)).toEqual(undefined) - }) - - it('has a lookup function for untrimmed prefixes', () => { - const cache = new CompletionsCache() - cache.add('id1', [{ prefix: 'foo\n ', content: 'baz' }]) - - expect(cache.get('foo\n ', false)).toEqual({ - logId: 'id1', - isExactPrefix: true, - completions: [ - { - prefix: 'foo\n ', - content: 'baz', - }, - ], - }) - expect(cache.get('foo\n ', false)).toEqual(undefined) - }) - - it('updates the log id for all cached entries', () => { - const cache = new CompletionsCache() - cache.add('id1', [{ prefix: 'foo \n ', content: 'bar' }]) - cache.updateLogId('id1', 'id2') - - expect(cache.get('foo \n ', true)?.logId).toBe('id2') - expect(cache.get('foo \n ', true)?.logId).toBe('id2') - expect(cache.get('foo \n', true)?.logId).toBe('id2') - }) -}) diff --git a/vscode/src/completions/cache.ts b/vscode/src/completions/cache.ts deleted file mode 100644 index 2b3396fc260d..000000000000 --- a/vscode/src/completions/cache.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { LRUCache } from 'lru-cache' - -import { trimEndOnLastLineIfWhitespaceOnly } from './text-processing' -import { Completion } from './types' - -export interface CachedCompletions { - logId: string - isExactPrefix: boolean - completions: Completion[] -} - -export class CompletionsCache { - private cache = new LRUCache({ - max: 500, // Maximum input prefixes in the cache. - }) - - public clear(): void { - this.cache.clear() - } - - // TODO: The caching strategy only takes the file content prefix into - // account. We need to add additional information like file path or suffix - // to make sure the cache does not return undesired results for other files - // in the same project. - public get(prefix: string, trim: boolean = true): CachedCompletions | undefined { - const trimmedPrefix = trim ? trimEndOnLastLineIfWhitespaceOnly(prefix) : prefix - const result = this.cache.get(trimmedPrefix) - - if (!result) { - return undefined - } - - const completions = result.completions.map(completion => { - if (trimmedPrefix.length === trimEndOnLastLineIfWhitespaceOnly(completion.prefix).length) { - return { ...completion, prefix, content: completion.content } - } - - // Cached results can be created by appending characters from a - // recommendation from a smaller input prompt. If that's the - // case, we need to slightly change the content and remove - // characters that are now part of the prefix. - const sliceChars = prefix.length - completion.prefix.length - return { - ...completion, - prefix, - content: completion.content.slice(sliceChars), - } - }) - - return { - ...result, - completions, - } - } - - public add(logId: string, completions: Completion[]): void { - for (const completion of completions) { - // Cache the exact prefix first and then append characters from the - // completion one after the other until the first line is exceeded. - // - // If the completion starts with a `\n`, this logic will append the - // second line instead. - let maxCharsAppended = completion.content.indexOf('\n', completion.content.at(0) === '\n' ? 1 : 0) - if (maxCharsAppended === -1) { - maxCharsAppended = completion.content.length - } - - // We also cache the completion with the exact (= untrimmed) prefix - // for the separate lookup mode used for deletions - if (trimEndOnLastLineIfWhitespaceOnly(completion.prefix) !== completion.prefix) { - this.insertCompletion(completion.prefix, logId, completion, true) - } - - for (let i = 0; i <= maxCharsAppended; i++) { - const key = trimEndOnLastLineIfWhitespaceOnly(completion.prefix) + completion.content.slice(0, i) - this.insertCompletion(key, logId, completion, key === completion.prefix) - } - } - } - - public updateLogId(oldLogId: string, newLogId: string): void { - const entries = this.cache.values() - for (const value of entries) { - if (value && 'logId' in value && value.logId === oldLogId) { - value.logId = newLogId - } - } - } - - private insertCompletion(key: string, logId: string, completion: Completion, isExactPrefix: boolean): void { - let existingCompletions: Completion[] = [] - if (this.cache.has(key)) { - existingCompletions = this.cache.get(key)!.completions - } - - const cachedCompletion: CachedCompletions = { - logId, - isExactPrefix, - completions: existingCompletions.concat(completion), - } - - this.cache.set(key, cachedCompletion) - } -} diff --git a/vscode/src/completions/request-manager.test.ts b/vscode/src/completions/request-manager.test.ts index e3921228b366..0b2400f498cb 100644 --- a/vscode/src/completions/request-manager.test.ts +++ b/vscode/src/completions/request-manager.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { vsCodeMocks } from '../testutils/mocks' -import { CompletionsCache } from './cache' import { Provider } from './providers/provider' import { RequestManager } from './request-manager' import { Completion } from './types' @@ -51,8 +50,7 @@ function createProvider(prefix: string) { describe('RequestManager', () => { let createRequest: (prefix: string, provider: Provider) => Promise beforeEach(() => { - const cache = new CompletionsCache() - const requestManager = new RequestManager(cache) + const requestManager = new RequestManager() createRequest = (prefix: string, provider: Provider) => requestManager.request(DOCUMENT_URI, LOG_ID, prefix, [provider], [], new AbortController().signal) @@ -98,25 +96,4 @@ describe('RequestManager', () => { expect((await promise1)[0].content).toBe('log();') expect(provider1.didFinishNetworkRequest).toBe(true) }) - - it('serves request from cache when a prior request resolves', async () => { - const prefix1 = 'console.' - const provider1 = createProvider(prefix1) - const promise1 = createRequest(prefix1, provider1) - - const prefix2 = 'console.log(' - const provider2 = createProvider(prefix2) - const promise2 = createRequest(prefix2, provider2) - - provider1.resolveRequest(["log('hello')"]) - - expect((await promise1)[0].content).toBe("log('hello')") - expect((await promise2)[0].content).toBe("'hello')") - - expect(provider1.didFinishNetworkRequest).toBe(true) - expect(provider2.didFinishNetworkRequest).toBe(false) - - // Ensure that the completed network request does not cause issues - provider2.resolveRequest(["'world')"]) - }) }) diff --git a/vscode/src/completions/request-manager.ts b/vscode/src/completions/request-manager.ts index 3440f0b3f372..7ae61ee3c05d 100644 --- a/vscode/src/completions/request-manager.ts +++ b/vscode/src/completions/request-manager.ts @@ -1,16 +1,7 @@ -import { CompletionsCache } from './cache' import { ReferenceSnippet } from './context' -import { logCompletionEvent } from './logger' import { CompletionProviderTracer, Provider } from './providers/provider' import { Completion } from './types' -interface Request { - prefix: string - tracer?: CompletionProviderTracer - resolve(completions: Completion[]): void - reject(error: Error): void -} - /** * This class can handle concurrent requests for code completions. The idea is * that requests are not cancelled even when the user continues typing in the @@ -18,10 +9,6 @@ interface Request { * return them when the user triggers a completion again. */ export class RequestManager { - private readonly requests: Map = new Map() - - constructor(private completionsCache: CompletionsCache | null) {} - public async request( documentUri: string, logId: string, @@ -31,107 +18,20 @@ export class RequestManager { signal: AbortSignal, tracer?: CompletionProviderTracer ): Promise { - let resolve: Request['resolve'] = () => {} - let reject: Request['reject'] = () => {} - const requestPromise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - - const request: Request = { - prefix, - resolve, - reject, - tracer, - } - this.startRequest(request, documentUri, logId, providers, context, signal) - - return requestPromise - } - - private startRequest( - request: Request, - documentUri: string, - logId: string, - providers: Provider[], - context: ReferenceSnippet[], - signal: AbortSignal - ): void { // We forward a different abort controller to the network request so we // can cancel the network request independently of the user cancelling // the completion. const networkRequestAbortController = new AbortController() - this.addRequest(documentUri, request) - - Promise.all( - providers.map(c => c.generateCompletions(networkRequestAbortController.signal, context, request.tracer)) + return Promise.all( + providers.map(c => c.generateCompletions(networkRequestAbortController.signal, context, tracer)) ) .then(res => res.flat()) .then(completions => { - // Add the completed results to the cache, even if the request - // was cancelled before or completed via a cache retest of a - // previous request. - this.completionsCache?.add(logId, completions) - if (signal.aborted) { throw new Error('aborted') } - - request.resolve(completions) - }) - .catch(error => { - request.reject(error) + return completions }) - .finally(() => { - this.removeRequest(documentUri, request) - this.retestCaches(documentUri) - }) - } - - /** - * When one network request completes and the item is being added to the - * completion cache, we check all pending requests for the same document to - * see if we can synthesize a completion response from the new cache. - */ - private retestCaches(documentUri: string): void { - const requests = this.requests.get(documentUri) - if (!requests) { - return - } - - for (const request of requests) { - const cachedCompletions = this.completionsCache?.get(request.prefix) - if (cachedCompletions) { - logCompletionEvent('synthesizedFromParallelRequest') - request.resolve(cachedCompletions.completions) - this.removeRequest(documentUri, request) - } - } - } - - private addRequest(documentUri: string, request: Request): void { - let requestsForDocument: Request[] = [] - if (this.requests.has(documentUri)) { - requestsForDocument = this.requests.get(documentUri)! - } else { - this.requests.set(documentUri, requestsForDocument) - } - requestsForDocument.push(request) - } - - private removeRequest(documentUri: string, request: Request): void { - const requestsForDocument = this.requests.get(documentUri) - const index = requestsForDocument?.indexOf(request) - - if (requestsForDocument === undefined || index === undefined || index === -1) { - return - } - - requestsForDocument.splice(index, 1) - - if (requestsForDocument.length === 0) { - this.requests.delete(documentUri) - } } } diff --git a/vscode/src/completions/vscodeInlineCompletionItemProvider.test.ts b/vscode/src/completions/vscodeInlineCompletionItemProvider.test.ts index 9ff25e1c389a..1e998ac7cfd1 100644 --- a/vscode/src/completions/vscodeInlineCompletionItemProvider.test.ts +++ b/vscode/src/completions/vscodeInlineCompletionItemProvider.test.ts @@ -12,7 +12,6 @@ import { import { CodyStatusBar } from '../services/StatusBar' import { vsCodeMocks } from '../testutils/mocks' -import { CompletionsCache } from './cache' import { DocumentHistory } from './history' import { createProviderConfig } from './providers/anthropic' import { completion, documentAndPosition } from './testHelpers' @@ -79,7 +78,7 @@ describe('Cody completions', () => { */ let complete: ( code: string, - responses?: CompletionResponse[] | 'stall', + responses?: CompletionResponse[], languageId?: string, context?: vscode.InlineCompletionContext ) => Promise<{ @@ -87,10 +86,9 @@ describe('Cody completions', () => { completions: vscode.InlineCompletionItem[] }> beforeEach(() => { - const cache = new CompletionsCache() complete = async ( code: string, - responses?: CompletionResponse[] | 'stall', + responses?: CompletionResponse[], languageId: string = 'typescript', context: vscode.InlineCompletionContext = { triggerKind: vsCodeMocks.InlineCompletionTriggerKind.Automatic, @@ -105,10 +103,6 @@ describe('Cody completions', () => { const completionsClient: Pick = { complete(params: CompletionParameters): Promise { requests.push(params) - if (responses === 'stall') { - // Creates a stalling request that never responds - return new Promise(() => {}) - } return Promise.resolve(responses?.[requestCounter++] || { completion: '', stopReason: 'unknown' }) }, } @@ -122,7 +116,6 @@ describe('Cody completions', () => { history: DUMMY_DOCUMENT_HISTORY, codebaseContext: DUMMY_CODEBASE_CONTEXT, disableTimeouts: true, - cache, }) if (!code.includes(CURSOR_MARKER)) { @@ -1059,14 +1052,4 @@ describe('Cody completions', () => { expect(completions[0].insertText).toBe("console.log('foo')") }) }) - - describe('completions cache', () => { - it('synthesizes a completion from a prior request', async () => { - await complete('console.█', [completion`log('Hello, world!');`]) - - const { completions } = await complete('console.log(█', 'stall') - - expect(completions[0].insertText).toBe("'Hello, world!');") - }) - }) }) diff --git a/vscode/src/completions/vscodeInlineCompletionItemProvider.ts b/vscode/src/completions/vscodeInlineCompletionItemProvider.ts index d9b7f972208a..e14563d303d6 100644 --- a/vscode/src/completions/vscodeInlineCompletionItemProvider.ts +++ b/vscode/src/completions/vscodeInlineCompletionItemProvider.ts @@ -8,7 +8,6 @@ import { CodebaseContext } from '@sourcegraph/cody-shared/src/codebase-context' import { debug } from '../log' import { CodyStatusBar } from '../services/StatusBar' -import { CachedCompletions, CompletionsCache } from './cache' import { getContext, GetContextOptions, GetContextResult } from './context' import { getCurrentDocContext } from './document' import { DocumentHistory } from './history' @@ -30,7 +29,6 @@ interface CodyCompletionItemProviderConfig { suffixPercentage?: number disableTimeouts?: boolean isEmbeddingsContextEnabled?: boolean - cache: CompletionsCache | null completeSuggestWidgetSelection?: boolean tracer?: ProvideInlineCompletionItemsTracer | null contextFetcher?: (options: GetContextOptions) => Promise @@ -49,7 +47,6 @@ export class InlineCompletionItemProvider implements vscode.InlineCompletionItem private readonly config: Required private requestManager: RequestManager - private previousCompletionLogId?: string constructor({ responsePercentage = 0.1, @@ -94,7 +91,7 @@ export class InlineCompletionItemProvider implements vscode.InlineCompletionItem this.maxPrefixChars = Math.floor(this.promptChars * this.config.prefixPercentage) this.maxSuffixChars = Math.floor(this.promptChars * this.config.suffixPercentage) - this.requestManager = new RequestManager(this.config.cache) + this.requestManager = new RequestManager() debug('CodyCompletionProvider:initialized', `provider: ${this.config.providerConfig.identifier}`) @@ -180,74 +177,11 @@ export class InlineCompletionItemProvider implements vscode.InlineCompletionItem return emptyCompletions() } - let cachedCompletions: CachedCompletions | undefined - - // Avoid showing completions when we're deleting code (Cody can only insert code at the - // moment) - const lastChange = this.lastContentChanges.get(document.fileName) ?? 'add' - if (lastChange === 'del') { - // When a line was deleted, only look up cached items and only include them if the - // untruncated prefix matches. This fixes some weird issues where the completion would - // render if you insert whitespace but not on the original place when you delete it - // again - cachedCompletions = this.config.cache?.get(docContext.prefix, false) - if (!cachedCompletions?.isExactPrefix) { - return emptyCompletions() - } - } - - // If cachedCompletions was already set by the above logic, we don't have to query the cache - // again. - cachedCompletions = cachedCompletions ?? this.config.cache?.get(docContext.prefix) - - // We create a log entry after determining if we have a potential cache hit. This is - // necessary to make sure that typing text of a displayed completion will not log a new - // completion on every keystroke - // - // However we only log a completion as started if it's either served from cache _or_ the - // debounce interval has passed to ensure we don't log too many start events where we end up - // not doing any work at all - const useLogIdFromPreviousCompletion = - cachedCompletions?.logId && cachedCompletions?.logId === this.previousCompletionLogId - if (!useLogIdFromPreviousCompletion) { - CompletionLogger.clear() - } - const logId = useLogIdFromPreviousCompletion - ? cachedCompletions!.logId - : CompletionLogger.create({ - multiline, - providerIdentifier: this.config.providerConfig.identifier, - languageId: document.languageId, - }) - this.previousCompletionLogId = logId - - if (cachedCompletions) { - // When we serve a completion from the cache and create a new log - // id, we want to ensure to only refer to the new id for future - // cache retrievals. If we don't do this, every subsequent cache hit - // would otherwise no longer match the previous completion ID and we - // would log a new completion each time, even if the user just - // continues typing on the currently displayed completion. - if (logId !== cachedCompletions.logId) { - this.config.cache?.updateLogId(cachedCompletions.logId, logId) - } - - tracer?.({ cacheHit: true }) - CompletionLogger.start(logId) - return this.prepareCompletions( - logId, - cachedCompletions.completions, - document, - context, - position, - docContext.prefix, - docContext.suffix, - multiline, - document.languageId, - true, - abortController.signal - ) - } + const logId = CompletionLogger.create({ + multiline, + providerIdentifier: this.config.providerConfig.identifier, + languageId: document.languageId, + }) tracer?.({ cacheHit: false }) const completers: Provider[] = [] diff --git a/vscode/src/configuration.test.ts b/vscode/src/configuration.test.ts index 676ff5aa0c39..26b13ec735f1 100644 --- a/vscode/src/configuration.test.ts +++ b/vscode/src/configuration.test.ts @@ -30,7 +30,6 @@ describe('getConfiguration', () => { autocompleteAdvancedProvider: 'anthropic', autocompleteAdvancedServerEndpoint: null, autocompleteAdvancedAccessToken: null, - autocompleteAdvancedCache: true, autocompleteAdvancedEmbeddings: true, autocompleteExperimentalCompleteSuggestWidgetSelection: false, }) @@ -77,8 +76,6 @@ describe('getConfiguration', () => { return 'https://example.com/llm' case 'cody.autocomplete.advanced.accessToken': return 'foobar' - case 'cody.autocomplete.advanced.cache': - return false case 'cody.autocomplete.advanced.embeddings': return false case 'cody.autocomplete.experimental.completeSuggestWidgetSelection': @@ -120,7 +117,6 @@ describe('getConfiguration', () => { autocompleteAdvancedProvider: 'unstable-codegen', autocompleteAdvancedServerEndpoint: 'https://example.com/llm', autocompleteAdvancedAccessToken: 'foobar', - autocompleteAdvancedCache: false, autocompleteAdvancedEmbeddings: false, autocompleteExperimentalCompleteSuggestWidgetSelection: false, }) diff --git a/vscode/src/configuration.ts b/vscode/src/configuration.ts index 8c9e396d1436..575113149fe1 100644 --- a/vscode/src/configuration.ts +++ b/vscode/src/configuration.ts @@ -75,7 +75,6 @@ export function getConfiguration(config: ConfigGetter): Configuration { null ), autocompleteAdvancedAccessToken: config.get(CONFIG_KEY.autocompleteAdvancedAccessToken, null), - autocompleteAdvancedCache: config.get(CONFIG_KEY.autocompleteAdvancedCache, true), autocompleteAdvancedEmbeddings: config.get(CONFIG_KEY.autocompleteAdvancedEmbeddings, true), autocompleteExperimentalCompleteSuggestWidgetSelection: config.get( CONFIG_KEY.autocompleteExperimentalCompleteSuggestWidgetSelection, diff --git a/vscode/src/main.ts b/vscode/src/main.ts index 7c2583bb2835..ab59fb27f8dd 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -10,7 +10,6 @@ import { ContextProvider } from './chat/ContextProvider' import { InlineChatViewManager } from './chat/InlineChatViewProvider' import { MessageProviderOptions } from './chat/MessageProvider' import { CODY_FEEDBACK_URL } from './chat/protocol' -import { CompletionsCache } from './completions/cache' import { VSCodeDocumentHistory } from './completions/history' import * as CompletionsLogger from './completions/logger' import { createProviderConfig } from './completions/providers/createProvider' @@ -423,7 +422,6 @@ function createCompletionsProvider( history, statusBar, codebaseContext, - cache: config.autocompleteAdvancedCache ? new CompletionsCache() : null, isEmbeddingsContextEnabled: config.autocompleteAdvancedEmbeddings, completeSuggestWidgetSelection: config.autocompleteExperimentalCompleteSuggestWidgetSelection, }) diff --git a/vscode/test/completions/completions-dataset.ts b/vscode/test/completions/completions-dataset.ts index 557843f45004..c3588b71b3fb 100644 --- a/vscode/test/completions/completions-dataset.ts +++ b/vscode/test/completions/completions-dataset.ts @@ -476,7 +476,6 @@ export const completionsDataset: Sample[] = [ import { vsCodeMocks } from '../testutils/mocks' import { CodyCompletionItemProvider } from '.' - import { CompletionsCache } from './cache' import { History } from './history' import { createProviderConfig } from './providers/anthropic' @@ -565,7 +564,6 @@ export const completionsDataset: Sample[] = [ completions: vscode.InlineCompletionItem[] }> beforeEach(() => { - const cache = new CompletionsCache() complete = async ( code: string, responses?: CompletionResponse[] | 'stall', diff --git a/vscode/test/completions/run-code-completions-on-dataset.ts b/vscode/test/completions/run-code-completions-on-dataset.ts index 9cb48ff96199..59e554840c9e 100644 --- a/vscode/test/completions/run-code-completions-on-dataset.ts +++ b/vscode/test/completions/run-code-completions-on-dataset.ts @@ -63,7 +63,6 @@ async function initCompletionsProvider(context: GetContextResult): Promise Promise.resolve(context), }) From 5a5a7c707ff44b0e3bfd41a0963bc780505685b1 Mon Sep 17 00:00:00 2001 From: Quinn Slack Date: Tue, 1 Aug 2023 00:00:51 -0700 Subject: [PATCH 2/2] getInlineCompletions, separate provider, update tests --- .../completions/getInlineCompletions.test.ts | 1294 +++++++++++++++++ .../src/completions/getInlineCompletions.ts | 471 ++++++ vscode/src/completions/logger.ts | 2 +- .../processInlineCompletions.test.ts | 48 + .../completions/processInlineCompletions.ts | 99 ++ vscode/src/completions/providers/anthropic.ts | 2 +- .../providers/unstable-azure-openai.ts | 2 +- .../src/completions/request-manager.test.ts | 26 +- vscode/src/completions/request-manager.ts | 65 +- vscode/src/completions/shared-post-process.ts | 35 - vscode/src/completions/testHelpers.ts | 5 +- vscode/src/completions/tracer/index.ts | 3 +- vscode/src/completions/tracer/traceView.ts | 21 +- vscode/src/completions/types.ts | 11 +- ...vscodeInlineCompletionItemProvider.test.ts | 1089 +------------- .../vscodeInlineCompletionItemProvider.ts | 388 +---- vscode/src/testutils/textDocument.ts | 4 + .../run-code-completions-on-dataset.ts | 1 - 18 files changed, 2177 insertions(+), 1389 deletions(-) create mode 100644 vscode/src/completions/getInlineCompletions.test.ts create mode 100644 vscode/src/completions/getInlineCompletions.ts create mode 100644 vscode/src/completions/processInlineCompletions.test.ts create mode 100644 vscode/src/completions/processInlineCompletions.ts delete mode 100644 vscode/src/completions/shared-post-process.ts diff --git a/vscode/src/completions/getInlineCompletions.test.ts b/vscode/src/completions/getInlineCompletions.test.ts new file mode 100644 index 000000000000..72ee43a4b26d --- /dev/null +++ b/vscode/src/completions/getInlineCompletions.test.ts @@ -0,0 +1,1294 @@ +import dedent from 'dedent' +import { describe, expect, test } from 'vitest' +import { URI } from 'vscode-uri' + +import { SourcegraphCompletionsClient } from '@sourcegraph/cody-shared/src/sourcegraph-api/completions/client' +import { + CompletionParameters, + CompletionResponse, +} from '@sourcegraph/cody-shared/src/sourcegraph-api/completions/types' + +import { vsCodeMocks } from '../testutils/mocks' +import { range } from '../testutils/textDocument' + +import { + getInlineCompletions as _getInlineCompletions, + InlineCompletionsParams, + InlineCompletionsResultSource, + LastInlineCompletionCandidate, +} from './getInlineCompletions' +import { createProviderConfig } from './providers/anthropic' +import { RequestManager } from './request-manager' +import { completion, documentAndPosition } from './testHelpers' + +// The dedent package seems to replace `\t` with `\\t` so in order to insert a tab character, we +// have to use interpolation. We abbreviate this to `T` because ${T} is exactly 4 characters, +// mimicking the default indentation of four spaces +const T = '\t' + +const URI_FIXTURE = URI.parse('file:///test.ts') + +/** + * A test helper to create the parameters for {@link getInlineCompletions}. + * + * The code example must include a block character (█) to denote the current cursor position. + */ +function params( + code: string, + responses: CompletionResponse[], + { + languageId = 'typescript', + requests = [], + context = { + triggerKind: vsCodeMocks.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: undefined, + }, + ...params + }: Partial> & { + languageId?: string + requests?: CompletionParameters[] + } = {} +): InlineCompletionsParams { + let requestCounter = 0 + const completionsClient: Pick = { + complete(params: CompletionParameters): Promise { + requests.push(params) + return Promise.resolve(responses?.[requestCounter++] || { completion: '', stopReason: 'unknown' }) + }, + } + const providerConfig = createProviderConfig({ + completionsClient, + contextWindowTokens: 2048, + }) + + const { document, position } = documentAndPosition(code, languageId, URI_FIXTURE.toString()) + + return { + document, + position, + context, + promptChars: 1000, + maxPrefixChars: 1000, + maxSuffixChars: 1000, + isEmbeddingsContextEnabled: true, + providerConfig, + responsePercentage: 0.4, + prefixPercentage: 0.3, + suffixPercentage: 0.3, + toWorkspaceRelativePath: () => 'test.ts', + requestManager: new RequestManager(), + ...params, + } +} + +/** + * Wraps the `getInlineCompletions` function to omit `logId` so that test expected values can omit + * it and be stable. + */ +async function getInlineCompletions( + ...args: Parameters +): Promise>>, 'logId'> | null> { + const result = await _getInlineCompletions(...args) + if (result) { + const { logId: _discard, ...rest } = result + return rest + } + return result +} + +/** Test helper for when you just want to assert the completion strings. */ +async function getInlineCompletionsInsertText(...args: Parameters): Promise { + const result = await getInlineCompletions(...args) + return result?.items.map(c => c.insertText) ?? [] +} + +type V = Awaited> + +describe('getInlineCompletions', () => { + test('after whitespace', async () => + expect(await getInlineCompletions(params('foo = █', [completion`bar`]))).toEqual({ + items: [{ insertText: 'bar' }], + source: InlineCompletionsResultSource.Network, + })) + + test('end of word', async () => + expect(await getInlineCompletions(params('foo█', [completion`()`]))).toEqual({ + items: [{ insertText: '()' }], + source: InlineCompletionsResultSource.Network, + })) + + test('middle of line', async () => + expect( + await getInlineCompletions(params('function bubbleSort(█)', [completion`array) {`, completion`items) {`])) + ).toEqual({ + items: [ + { insertText: 'array) {', range: range(0, 20, 0, 21) }, + { insertText: 'items) {', range: range(0, 20, 0, 21) }, + ], + source: InlineCompletionsResultSource.Network, + })) + + test('single-line mode only completes one line', async () => + expect( + await getInlineCompletions( + params( + ` + function test() { + console.log(1); + █ + } + `, + [ + completion` + ├if (true) { + console.log(3); + } + console.log(4);┤ + ┴┴┴┴`, + ] + ) + ) + ).toEqual({ + items: [{ insertText: 'if (true) {' }], + source: InlineCompletionsResultSource.Network, + })) + + test('with selectedCompletionInfo', async () => + expect( + await getInlineCompletions( + params('array.so█', [completion`rt()`], { + context: { + triggerKind: vsCodeMocks.InlineCompletionTriggerKind.Automatic, + selectedCompletionInfo: { text: 'sort', range: new vsCodeMocks.Range(0, 6, 0, 8) }, + }, + }) + ) + ).toEqual({ + items: [{ insertText: 'rt()' }], + source: InlineCompletionsResultSource.Network, + })) + + test('preserves leading whitespace when prefix has no trailing whitespace', async () => + expect( + await getInlineCompletions( + params('const isLocalHost = window.location.host█', [completion`├ === 'localhost'┤`]) + ) + ).toEqual({ + items: [{ insertText: " === 'localhost'" }], + source: InlineCompletionsResultSource.Network, + })) + + test('collapses leading whitespace when prefix has trailing whitespace', async () => + expect(await getInlineCompletions(params('const x = █', [completion`├${T}7┤`]))).toEqual({ + items: [{ insertText: '7' }], + source: InlineCompletionsResultSource.Network, + })) + + describe('same line suffix behavior', () => { + test('does not trigger when there are alphanumeric chars in the line suffix', async () => + expect(await getInlineCompletions(params('foo = █ // x', []))).toBeNull()) + + test('triggers when there are only non-alphanumeric chars in the line suffix', async () => + expect(await getInlineCompletions(params('foo = █;', []))).toBeTruthy()) + }) + + describe('reuseResultFromLastCandidate', () => { + function lastCandidate(code: string, insertText: string): LastInlineCompletionCandidate { + const { document, position } = documentAndPosition(code) + return { + uri: document.uri, + originalTriggerPosition: position, + originalTriggerLinePrefix: document.lineAt(position).text.slice(0, position.character), + result: { + logId: '1', + items: [{ insertText }], + }, + } + } + + test('reused when typing forward as suggested', async () => + // The user types `\n`, sees ghost text `const x = 123`, then types `const x = 1` (i.e., + // all but the last 2 characters of the ghost text). The original completion should + // still display. + expect( + await getInlineCompletions( + params('\nconst x = 1█', [], { lastCandidate: lastCandidate('\n█', 'const x = 123') }) + ) + ).toEqual({ + items: [{ insertText: '23' }], + source: InlineCompletionsResultSource.LastCandidate, + })) + + test('reused when typing forward as suggested through partial whitespace', async () => + // The user types ` `, sees ghost text ` x`, then types ` `. The original completion + // should still display. + expect( + await getInlineCompletions(params(' █', [], { lastCandidate: lastCandidate(' █', ' x') })) + ).toEqual({ + items: [{ insertText: 'x' }], + source: InlineCompletionsResultSource.LastCandidate, + })) + + test('reused when typing forward as suggested through all whitespace', async () => + // The user sees ghost text ` x`, then types ` `. The original completion should still + // display. + expect( + await getInlineCompletions(params(' █', [], { lastCandidate: lastCandidate('█', ' x') })) + ).toEqual({ + items: [{ insertText: 'x' }], + source: InlineCompletionsResultSource.LastCandidate, + })) + + test('reused when adding leading whitespace', async () => + // The user types ``, sees ghost text `x = 1`, then types ` ` (space). The original + // completion should be reused. + expect( + await getInlineCompletions(params(' █', [], { lastCandidate: lastCandidate('█', 'x = 1') })) + ).toEqual({ + items: [{ insertText: 'x = 1' }], + source: InlineCompletionsResultSource.LastCandidate, + })) + + test('reused when the deleting back to the start of the original trigger (but no further)', async () => + // The user types `const x`, accepts a completion to `const x = 123`, then deletes back + // to `const x` (i.e., to the start of the original trigger). The original completion + // should be reused. + expect( + await getInlineCompletions( + params('const x█', [], { lastCandidate: lastCandidate('const x█', ' = 123') }) + ) + ).toEqual({ + items: [{ insertText: ' = 123' }], + source: InlineCompletionsResultSource.LastCandidate, + })) + + test('not reused when deleting past the entire original trigger', async () => + // The user types `const x`, accepts a completion to `const x = 1`, then deletes back to + // `const ` (i.e., *past* the start of the original trigger). The original ghost text + // should not be reused. + expect( + await getInlineCompletions( + params('const █', [], { + lastCandidate: lastCandidate('const x█', ' = 1'), + }) + ) + ).toEqual({ + items: [], + source: InlineCompletionsResultSource.Network, + })) + + test('not reused when deleting the entire non-whitespace line', async () => + // The user types `const x`, then deletes the entire line. The original ghost text + // should not be reused. + expect( + await getInlineCompletions( + params('█', [], { + lastCandidate: lastCandidate('const x█', ' = 1'), + }) + ) + ).toEqual({ + items: [], + source: InlineCompletionsResultSource.Network, + })) + + test('not reused when prefix changes', async () => + // The user types `x`, then deletes it, then types `y`. The original ghost text should + // not be reused. + expect( + await getInlineCompletions( + params('y█', [], { + lastCandidate: lastCandidate('x█', ' = 1'), + }) + ) + ).toEqual({ + items: [], + source: InlineCompletionsResultSource.Network, + })) + + describe('deleting leading whitespace', () => { + const candidate = lastCandidate('\t\t█', 'const x = 1') + + test('reused when deleting some (not all) leading whitespace', async () => + // The user types on a new line `\t\t`, sees ghost text `const x = 1`, then + // deletes one `\t`. The same ghost text should still be displayed. + expect(await getInlineCompletions(params('\t█', [], { lastCandidate: candidate }))).toEqual({ + items: [{ insertText: '\tconst x = 1' }], + source: InlineCompletionsResultSource.LastCandidate, + })) + + test('reused when deleting all leading whitespace', async () => + // The user types on a new line `\t\t`, sees ghost text `const x = 1`, then deletes + // all leading whitespace (both `\t\t`). The same ghost text should still be + // displayed. + expect(await getInlineCompletions(params('█', [], { lastCandidate: candidate }))).toEqual({ + items: [{ insertText: '\t\tconst x = 1' }], + source: InlineCompletionsResultSource.LastCandidate, + })) + + test('not reused when different leading whitespace is added at end of prefix', async () => + // The user types on a new line `\t\t`, sees ghost text `const x = 1`, then deletes + // `\t` and adds ` ` (space). The same ghost text should not still be displayed. + expect(await getInlineCompletions(params('\t █', [], { lastCandidate: candidate }))).toEqual({ + items: [], + source: InlineCompletionsResultSource.Network, + })) + + test('not reused when different leading whitespace is added at start of prefix', async () => + // The user types on a new line `\t\t`, sees ghost text `const x = 1`, then deletes + // `\t\t` and adds ` \t` (space). The same ghost text should not still be displayed. + expect(await getInlineCompletions(params(' \t█', [], { lastCandidate: candidate }))).toEqual({ + items: [], + source: InlineCompletionsResultSource.Network, + })) + + test('not reused when prefix replaced by different leading whitespace', async () => + // The user types on a new line `\t\t`, sees ghost text `const x = 1`, then deletes + // `\t\t` and adds ` ` (space). The same ghost text should not still be displayed. + expect(await getInlineCompletions(params(' █', [], { lastCandidate: candidate }))).toEqual({ + items: [], + source: InlineCompletionsResultSource.Network, + })) + }) + + test('reused for a multi-line completion', async () => + // The user types ``, sees ghost text `x\ny`, then types ` ` (space). The original + // completion should be reused. + expect( + await getInlineCompletions(params('x█', [], { lastCandidate: lastCandidate('█', 'x\ny') })) + ).toEqual({ + items: [{ insertText: '\ny' }], + source: InlineCompletionsResultSource.LastCandidate, + })) + + test('reused when adding leading whitespace for a multi-line completion', async () => + // The user types ``, sees ghost text `x\ny`, then types ` `. The original completion + // should be reused. + expect( + await getInlineCompletions(params(' █', [], { lastCandidate: lastCandidate('█', 'x\ny') })) + ).toEqual({ + items: [{ insertText: 'x\ny' }], + source: InlineCompletionsResultSource.LastCandidate, + })) + }) + + describe('bad completion starts', () => { + test.each([ + [completion`├➕ 1┤`, '1'], + [completion`├${'\u200B'} 1┤`, '1'], + [completion`├. 1┤`, '1'], + [completion`├+ 1┤`, '1'], + [completion`├- 1┤`, '1'], + ])('fixes %s to %s', async (completion, expected) => + expect(await getInlineCompletions(params('█', [completion]))).toEqual({ + items: [{ insertText: expected }], + source: InlineCompletionsResultSource.Network, + }) + ) + }) + + describe('odd indentation', () => { + test('filters out odd indentation in single-line completions', async () => + expect(await getInlineCompletions(params('const foo = █', [completion`├ 1┤`]))).toEqual({ + items: [{ insertText: '1' }], + source: InlineCompletionsResultSource.Network, + })) + }) + + describe('multi-line completions', () => { + test('removes trailing spaces', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + function bubbleSort() { + █ + }`, + [ + completion` + ├console.log('foo')${' '} + console.log('bar')${' '} + console.log('baz')${' '}┤ + ┴┴┴┴`, + ] + ) + ) + )[0] + ).toMatchInlineSnapshot(` + "console.log('foo') + console.log('bar') + console.log('baz')" + `) + }) + + test('honors a leading new line in the completion', async () => { + const items = await getInlineCompletionsInsertText( + params( + dedent` + describe('bubbleSort', () => { + it('bubbleSort test case', () => {█ + + }) + })`, + [ + completion` + ├${' '} + const unsortedArray = [4,3,78,2,0,2] + const sortedArray = bubbleSort(unsortedArray) + expect(sortedArray).toEqual([0,2,2,3,4,78]) + }) + }┤`, + ] + ) + ) + + expect(items[0]).toMatchInlineSnapshot(` + " + const unsortedArray = [4,3,78,2,0,2] + const sortedArray = bubbleSort(unsortedArray) + expect(sortedArray).toEqual([0,2,2,3,4,78])" + `) + }) + + test('cuts-off redundant closing brackets on the start indent level', async () => { + const items = await getInlineCompletionsInsertText( + params( + dedent` + describe('bubbleSort', () => { + it('bubbleSort test case', () => {█ + + }) + })`, + [ + completion` + ├const unsortedArray = [4,3,78,2,0,2] + const sortedArray = bubbleSort(unsortedArray) + expect(sortedArray).toEqual([0,2,2,3,4,78]) + }) + }┤`, + ] + ) + ) + + expect(items[0]).toMatchInlineSnapshot(` + "const unsortedArray = [4,3,78,2,0,2] + const sortedArray = bubbleSort(unsortedArray) + expect(sortedArray).toEqual([0,2,2,3,4,78])" + `) + }) + + test('keeps the closing bracket', async () => { + const items = await getInlineCompletionsInsertText( + params('function printHello(█)', [ + completion` + ├) { + console.log('Hello'); + }┤`, + ]) + ) + + expect(items[0]).toMatchInlineSnapshot(` + ") { + console.log('Hello'); + }" + `) + }) + + test('triggers a multi-line completion at the start of a block', async () => { + const requests: CompletionParameters[] = [] + await getInlineCompletions(params('function bubbleSort() {\n █', [], { requests })) + expect(requests).toHaveLength(3) + expect(requests[0].stopSequences).not.toContain('\n') + }) + + test('uses an indentation based approach to cut-off completions', async () => { + const items = await getInlineCompletionsInsertText( + params( + dedent` + class Foo { + constructor() { + █ + } + } + `, + [ + completion` + ├console.log('foo') + } + + add() { + console.log('bar') + }┤ + ┴┴┴┴`, + completion` + ├if (foo) { + console.log('foo1'); + } + } + + add() { + console.log('bar') + }┤ + ┴┴┴┴`, + ] + ) + ) + + expect(items[0]).toBe("if (foo) {\n console.log('foo1');\n }") + expect(items[1]).toBe("console.log('foo')") + }) + + test('cuts-off the whole completions when suffix is very similar to suffix line', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + function() { + █ + console.log('bar') + } + `, + [ + completion` + ├console.log('foo') + console.log('bar') + }┤`, + ] + ) + ) + ).length + ).toBe(0) + }) + + test('does not support multi-line completion on unsupported languages', async () => { + const requests: CompletionParameters[] = [] + await getInlineCompletions(params('function looksLegit() {\n █', [], { languageId: 'elixir', requests })) + expect(requests).toHaveLength(1) + expect(requests[0].stopSequences).toContain('\n\n') + }) + + test('requires an indentation to start a block', async () => { + const requests: CompletionParameters[] = [] + await getInlineCompletions(params('function bubbleSort() {\n█', [], { requests })) + expect(requests).toHaveLength(1) + expect(requests[0].stopSequences).toContain('\n\n') + }) + + test('works with python', async () => { + const requests: CompletionParameters[] = [] + const items = await getInlineCompletionsInsertText( + params( + dedent` + for i in range(11): + if i % 2 == 0: + █ + `, + [ + completion` + ├print(i) + elif i % 3 == 0: + print(f"Multiple of 3: {i}") + else: + print(f"ODD {i}") + + for i in range(12): + print("unrelated")┤`, + ], + { languageId: 'python', requests } + ) + ) + expect(requests).toHaveLength(3) + expect(requests[0].stopSequences).not.toContain('\n') + expect(items[0]).toMatchInlineSnapshot(` + "print(i) + elif i % 3 == 0: + print(f\\"Multiple of 3: {i}\\") + else: + print(f\\"ODD {i}\\")" + `) + }) + + test('works with java', async () => { + const requests: CompletionParameters[] = [] + const items = await getInlineCompletionsInsertText( + params( + dedent` + for (int i = 0; i < 11; i++) { + if (i % 2 == 0) { + █ + `, + [ + completion` + ├System.out.println(i); + } else if (i % 3 == 0) { + System.out.println("Multiple of 3: " + i); + } else { + System.out.println("ODD " + i); + } + } + + for (int i = 0; i < 12; i++) { + System.out.println("unrelated"); + }┤`, + ], + { languageId: 'java', requests } + ) + ) + expect(requests).toHaveLength(3) + expect(requests[0].stopSequences).not.toContain('\n') + expect(items[0]).toMatchInlineSnapshot(` + "System.out.println(i); + } else if (i % 3 == 0) { + System.out.println(\\"Multiple of 3: \\" + i); + } else { + System.out.println(\\"ODD \\" + i); + }" + `) + }) + + // TODO: Detect `}\nelse\n{` pattern for else skip logic + test('works with csharp', async () => { + const requests: CompletionParameters[] = [] + const items = await getInlineCompletionsInsertText( + params( + dedent` + for (int i = 0; i < 11; i++) { + if (i % 2 == 0) + { + █ + `, + [ + completion` + ├Console.WriteLine(i); + } + else if (i % 3 == 0) + { + Console.WriteLine("Multiple of 3: " + i); + } + else + { + Console.WriteLine("ODD " + i); + } + + } + + for (int i = 0; i < 12; i++) + { + Console.WriteLine("unrelated"); + }┤`, + ], + { languageId: 'csharp', requests } + ) + ) + expect(requests).toHaveLength(3) + expect(requests[0].stopSequences).not.toContain('\n') + expect(items[0]).toMatchInlineSnapshot(` + "Console.WriteLine(i); + }" + `) + }) + + test('works with c++', async () => { + const requests: CompletionParameters[] = [] + const items = await getInlineCompletionsInsertText( + params( + dedent` + for (int i = 0; i < 11; i++) { + if (i % 2 == 0) { + █ + `, + [ + completion` + ├std::cout << i; + } else if (i % 3 == 0) { + std::cout << "Multiple of 3: " << i; + } else { + std::cout << "ODD " << i; + } + } + + for (int i = 0; i < 12; i++) { + std::cout << "unrelated"; + }┤`, + ], + { languageId: 'cpp', requests } + ) + ) + expect(requests).toHaveLength(3) + expect(requests[0].stopSequences).not.toContain('\n') + expect(items[0]).toMatchInlineSnapshot(` + "std::cout << i; + } else if (i % 3 == 0) { + std::cout << \\"Multiple of 3: \\" << i; + } else { + std::cout << \\"ODD \\" << i; + }" + `) + }) + + test('works with c', async () => { + const requests: CompletionParameters[] = [] + const items = await getInlineCompletionsInsertText( + params( + dedent` + for (int i = 0; i < 11; i++) { + if (i % 2 == 0) { + █ + `, + [ + completion` + ├printf("%d", i); + } else if (i % 3 == 0) { + printf("Multiple of 3: %d", i); + } else { + printf("ODD %d", i); + } + } + + for (int i = 0; i < 12; i++) { + printf("unrelated"); + }┤`, + ], + { languageId: 'c', requests } + ) + ) + expect(requests).toHaveLength(3) + expect(requests[0].stopSequences).not.toContain('\n') + expect(items[0]).toMatchInlineSnapshot(` + "printf(\\"%d\\", i); + } else if (i % 3 == 0) { + printf(\\"Multiple of 3: %d\\", i); + } else { + printf(\\"ODD %d\\", i); + }" + `) + }) + + test('works with php', async () => { + const requests: CompletionParameters[] = [] + const items = await getInlineCompletionsInsertText( + params( + dedent` + for ($i = 0; $i < 11; $i++) { + if ($i % 2 == 0) { + █ + `, + [ + completion` + ├echo $i; + } else if ($i % 3 == 0) { + echo "Multiple of 3: " . $i; + } else { + echo "ODD " . $i; + } + } + + for ($i = 0; $i < 12; $i++) { + echo "unrelated"; + }┤`, + ], + { languageId: 'c', requests } + ) + ) + + expect(requests).toHaveLength(3) + expect(requests[0].stopSequences).not.toContain('\n') + expect(items[0]).toMatchInlineSnapshot(` + "echo $i; + } else if ($i % 3 == 0) { + echo \\"Multiple of 3: \\" . $i; + } else { + echo \\"ODD \\" . $i; + }" + `) + }) + + test('works with dart', async () => { + const requests: CompletionParameters[] = [] + const items = await getInlineCompletionsInsertText( + params( + dedent` + for (int i = 0; i < 11; i++) { + if (i % 2 == 0) { + █ + `, + [ + completion` + ├print(i); + } else if (i % 3 == 0) { + print('Multiple of 3: $i'); + } else { + print('ODD $i'); + } + } + + for (int i = 0; i < 12; i++) { + print('unrelated'); + }┤`, + ], + { languageId: 'dart', requests } + ) + ) + + expect(requests).toHaveLength(3) + expect(requests[0].stopSequences).not.toContain('\n') + expect(items[0]).toMatchInlineSnapshot(` + "print(i); + } else if (i % 3 == 0) { + print('Multiple of 3: $i'); + } else { + print('ODD $i'); + }" + `) + }) + + test('skips over empty lines', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + class Foo { + constructor() { + █ + } + } + `, + [ + completion` + ├console.log('foo') + + console.log('bar') + + console.log('baz')┤ + ┴┴┴┴┴┴┴┴`, + ] + ) + ) + )[0] + ).toMatchInlineSnapshot(` + "console.log('foo') + + console.log('bar') + + console.log('baz')" + `) + }) + + test('skips over else blocks', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + if (check) { + █ + } + `, + [ + completion` + ├console.log('one') + } else { + console.log('two') + }┤`, + ] + ) + ) + )[0] + ).toMatchInlineSnapshot(` + "console.log('one') + } else { + console.log('two')" + `) + }) + + test('includes closing parentheses in the completion', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + if (check) { + █ + `, + [ + completion` + ├console.log('one') + }┤`, + ] + ) + ) + )[0] + ).toMatchInlineSnapshot(` + "console.log('one') + }" + `) + }) + + test('stops when the next non-empty line of the suffix matches', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + function myFunction() { + █ + console.log('three') + } + `, + [ + completion` + ├console.log('one') + console.log('two') + console.log('three') + console.log('four') + }┤`, + ] + ) + ) + ).length + ).toBe(0) + }) + + test('stops when the next non-empty line of the suffix matches exactly with one line completion', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + function myFunction() { + console.log('one') + █ + console.log('three') + } + `, + [ + completion` + ├console.log('three') + }┤`, + ] + ) + ) + ).length + ).toBe(0) + }) + + test('cuts off a matching line with the next line even if the completion is longer', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + function bubbleSort() { + █ + do { + swapped = false; + for (let i = 0; i < array.length - 1; i++) { + if (array[i] > array[i + 1]) { + let temp = array[i]; + array[i] = array[i + 1]; + array[i + 1] = temp; + swapped = true; + } + } + } while (swapped); + }`, + [ + completion` + ├let swapped; + do { + swapped = false; + for (let i = 0; i < array.length - 1; i++) { + if (array[i] > array[i + 1]) { + let temp = array[i]; + array[i] = array[i + 1]; + array[i + 1] = temp; + swapped = true; + } + } + } while (swapped);┤ + ┴┴┴┴`, + ] + ) + ) + )[0] + ).toBe('let swapped;') + }) + + describe('stops when the next non-empty line of the suffix matches partially', () => { + test('simple example', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + path: $GITHUB_WORKSPACE/vscode/.vscode-test/█ + key: {{ runner.os }}-pnpm-store-{{ hashFiles('**/pnpm-lock.yaml') }}`, + [ + completion` + ├pnpm-store + key: {{ runner.os }}-pnpm-{{ steps.pnpm-cache.outputs.STORE_PATH }}┤`, + ] + ) + ) + )[0] + ).toBe('pnpm-store') + }) + + test('example with return', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + console.log('<< stop completion: █') + return [] + `, + [ + completion` + lastChange was delete') + return [] + `, + ] + ) + ) + )[0] + ).toBe("lastChange was delete')") + }) + + test('example with inline comment', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + // █ + const currentFilePath = path.normalize(document.fileName) + `, + [ + completion` + Get the file path + const filePath = normalize(document.fileName) + `, + ] + ) + ) + )[0] + ).toBe('Get the file path') + }) + }) + + test('ranks results by number of lines', async () => { + const items = await getInlineCompletionsInsertText( + params( + dedent` + function test() { + █ + `, + [ + completion` + ├console.log('foo') + console.log('foo')┤ + ┴┴┴┴ + `, + completion` + ├console.log('foo') + console.log('foo') + console.log('foo') + console.log('foo') + console.log('foo')┤ + ┴┴┴┴`, + completion` + ├console.log('foo')┤ + `, + ] + ) + ) + + expect(items[0]).toMatchInlineSnapshot(` + "console.log('foo') + console.log('foo') + console.log('foo') + console.log('foo') + console.log('foo')" + `) + expect(items[1]).toMatchInlineSnapshot(` + "console.log('foo') + console.log('foo')" + `) + expect(items[2]).toBe("console.log('foo')") + }) + + test('dedupes duplicate results', async () => { + expect( + await getInlineCompletionsInsertText( + params( + dedent` + function test() { + █ + `, + [completion`return true`, completion`return true`, completion`return true`] + ) + ) + ).toEqual(['return true']) + }) + + test('handles tab/newline interop in completion truncation', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + class Foo { + constructor() { + █ + `, + [ + completion` + ├console.log('foo') + ${T}${T}if (yes) { + ${T}${T} sure() + ${T}${T}} + ${T}} + + ${T}add() {┤ + ┴┴┴┴`, + ] + ) + ) + )[0] + ).toMatchInlineSnapshot(` + "console.log('foo') + \t\tif (yes) { + \t\t sure() + \t\t} + \t}" + `) + }) + + test('does not include block end character if there is already content in the block', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + if (check) { + █ + const d = 5; + `, + [ + completion` + ├console.log('one') + }┤`, + ] + ) + ) + )[0] + ).toBe("console.log('one')") + }) + + test('does not include block end character if there is already closed bracket', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + ` + if (check) { + █ + }`, + [completion`}`] + ) + ) + ).length + ).toBe(0) + }) + + test('does not include block end character if there is already closed bracket [sort example]', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + ` + function bubbleSort(arr: number[]): number[] { + for (let i = 0; i < arr.length; i++) { + for (let j = 0; j < (arr.length - i - 1); j++) { + if (arr[j] > arr[j + 1]) { + // swap elements + let temp = arr[j]; + arr[j] = arr[j + 1]; + arr[j + 1] = temp; + } + █ + } + } + return arr; + }`, + [completion`}`] + ) + ) + ).length + ).toBe(0) + }) + + test('normalizes Cody responses starting with an empty line and following the exact same indentation as the start line', async () => { + expect( + ( + await getInlineCompletionsInsertText( + params( + dedent` + function test() { + █ + `, + [ + completion` + ├ + console.log('foo')┤ + ┴┴┴┴`, + ] + ) + ) + )[0] + ).toBe("console.log('foo')") + }) + }) + + test('uses a more complex prompt for larger files', async () => { + const requests: CompletionParameters[] = [] + await getInlineCompletions( + params( + dedent` + class Range { + public startLine: number + public startCharacter: number + public endLine: number + public endCharacter: number + public start: Position + public end: Position + + constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number) { + this.startLine = █ + this.startCharacter = startCharacter + this.endLine = endLine + this.endCharacter = endCharacter + this.start = new Position(startLine, startCharacter) + this.end = new Position(endLine, endCharacter) + } + } + `, + [], + { requests } + ) + ) + expect(requests).toHaveLength(1) + const messages = requests[0].messages + expect(messages[messages.length - 1]).toMatchInlineSnapshot(` + { + "speaker": "assistant", + "text": "Here is the code: constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number) { + this.startLine =", + } + `) + expect(requests[0].stopSequences).toEqual(['\n\nHuman:', '', '\n\n']) + }) +}) diff --git a/vscode/src/completions/getInlineCompletions.ts b/vscode/src/completions/getInlineCompletions.ts new file mode 100644 index 000000000000..a500780556cf --- /dev/null +++ b/vscode/src/completions/getInlineCompletions.ts @@ -0,0 +1,471 @@ +import * as vscode from 'vscode' +import { URI } from 'vscode-uri' + +import { CodebaseContext } from '@sourcegraph/cody-shared/src/codebase-context' +import { isDefined } from '@sourcegraph/cody-shared/src/common' + +import { debug } from '../log' + +import { GetContextOptions, GetContextResult } from './context' +import { DocumentContext, getCurrentDocContext } from './document' +import { DocumentHistory } from './history' +import * as CompletionLogger from './logger' +import { detectMultiline } from './multiline' +import { processInlineCompletions } from './processInlineCompletions' +import { CompletionProviderTracer, Provider, ProviderConfig, ProviderOptions } from './providers/provider' +import { RequestManager } from './request-manager' +import { ProvideInlineCompletionsItemTraceData } from './tracer' +import { InlineCompletionItem } from './types' +import { isAbortError, SNIPPET_WINDOW_SIZE } from './utils' + +export interface InlineCompletionsParams { + // Context + document: vscode.TextDocument + position: vscode.Position + context: vscode.InlineCompletionContext + + // Prompt parameters + promptChars: number + maxPrefixChars: number + maxSuffixChars: number + providerConfig: ProviderConfig + responsePercentage: number + prefixPercentage: number + suffixPercentage: number + isEmbeddingsContextEnabled: boolean + + // Platform + toWorkspaceRelativePath: (uri: URI) => string + + // Injected + contextFetcher?: (options: GetContextOptions) => Promise + codebaseContext?: CodebaseContext + documentHistory?: DocumentHistory + + // Shared + requestManager: RequestManager + + // UI state + lastCandidate?: LastInlineCompletionCandidate + debounceInterval?: { singleLine: number; multiLine: number } + setIsLoading?: (isLoading: boolean) => void + + // Execution + abortSignal?: AbortSignal + tracer?: (data: Partial) => void +} + +/** + * The last-suggested ghost text result, which can be reused if it is still valid. + */ +export interface LastInlineCompletionCandidate { + /** The document URI for which this candidate was generated. */ + uri: URI + + /** The position at which this candidate was generated. */ + originalTriggerPosition: vscode.Position + + /** The prefix of the line (before the cursor position) where this candidate was generated. */ + originalTriggerLinePrefix: string + + /** The previously suggested result. */ + result: Pick +} + +/** + * The result of a call to {@link getInlineCompletions}. + */ +export interface InlineCompletionsResult { + /** The unique identifier for logging this result. */ + logId: string + + /** Where this result was generated from. */ + source: InlineCompletionsResultSource + + /** The completions. */ + items: InlineCompletionItem[] +} + +/** + * The source of the inline completions result. + */ +export enum InlineCompletionsResultSource { + Network, + Cache, + + /** + * The user is typing as suggested by the currently visible ghost text. For example, if the + * user's editor shows ghost text `abc` ahead of the cursor, and the user types `ab`, the + * original completion should be reused because it is still relevant. + * + * The last suggestion is passed in {@link InlineCompletionsParams.lastCandidate}. + */ + LastCandidate, +} + +export async function getInlineCompletions(params: InlineCompletionsParams): Promise { + try { + const result = await doGetInlineCompletions(params) + if (result) { + debug('getInlineCompletions:result', InlineCompletionsResultSource[result.source]) + } else { + debug('getInlineCompletions:noResult', '') + } + params.tracer?.({ result }) + return result + } catch (unknownError: unknown) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const error = unknownError instanceof Error ? unknownError : new Error(unknownError as any) + + params.tracer?.({ error: error.toString() }) + + if (isAbortError(error)) { + debug('getInlineCompletions:error', error.message, { verbose: error }) + return null + } + + throw error + } finally { + params.setIsLoading?.(false) + } +} + +async function doGetInlineCompletions({ + document, + position, + context, + promptChars, + maxPrefixChars, + maxSuffixChars, + providerConfig, + responsePercentage, + prefixPercentage, + suffixPercentage, + isEmbeddingsContextEnabled, + toWorkspaceRelativePath, + contextFetcher, + codebaseContext, + documentHistory, + requestManager, + lastCandidate: lastCandidate, + debounceInterval, + setIsLoading, + abortSignal, + tracer, +}: InlineCompletionsParams): Promise { + tracer?.({ params: { document, position, context } }) + + const docContext = getCurrentDocContext(document, position, maxPrefixChars, maxSuffixChars) + if (!docContext) { + return null + } + + // If we have a suffix in the same line as the cursor and the suffix contains any word + // characters, do not attempt to make a completion. This means we only make completions if + // we have a suffix in the same line for special characters like `)]}` etc. + // + // VS Code will attempt to merge the remainder of the current line by characters but for + // words this will easily get very confusing. + if (/\w/.test(docContext.currentLineSuffix)) { + return null + } + + // Check if the user is typing as suggested by the last candidate completion (that is shown as + // ghost text in the editor), and reuse it if it is still valid. + const resultToReuse = lastCandidate + ? reuseResultFromLastCandidate({ document, position, lastCandidate, docContext }) + : null + if (resultToReuse) { + return resultToReuse + } + + const multiline = detectMultiline(docContext, document.languageId, providerConfig.enableExtendedMultilineTriggers) + + // Only log a completion as started if it's either served from cache _or_ the debounce interval + // has passed to ensure we don't log too many start events where we end up not doing any work at + // all. + CompletionLogger.clear() + const logId = CompletionLogger.create({ + multiline, + providerIdentifier: providerConfig.identifier, + languageId: document.languageId, + }) + + // Debounce to avoid firing off too many network requests as the user is still typing. + const interval = multiline ? debounceInterval?.multiLine : debounceInterval?.singleLine + if (interval !== undefined && interval > 0) { + await new Promise(resolve => setTimeout(resolve, interval)) + } + + // We don't need to make a request at all if the signal is already aborted after the debounce. + if (abortSignal?.aborted) { + return null + } + + setIsLoading?.(true) + CompletionLogger.start(logId) + + // Fetch context + const contextResult = await getCompletionContext({ + document, + promptChars, + isEmbeddingsContextEnabled, + contextFetcher, + codebaseContext, + documentHistory, + docContext, + }) + if (abortSignal?.aborted) { + return null + } + tracer?.({ context: contextResult }) + + // Completion providers + const completionProviders = getCompletionProviders({ + document, + context, + providerConfig, + responsePercentage, + prefixPercentage, + suffixPercentage, + multiline, + docContext, + toWorkspaceRelativePath, + }) + tracer?.({ completers: completionProviders.map(({ options }) => options) }) + + CompletionLogger.networkRequestStarted(logId, contextResult?.logSummary ?? null) + + // Get completions from providers + const { completions, cacheHit } = await requestManager.request( + { prefix: docContext.prefix }, + completionProviders, + contextResult?.context ?? [], + abortSignal, + tracer ? createCompletionProviderTracer(tracer) : undefined + ) + tracer?.({ cacheHit }) + + if (abortSignal?.aborted) { + return null + } + + // Shared post-processing logic + const processedCompletions = processInlineCompletions( + completions.map(item => ({ insertText: item.content })), + { + document, + position, + multiline, + docContext, + } + ) + logCompletions(logId, processedCompletions, document, context) + return { + logId, + items: processedCompletions, + source: cacheHit ? InlineCompletionsResultSource.Cache : InlineCompletionsResultSource.Network, + } +} + +function isWhitespace(s: string): boolean { + return /^\s*$/.test(s) +} + +/** + * See test cases for the expected behaviors. + */ +function reuseResultFromLastCandidate({ + document, + position, + lastCandidate: { originalTriggerPosition, originalTriggerLinePrefix, ...lastCandidate }, + docContext: { currentLinePrefix, currentLineSuffix }, +}: Required> & { + docContext: DocumentContext +}): InlineCompletionsResult | null { + const isSameDocument = lastCandidate.uri.toString() === document.uri.toString() + const isSameLine = originalTriggerPosition.line === position.line + + if (!isSameDocument || !isSameLine) { + return null + } + + // There are 2 reasons we can reuse a candidate: typing-as-suggested or change-of-indentation. + + const isIndentation = isWhitespace(currentLinePrefix) && currentLinePrefix.startsWith(originalTriggerLinePrefix) + const isDeindentation = + isWhitespace(originalTriggerLinePrefix) && originalTriggerLinePrefix.startsWith(currentLinePrefix) + const isIndentationChange = currentLineSuffix === '' && (isIndentation || isDeindentation) + + const itemsToReuse = lastCandidate.result.items + .map((item): InlineCompletionItem | undefined => { + // Allow reuse if the user is (possibly) typing forward as suggested by the last + // candidate completion. We still need to filter the candidate items to see which ones + // the user's typing actually follows. + const originalCompletion = originalTriggerLinePrefix + item.insertText + const isTypingAsSuggested = + originalCompletion.startsWith(currentLinePrefix) && position.isAfterOrEqual(originalTriggerPosition) + if (isTypingAsSuggested) { + return { insertText: originalCompletion.slice(currentLinePrefix.length) } + } + + // Allow reuse if only the indentation (leading whitespace) has changed. + if (isIndentationChange) { + return { insertText: originalTriggerLinePrefix.slice(currentLinePrefix.length) + item.insertText } + } + + return undefined + }) + .filter(isDefined) + return itemsToReuse.length > 0 + ? { + // Reuse the logId to so that typing text of a displayed completion will not log a new + // completion on every keystroke. + logId: lastCandidate.result.logId, + + source: InlineCompletionsResultSource.LastCandidate, + items: itemsToReuse, + } + : null +} + +interface GetCompletionProvidersParams + extends Pick< + InlineCompletionsParams, + | 'document' + | 'context' + | 'providerConfig' + | 'responsePercentage' + | 'prefixPercentage' + | 'suffixPercentage' + | 'toWorkspaceRelativePath' + > { + multiline: boolean + docContext: DocumentContext +} + +function getCompletionProviders({ + document, + context, + providerConfig, + responsePercentage, + prefixPercentage, + suffixPercentage, + multiline, + docContext: { prefix, suffix }, + toWorkspaceRelativePath, +}: GetCompletionProvidersParams): Provider[] { + const sharedProviderOptions: Omit = { + prefix, + suffix, + fileName: toWorkspaceRelativePath(document.uri), + languageId: document.languageId, + responsePercentage, + prefixPercentage, + suffixPercentage, + } + if (multiline) { + return [ + providerConfig.create({ + id: 'multiline', + ...sharedProviderOptions, + n: 3, // 3 vs. 1 does not meaningfully affect perf + multiline: true, + }), + ] + } + return [ + providerConfig.create({ + id: 'single-line-suffix', + ...sharedProviderOptions, + // Show more if manually triggered (but only showing 1 is faster, so we use it + // in the automatic trigger case). + n: context.triggerKind === vscode.InlineCompletionTriggerKind.Automatic ? 1 : 3, + multiline: false, + }), + ] +} + +interface GetCompletionContextParams + extends Pick< + InlineCompletionsParams, + | 'document' + | 'promptChars' + | 'isEmbeddingsContextEnabled' + | 'contextFetcher' + | 'codebaseContext' + | 'documentHistory' + > { + docContext: DocumentContext +} + +async function getCompletionContext({ + document, + promptChars, + isEmbeddingsContextEnabled, + contextFetcher, + codebaseContext, + documentHistory, + docContext: { prefix, suffix }, +}: GetCompletionContextParams): Promise { + if (!contextFetcher) { + return null + } + if (!codebaseContext) { + throw new Error('codebaseContext is required if contextFetcher is provided') + } + if (!documentHistory) { + throw new Error('documentHistory is required if contextFetcher is provided') + } + + return contextFetcher({ + document, + prefix, + suffix, + history: documentHistory, + jaccardDistanceWindowSize: SNIPPET_WINDOW_SIZE, + maxChars: promptChars, + codebaseContext, + isEmbeddingsContextEnabled, + }) +} + +function createCompletionProviderTracer( + tracer: InlineCompletionsParams['tracer'] +): CompletionProviderTracer | undefined { + return ( + tracer && { + params: data => tracer({ completionProviderCallParams: data }), + result: data => tracer({ completionProviderCallResult: data }), + } + ) +} + +function logCompletions( + logId: string, + completions: InlineCompletionItem[], + document: vscode.TextDocument, + context: vscode.InlineCompletionContext +): void { + if (completions.length > 0) { + // When the VS Code completion popup is open and we suggest a completion that does not match + // the currently selected completion, VS Code won't display it. For now we make sure to not + // log these completions as displayed. + // + // TODO: Take this into account when creating the completion prefix. + let isCompletionVisible = true + if (context.selectedCompletionInfo) { + const currentText = document.getText(context.selectedCompletionInfo.range) + const selectedText = context.selectedCompletionInfo.text + if (!(currentText + completions[0].insertText).startsWith(selectedText)) { + isCompletionVisible = false + } + } + + if (isCompletionVisible) { + CompletionLogger.suggest(logId, isCompletionVisible) + } + } else { + CompletionLogger.noResponse(logId) + } +} diff --git a/vscode/src/completions/logger.ts b/vscode/src/completions/logger.ts index 976b6b3d1968..8109eda116d8 100644 --- a/vscode/src/completions/logger.ts +++ b/vscode/src/completions/logger.ts @@ -83,7 +83,7 @@ export function networkRequestStarted( embeddings?: number local?: number duration: number - } + } | null ): void { const event = displayedCompletions.get(id) if (event) { diff --git a/vscode/src/completions/processInlineCompletions.test.ts b/vscode/src/completions/processInlineCompletions.test.ts new file mode 100644 index 000000000000..451e03db4e46 --- /dev/null +++ b/vscode/src/completions/processInlineCompletions.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest' + +import { range } from '../testutils/textDocument' + +import { adjustRangeToOverwriteOverlappingCharacters } from './processInlineCompletions' +import { documentAndPosition } from './testHelpers' +import { InlineCompletionItem } from './types' + +describe('adjustRangeToOverwriteOverlappingCharacters', () => { + test('no adjustment at end of line', () => { + const item: InlineCompletionItem = { insertText: 'array) {' } + const { position } = documentAndPosition('function sort(█') + expect( + adjustRangeToOverwriteOverlappingCharacters(item, { + position, + docContext: { currentLineSuffix: '' }, + }) + ).toEqual(item) + }) + + test('handles non-empty currentLineSuffix', () => { + const item: InlineCompletionItem = { insertText: 'array) {' } + const { position } = documentAndPosition('function sort(█)') + expect( + adjustRangeToOverwriteOverlappingCharacters(item, { + position, + docContext: { currentLineSuffix: ')' }, + }) + ).toEqual({ + ...item, + range: range(0, 14, 0, 15), + }) + }) + + test('handles whitespace in currentLineSuffix', () => { + const item: InlineCompletionItem = { insertText: 'array) {' } + const { position } = documentAndPosition('function sort(█)') + expect( + adjustRangeToOverwriteOverlappingCharacters(item, { + position, + docContext: { currentLineSuffix: ') ' }, + }) + ).toEqual({ + ...item, + range: range(0, 14, 0, 16), + }) + }) +}) diff --git a/vscode/src/completions/processInlineCompletions.ts b/vscode/src/completions/processInlineCompletions.ts new file mode 100644 index 000000000000..623f98e7a1f0 --- /dev/null +++ b/vscode/src/completions/processInlineCompletions.ts @@ -0,0 +1,99 @@ +import type { Position, TextDocument } from 'vscode' + +import { DocumentContext } from './document' +import { truncateMultilineCompletion } from './multiline' +import { collapseDuplicativeWhitespace, removeTrailingWhitespace, trimUntilSuffix } from './text-processing' +import { InlineCompletionItem } from './types' + +export interface ProcessInlineCompletionsParams { + document: Pick + position: Position + multiline: boolean + docContext: DocumentContext +} + +/** + * This function implements post-processing logic that is applied regardless of + * which provider is chosen. + */ +export function processInlineCompletions( + items: InlineCompletionItem[], + { document, position, multiline, docContext }: ProcessInlineCompletionsParams +): InlineCompletionItem[] { + // Shared post-processing logic + const processedCompletions = items.map(item => processItem(item, { document, position, multiline, docContext })) + + // Filter results + const visibleResults = filterCompletions(processedCompletions) + + // Remove duplicate results + const uniqueResults = [...new Map(visibleResults.map(item => [item.insertText, item])).values()] + + // Rank results + const rankedResults = rankCompletions(uniqueResults) + + return rankedResults +} + +function processItem( + item: InlineCompletionItem, + { + document, + position, + multiline, + docContext: { prefix, suffix, currentLineSuffix }, + }: Pick +): InlineCompletionItem { + // Make a copy to avoid unexpected behavior. + item = { ...item } + + if (typeof item.insertText !== 'string') { + throw new TypeError('SnippetText not supported') + } + + item = adjustRangeToOverwriteOverlappingCharacters(item, { position, docContext: { currentLineSuffix } }) + if (multiline) { + item.insertText = truncateMultilineCompletion(item.insertText, prefix, suffix, document.languageId) + item.insertText = removeTrailingWhitespace(item.insertText) + } + item.insertText = trimUntilSuffix(item.insertText, prefix, suffix, document.languageId) + item.insertText = collapseDuplicativeWhitespace(prefix, item.insertText) + + return item +} + +/** + * Return a copy of item with an adjusted range to overwrite duplicative characters after the + * completion on the first line. + * + * For example, with position `function sort(█)` and completion `array) {`, the range should be + * adjusted to span the `)` so it is overwritten by the `insertText` (so that we don't end up with + * the invalid `function sort(array) {)`). + */ +export function adjustRangeToOverwriteOverlappingCharacters( + item: InlineCompletionItem, + { + position, + docContext: { currentLineSuffix }, + }: Pick & { + docContext: Pick + } +): InlineCompletionItem { + // TODO(sqs): This is a very naive implementation that will not work for many cases. It always + // just clobbers the rest of the line. + + if (!item.range && currentLineSuffix !== '') { + return { ...item, range: { start: position, end: position.translate(undefined, currentLineSuffix.length) } } + } + + return item +} + +function rankCompletions(completions: InlineCompletionItem[]): InlineCompletionItem[] { + // TODO(philipp-spiess): Improve ranking to something more complex then just length + return completions.sort((a, b) => b.insertText.split('\n').length - a.insertText.split('\n').length) +} + +function filterCompletions(completions: InlineCompletionItem[]): InlineCompletionItem[] { + return completions.filter(c => c.insertText.trim() !== '') +} diff --git a/vscode/src/completions/providers/anthropic.ts b/vscode/src/completions/providers/anthropic.ts index 38a4f6d8a41c..da452689680d 100644 --- a/vscode/src/completions/providers/anthropic.ts +++ b/vscode/src/completions/providers/anthropic.ts @@ -145,7 +145,7 @@ export class AnthropicProvider extends Provider { // Create prompt const { messages: prompt } = this.createPrompt(snippets) if (prompt.length > this.promptChars) { - throw new Error('prompt length exceeded maximum alloted chars') + throw new Error(`prompt length (${prompt.length}) exceeded maximum character length (${this.promptChars})`) } const args: CompletionParameters = this.options.multiline diff --git a/vscode/src/completions/providers/unstable-azure-openai.ts b/vscode/src/completions/providers/unstable-azure-openai.ts index d86588453f9b..36d45c1deb95 100644 --- a/vscode/src/completions/providers/unstable-azure-openai.ts +++ b/vscode/src/completions/providers/unstable-azure-openai.ts @@ -1,7 +1,7 @@ import { logger } from '../../log' import { ReferenceSnippet } from '../context' import { getHeadAndTail } from '../text-processing' -import { Completion } from '../vscodeInlineCompletionItemProvider' +import { Completion } from '../types' import { Provider, ProviderConfig, ProviderOptions } from './provider' diff --git a/vscode/src/completions/request-manager.test.ts b/vscode/src/completions/request-manager.test.ts index 0b2400f498cb..82edef9a37d7 100644 --- a/vscode/src/completions/request-manager.test.ts +++ b/vscode/src/completions/request-manager.test.ts @@ -3,12 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { vsCodeMocks } from '../testutils/mocks' import { Provider } from './providers/provider' -import { RequestManager } from './request-manager' +import { RequestManager, RequestManagerResult } from './request-manager' import { Completion } from './types' vi.mock('vscode', () => vsCodeMocks) -const DOCUMENT_URI = 'file:///path/to/file.ts' const LOG_ID = 'some-log-id' class MockProvider extends Provider { @@ -48,12 +47,12 @@ function createProvider(prefix: string) { } describe('RequestManager', () => { - let createRequest: (prefix: string, provider: Provider) => Promise + let createRequest: (prefix: string, provider: Provider) => Promise beforeEach(() => { const requestManager = new RequestManager() createRequest = (prefix: string, provider: Provider) => - requestManager.request(DOCUMENT_URI, LOG_ID, prefix, [provider], [], new AbortController().signal) + requestManager.request({ prefix }, [provider], [], new AbortController().signal) }) it('resolves a single request', async () => { @@ -63,12 +62,15 @@ describe('RequestManager', () => { setTimeout(() => provider.resolveRequest(["'hello')"]), 0) await expect(createRequest(prefix, provider)).resolves.toMatchInlineSnapshot(` - [ - { - "content": "'hello')", - "prefix": "console.log(", - }, - ] + { + "cacheHit": false, + "completions": [ + { + "content": "'hello')", + "prefix": "console.log(", + }, + ], + } `) }) @@ -86,14 +88,14 @@ describe('RequestManager', () => { provider2.resolveRequest(["'hello')"]) - expect((await promise2)[0].content).toBe("'hello')") + expect((await promise2).completions[0].content).toBe("'hello')") expect(provider1.didFinishNetworkRequest).toBe(false) expect(provider2.didFinishNetworkRequest).toBe(true) provider1.resolveRequest(['log();']) - expect((await promise1)[0].content).toBe('log();') + expect((await promise1).completions[0].content).toBe('log();') expect(provider1.didFinishNetworkRequest).toBe(true) }) }) diff --git a/vscode/src/completions/request-manager.ts b/vscode/src/completions/request-manager.ts index 7ae61ee3c05d..92fcd99256ae 100644 --- a/vscode/src/completions/request-manager.ts +++ b/vscode/src/completions/request-manager.ts @@ -1,7 +1,23 @@ +import { LRUCache } from 'lru-cache' + +import { debug } from '../log' + import { ReferenceSnippet } from './context' import { CompletionProviderTracer, Provider } from './providers/provider' import { Completion } from './types' +export interface RequestParams { + // TODO(sqs): This is not a unique enough cache key. We should cache based on the params wrapped + // into generateCompletions instead of requiring callers to separately pass cache-key-able + // params to RequestManager. + prefix: string +} + +export interface RequestManagerResult { + completions: Completion[] + cacheHit: boolean +} + /** * This class can handle concurrent requests for code completions. The idea is * that requests are not cancelled even when the user continues typing in the @@ -9,15 +25,22 @@ import { Completion } from './types' * return them when the user triggers a completion again. */ export class RequestManager { + private cache = new RequestCache() + public async request( - documentUri: string, - logId: string, - prefix: string, + params: RequestParams, providers: Provider[], context: ReferenceSnippet[], - signal: AbortSignal, + signal?: AbortSignal, tracer?: CompletionProviderTracer - ): Promise { + ): Promise { + const existing = this.cache.get({ params }) + if (existing) { + debug('RequestManager', 'cache hit', { verbose: { params, existing } }) + return { ...existing.result, cacheHit: true } + } + debug('RequestManager', 'cache miss', { verbose: { params } }) + // We forward a different abort controller to the network request so we // can cancel the network request independently of the user cancelling // the completion. @@ -28,10 +51,38 @@ export class RequestManager { ) .then(res => res.flat()) .then(completions => { - if (signal.aborted) { + // Cache even if the request was aborted. + this.cache.set({ params }, { result: { completions } }) + + if (signal?.aborted) { throw new Error('aborted') } - return completions + + return { completions, cacheHit: false } }) } } + +interface RequestCacheKey { + params: RequestParams +} + +interface RequestCacheEntry { + result: Omit +} + +class RequestCache { + private cache = new LRUCache({ max: 50 }) + + private toCacheKey(key: RequestCacheKey): string { + return key.params.prefix + } + + public get(key: RequestCacheKey): RequestCacheEntry | undefined { + return this.cache.get(this.toCacheKey(key)) + } + + public set(key: RequestCacheKey, entry: RequestCacheEntry): void { + this.cache.set(this.toCacheKey(key), entry) + } +} diff --git a/vscode/src/completions/shared-post-process.ts b/vscode/src/completions/shared-post-process.ts deleted file mode 100644 index b6ba0ee6f615..000000000000 --- a/vscode/src/completions/shared-post-process.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { truncateMultilineCompletion } from './multiline' -import { collapseDuplicativeWhitespace, removeTrailingWhitespace, trimUntilSuffix } from './text-processing' -import { Completion } from './types' - -/** - * This function implements post-processing logic that is applied regardless of - * which provider is chosen. - */ -export function sharedPostProcess({ - prefix, - suffix, - languageId, - multiline, - completion, -}: { - prefix: string - suffix: string - languageId: string - multiline: boolean - completion: Completion -}): Completion { - let content = completion.content - - if (multiline) { - content = truncateMultilineCompletion(content, prefix, suffix, languageId) - content = removeTrailingWhitespace(content) - } - content = trimUntilSuffix(content, prefix, suffix, languageId) - content = collapseDuplicativeWhitespace(prefix, content) - - return { - ...completion, - content: content.trimEnd(), - } -} diff --git a/vscode/src/completions/testHelpers.ts b/vscode/src/completions/testHelpers.ts index 830c85b210f2..6a8017a05843 100644 --- a/vscode/src/completions/testHelpers.ts +++ b/vscode/src/completions/testHelpers.ts @@ -36,7 +36,8 @@ const CURSOR_MARKER = '█' export function documentAndPosition( textWithCursor: string, - languageId = 'typescript' + languageId = 'typescript', + uriString = 'file:///test.ts' ): { document: VSCodeTextDocument; position: VSCodePosition } { const cursorIndex = textWithCursor.indexOf(CURSOR_MARKER) if (cursorIndex === -1) { @@ -45,7 +46,7 @@ export function documentAndPosition( const prefix = textWithCursor.slice(0, cursorIndex) const suffix = textWithCursor.slice(cursorIndex + CURSOR_MARKER.length) const codeWithoutCursor = prefix + suffix - const document = wrapVSCodeTextDocument(TextDocument.create('file:///test.ts', languageId, 0, codeWithoutCursor)) + const document = wrapVSCodeTextDocument(TextDocument.create(uriString, languageId, 0, codeWithoutCursor)) const position = document.positionAt(cursorIndex) return { document, position } } diff --git a/vscode/src/completions/tracer/index.ts b/vscode/src/completions/tracer/index.ts index b5ac757b2038..737f5a6dd3ff 100644 --- a/vscode/src/completions/tracer/index.ts +++ b/vscode/src/completions/tracer/index.ts @@ -4,6 +4,7 @@ import { CompletionParameters } from '@sourcegraph/cody-shared/src/sourcegraph-a import { GetContextResult } from '../context' import { CompletionProviderTracerResultData, Provider } from '../providers/provider' +import { InlineCompletionItem } from '../types' /** * Traces invocations of {@link CodyCompletionItemProvider.provideInlineCompletionItems}. @@ -37,7 +38,7 @@ export interface ProvideInlineCompletionsItemTraceData { completionProviderCallResult?: CompletionProviderTracerResultData context?: GetContextResult | null - result?: vscode.InlineCompletionList | null + result?: { items: InlineCompletionItem[] } | null cacheHit?: boolean error?: string } diff --git a/vscode/src/completions/tracer/traceView.ts b/vscode/src/completions/tracer/traceView.ts index cdc1775c1aa9..8bc7ad95288d 100644 --- a/vscode/src/completions/tracer/traceView.ts +++ b/vscode/src/completions/tracer/traceView.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode' import { isDefined } from '@sourcegraph/cody-shared' import { renderMarkdown } from '@sourcegraph/cody-shared/src/common/markdown' +import { InlineCompletionItem } from '../types' import { InlineCompletionItemProvider } from '../vscodeInlineCompletionItemProvider' import { ProvideInlineCompletionsItemTraceData } from '.' @@ -206,13 +207,23 @@ function selectedCompletionInfoDescription( } function inlineCompletionItemDescription( - item: vscode.InlineCompletionItem, + item: InlineCompletionItem, document: vscode.TextDocument | undefined ): string { - return `${markdownCodeBlock( - withVisibleWhitespace(typeof item.insertText === 'string' ? item.insertText : item.insertText.value) - )} -${item.range ? `replacing ${rangeDescriptionWithCurrentText(item.range, document)}` : 'inserting at cursor'}` + return `${markdownCodeBlock(withVisibleWhitespace(item.insertText))} +${ + item.range + ? `replacing ${rangeDescriptionWithCurrentText( + new vscode.Range( + item.range.start.line, + item.range.start.character, + item.range.end.line, + item.range.end.character + ), + document + )}` + : 'inserting at cursor' +}` } function rangeDescription(range: vscode.Range): string { diff --git a/vscode/src/completions/types.ts b/vscode/src/completions/types.ts index c024ad1ab943..d0b922f9ec4a 100644 --- a/vscode/src/completions/types.ts +++ b/vscode/src/completions/types.ts @@ -1,5 +1,14 @@ +import { Range } from 'vscode-languageserver-textdocument' + export interface Completion { - prefix: string content: string stopReason?: string } + +/** + * @see vscode.InlineCompletionItem + */ +export interface InlineCompletionItem { + insertText: string + range?: Range +} diff --git a/vscode/src/completions/vscodeInlineCompletionItemProvider.test.ts b/vscode/src/completions/vscodeInlineCompletionItemProvider.test.ts index 1e998ac7cfd1..7ce7c5f74b99 100644 --- a/vscode/src/completions/vscodeInlineCompletionItemProvider.test.ts +++ b/vscode/src/completions/vscodeInlineCompletionItemProvider.test.ts @@ -1,30 +1,14 @@ -import dedent from 'dedent' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import type * as vscode from 'vscode' -import { CodebaseContext } from '@sourcegraph/cody-shared/src/codebase-context' -import { SourcegraphCompletionsClient } from '@sourcegraph/cody-shared/src/sourcegraph-api/completions/client' -import { - CompletionParameters, - CompletionResponse, -} from '@sourcegraph/cody-shared/src/sourcegraph-api/completions/types' - -import { CodyStatusBar } from '../services/StatusBar' import { vsCodeMocks } from '../testutils/mocks' -import { DocumentHistory } from './history' +import { getInlineCompletions, InlineCompletionsResultSource } from './getInlineCompletions' import { createProviderConfig } from './providers/anthropic' -import { completion, documentAndPosition } from './testHelpers' +import { documentAndPosition } from './testHelpers' +import { InlineCompletionItem } from './types' import { InlineCompletionItemProvider } from './vscodeInlineCompletionItemProvider' -const CURSOR_MARKER = '█' - -// The dedent package seems to replace `\t` with `\\t` so in order to insert a -// tab character, we have to use interpolation. We abbreviate this to `T` -// because ${T} is exactly 4 characters, mimicking the default indentation of -// four spaces -const T = '\t' - vi.mock('vscode', () => ({ ...vsCodeMocks, workspace: { @@ -43,1013 +27,94 @@ vi.mock('vscode', () => ({ }, })) -vi.mock('./context-embeddings.ts', () => ({ - getContextFromEmbeddings: () => [], -})) - -const NOOP_STATUS_BAR: CodyStatusBar = { - dispose: () => {}, - startLoading: () => () => {}, +const DUMMY_CONTEXT: vscode.InlineCompletionContext = { + selectedCompletionInfo: undefined, + triggerKind: vsCodeMocks.InlineCompletionTriggerKind.Automatic, } -const DUMMY_DOCUMENT_HISTORY: DocumentHistory = { - addItem: () => {}, - lastN: () => [], -} - -const DUMMY_CODEBASE_CONTEXT: CodebaseContext = new CodebaseContext( - { serverEndpoint: 'https://example.com', useContext: 'none' }, - undefined, - null, - null, - null -) - -describe('Cody completions', () => { - /** - * A test helper to trigger a completion request. The code example must include - * a pipe character to denote the current cursor position. - * - * @example - * complete(` - * async function foo() { - * █ - * }`) - */ - let complete: ( - code: string, - responses?: CompletionResponse[], - languageId?: string, - context?: vscode.InlineCompletionContext - ) => Promise<{ - requests: CompletionParameters[] - completions: vscode.InlineCompletionItem[] - }> - beforeEach(() => { - complete = async ( - code: string, - responses?: CompletionResponse[], - languageId: string = 'typescript', - context: vscode.InlineCompletionContext = { - triggerKind: vsCodeMocks.InlineCompletionTriggerKind.Automatic, - selectedCompletionInfo: undefined, - } - ): Promise<{ - requests: CompletionParameters[] - completions: vscode.InlineCompletionItem[] - }> => { - const requests: CompletionParameters[] = [] - let requestCounter = 0 - const completionsClient: Pick = { - complete(params: CompletionParameters): Promise { - requests.push(params) - return Promise.resolve(responses?.[requestCounter++] || { completion: '', stopReason: 'unknown' }) - }, - } - const providerConfig = createProviderConfig({ - completionsClient, +class MockableInlineCompletionItemProvider extends InlineCompletionItemProvider { + constructor( + mockGetInlineCompletions: typeof getInlineCompletions, + superArgs?: Partial[0]> + ) { + super({ + completeSuggestWidgetSelection: false, + // Most of these are just passed directly to `getInlineCompletions`, which we've mocked, so + // we can just make them `null`. + // + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + codebaseContext: null as any, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + history: null as any, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + statusBar: null as any, + providerConfig: createProviderConfig({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + completionsClient: null as any, contextWindowTokens: 2048, - }) - const completionProvider = new InlineCompletionItemProvider({ - providerConfig, - statusBar: NOOP_STATUS_BAR, - history: DUMMY_DOCUMENT_HISTORY, - codebaseContext: DUMMY_CODEBASE_CONTEXT, - disableTimeouts: true, - }) - - if (!code.includes(CURSOR_MARKER)) { - throw new Error(`The test code must include a ${CURSOR_MARKER} to denote the cursor position`) - } - - const { document, position } = documentAndPosition(code, languageId) - - const result = await completionProvider.provideInlineCompletionItems(document, position, context) - const completions = 'items' in result ? result.items : result - - // The provider returns completions with text starting at the beginning of the - // current line (to reduce jitter in VS Code), but for testing, it's simpler to - // omit that prefix. - const completionsWithCurrentLinePrefixRemoved = completions.map(c => ({ - ...c, - insertText: (c.insertText as string).slice(position.character), - range: c.range?.with({ start: position }), - })) - - return { - requests, - completions: completionsWithCurrentLinePrefixRemoved, - } - } - }) - - it('uses a more complex prompt for larger files', async () => { - const { requests } = await complete(dedent` - class Range { - public startLine: number - public startCharacter: number - public endLine: number - public endCharacter: number - public start: Position - public end: Position - - constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number) { - this.startLine = █ - this.startCharacter = startCharacter - this.endLine = endLine - this.endCharacter = endCharacter - this.start = new Position(startLine, startCharacter) - this.end = new Position(endLine, endCharacter) - } - } - `) - - expect(requests).toHaveLength(1) - const messages = requests[0].messages - expect(messages[messages.length - 1]).toMatchInlineSnapshot(` - { - "speaker": "assistant", - "text": "Here is the code: constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number) { - this.startLine =", - } - `) - expect(requests[0].stopSequences).toEqual(['\n\nHuman:', '', '\n\n']) - }) - - it('makes a request when in the middle of a word', async () => { - const { requests } = await complete('foo█', [completion`()`], undefined, undefined) - expect(requests).toHaveLength(1) - }) - - it('completes a single-line at the end of a sentence', async () => { - const { completions } = await complete('foo = █', [completion`'bar'`]) - - expect(completions[0].insertText).toBe("'bar'") - }) - - it('only complete one line in single line mode', async () => { - const { completions } = await complete( - ` - function test() { - console.log(1); - █ - } - `, - [ - completion` - ├if (true) { - console.log(3); - } - console.log(4);┤ - ┴┴┴┴`, - ] - ) - - expect(completions[0].insertText).toBe('if (true) {') - }) - - it('completes a single-line at the middle of a sentence', async () => { - const { completions } = await complete('function bubbleSort(█)', [completion`array) {`, completion`items) {`]) + }), - expect(completions[0].insertText).toBe('array) {') - expect(completions[1].insertText).toBe('items) {') - }) + ...superArgs, + }) + this.getInlineCompletions = mockGetInlineCompletions + } - it('marks the rest of the line as to be replaced so closing characters in the same line suffix are properly merged', async () => { - const { completions } = await complete('function bubbleSort(█)', [completion`array) {`]) + public declare lastCandidate +} - expect(completions[0].range).toMatchInlineSnapshot(` - Range { - "end": Position { - "character": 21, - "line": 0, - }, - "start": Position { - "character": 20, - "line": 0, +describe('InlineCompletionItemProvider', () => { + test('returns results that span the whole line', async () => { + const { document, position } = documentAndPosition('const foo = █', 'typescript') + const fn = vi.fn(getInlineCompletions).mockResolvedValue({ + logId: '1', + items: [{ insertText: 'test', range: new vsCodeMocks.Range(position, position) }], + source: InlineCompletionsResultSource.Network, + }) + const provider = new MockableInlineCompletionItemProvider(fn) + const { items } = await provider.provideInlineCompletionItems(document, position, DUMMY_CONTEXT) + expect(items).toMatchInlineSnapshot(` + [ + InlineCompletionItem { + "insertText": "const foo = test", + "range": Range { + "end": Position { + "character": 12, + "line": 0, + }, + "start": Position { + "character": 0, + "line": 0, + }, + }, }, - } + ] `) }) - it('makes a request when context has a selectedCompletionInfo', async () => { - const { completions } = await complete('foo█', [completion`123`], undefined, { - selectedCompletionInfo: { - range: new vsCodeMocks.Range(0, 0, 0, 3), - text: 'foo123', - }, - triggerKind: vsCodeMocks.InlineCompletionTriggerKind.Invoke, - }) - expect(completions[0].insertText).toBe('123') - }) - - it('preserves leading whitespace when prefix has no trailing whitespace', async () => { - const { completions } = await complete('const isLocalHost = window.location.host█', [ - completion`├ === 'localhost'┤`, - ]) - expect(completions[0].insertText).toBe(" === 'localhost'") - }) - - it('collapses leading whitespace when prefix has trailing whitespace', async () => { - const { completions } = await complete('const x = █', [completion`├${T}7┤`]) - expect(completions[0].insertText).toBe('7') - }) - - it('should not trigger a request if there is text in the suffix for the same line', async () => { - const { requests } = await complete('foo: █ = 123;') - expect(requests).toHaveLength(0) - }) - - it('should trigger a request if the suffix of the same line is only special tags', async () => { - const { requests } = await complete('if(█) {') - expect(requests).toHaveLength(3) - }) - - describe('bad completion starts', () => { - it.each([ - [completion`├➕ 1┤`, '1'], - [completion`├${'\u200B'} 1┤`, '1'], - [completion`├. 1┤`, '1'], - [completion`├+ 1┤`, '1'], - [completion`├- 1┤`, '1'], - ])('fixes %s to %s', async (completion, expected) => { - const { completions } = await complete(CURSOR_MARKER, [completion]) - expect(completions[0].insertText).toBe(expected) - }) - }) - - describe('odd indentation', () => { - it('filters out odd indentation in single-line completions', async () => { - const { completions } = await complete('const foo = █', [completion`├ 1┤`]) - expect(completions[0].insertText).toBe('1') - }) - }) - - describe('multi-line completions', () => { - it('removes trailing spaces', async () => { - const { completions } = await complete( - dedent` - function bubbleSort() { - █ - }`, - [ - completion` - ├console.log('foo')${' '} - console.log('bar')${' '} - console.log('baz')${' '}┤ - ┴┴┴┴`, - ] - ) - - expect(completions[0].insertText).toMatchInlineSnapshot(` - "console.log('foo') - console.log('bar') - console.log('baz')" - `) - }) - - it('honors a leading new line in the completion', async () => { - const { completions } = await complete( - dedent` - describe('bubbleSort', () => { - it('bubbleSort test case', () => {█ - - }) - })`, - [ - completion` - ├${' '} - const unsortedArray = [4,3,78,2,0,2] - const sortedArray = bubbleSort(unsortedArray) - expect(sortedArray).toEqual([0,2,2,3,4,78]) - }) - }┤`, - ] - ) - - expect(completions[0].insertText).toMatchInlineSnapshot(` - " - const unsortedArray = [4,3,78,2,0,2] - const sortedArray = bubbleSort(unsortedArray) - expect(sortedArray).toEqual([0,2,2,3,4,78])" - `) - }) - - it('cuts-off redundant closing brackets on the start indent level', async () => { - const { completions } = await complete( - dedent` - describe('bubbleSort', () => { - it('bubbleSort test case', () => {█ - - }) - })`, - [ - completion` - ├const unsortedArray = [4,3,78,2,0,2] - const sortedArray = bubbleSort(unsortedArray) - expect(sortedArray).toEqual([0,2,2,3,4,78]) - }) - }┤`, - ] - ) - - expect(completions[0].insertText).toMatchInlineSnapshot(` - "const unsortedArray = [4,3,78,2,0,2] - const sortedArray = bubbleSort(unsortedArray) - expect(sortedArray).toEqual([0,2,2,3,4,78])" - `) - }) - - it('keeps the closing bracket', async () => { - const { completions } = await complete('function printHello(█)', [ - completion` - ├) { - console.log('Hello'); - }┤`, - ]) - - expect(completions[0].insertText).toMatchInlineSnapshot(` - ") { - console.log('Hello'); - }" - `) - }) - - it('triggers a multi-line completion at the start of a block', async () => { - const { requests } = await complete('function bubbleSort() {\n █') - - expect(requests).toHaveLength(3) - expect(requests[0].stopSequences).not.toContain('\n') - }) - - it('uses an indentation based approach to cut-off completions', async () => { - const { completions } = await complete( - dedent` - class Foo { - constructor() { - █ - } - } - `, - [ - completion` - ├console.log('foo') - } - - add() { - console.log('bar') - }┤ - ┴┴┴┴`, - completion` - ├if (foo) { - console.log('foo1'); - } - } - - add() { - console.log('bar') - }┤ - ┴┴┴┴`, - ] - ) - - expect(completions[0].insertText).toBe("if (foo) {\n console.log('foo1');\n }") - expect(completions[1].insertText).toBe("console.log('foo')") - }) - - it('cuts-off the whole completions when suffix is very similar to suffix line', async () => { - const { completions } = await complete( - dedent` - function() { - █ - console.log('bar') - } - `, - [ - completion` - ├console.log('foo') - console.log('bar') - }┤`, - ] - ) - - expect(completions.length).toBe(0) - }) - - it('does not support multi-line completion on unsupported languages', async () => { - const { requests } = await complete('function looksLegit() {\n █', undefined, 'elixir') + test('saves lastInlineCompletionResult', async () => { + const { document, position } = documentAndPosition('const foo = █', 'typescript') - expect(requests).toHaveLength(1) - expect(requests[0].stopSequences).toContain('\n\n') + const item: InlineCompletionItem = { insertText: 'test', range: new vsCodeMocks.Range(position, position) } + const fn = vi.fn(getInlineCompletions).mockResolvedValue({ + logId: '1', + items: [item], + source: InlineCompletionsResultSource.Network, }) + const provider = new MockableInlineCompletionItemProvider(fn) - it('requires an indentation to start a block', async () => { - const { requests } = await complete('function bubbleSort() {\n█') + // Initially it is undefined. + expect(provider.lastCandidate).toBeUndefined() - expect(requests).toHaveLength(1) - expect(requests[0].stopSequences).toContain('\n\n') - }) - - it('works with python', async () => { - const { completions, requests } = await complete( - dedent` - for i in range(11): - if i % 2 == 0: - █ - `, - [ - completion` - ├print(i) - elif i % 3 == 0: - print(f"Multiple of 3: {i}") - else: - print(f"ODD {i}") - - for i in range(12): - print("unrelated")┤`, - ], - 'python' - ) - - expect(requests).toHaveLength(3) - expect(requests[0].stopSequences).not.toContain('\n') - expect(completions[0].insertText).toMatchInlineSnapshot(` - "print(i) - elif i % 3 == 0: - print(f\\"Multiple of 3: {i}\\") - else: - print(f\\"ODD {i}\\")" - `) - }) - - it('works with java', async () => { - const { completions, requests } = await complete( - dedent` - for (int i = 0; i < 11; i++) { - if (i % 2 == 0) { - █ - `, - [ - completion` - ├System.out.println(i); - } else if (i % 3 == 0) { - System.out.println("Multiple of 3: " + i); - } else { - System.out.println("ODD " + i); - } - } - - for (int i = 0; i < 12; i++) { - System.out.println("unrelated"); - }┤`, - ], - 'java' - ) - - expect(requests).toHaveLength(3) - expect(requests[0].stopSequences).not.toContain('\n') - expect(completions[0].insertText).toMatchInlineSnapshot(` - "System.out.println(i); - } else if (i % 3 == 0) { - System.out.println(\\"Multiple of 3: \\" + i); - } else { - System.out.println(\\"ODD \\" + i); - }" - `) - }) - - // TODO: Detect `}\nelse\n{` pattern for else skip logic - it('works with csharp', async () => { - const { completions, requests } = await complete( - dedent` - for (int i = 0; i < 11; i++) { - if (i % 2 == 0) - { - █ - `, - [ - completion` - ├Console.WriteLine(i); - } - else if (i % 3 == 0) - { - Console.WriteLine("Multiple of 3: " + i); - } - else - { - Console.WriteLine("ODD " + i); - } - - } - - for (int i = 0; i < 12; i++) - { - Console.WriteLine("unrelated"); - }┤`, - ], - 'csharp' - ) - - expect(requests).toHaveLength(3) - expect(requests[0].stopSequences).not.toContain('\n') - expect(completions[0].insertText).toMatchInlineSnapshot(` - "Console.WriteLine(i); - }" - `) - }) - - it('works with c++', async () => { - const { completions, requests } = await complete( - dedent` - for (int i = 0; i < 11; i++) { - if (i % 2 == 0) { - █ - `, - [ - completion` - ├std::cout << i; - } else if (i % 3 == 0) { - std::cout << "Multiple of 3: " << i; - } else { - std::cout << "ODD " << i; - } - } - - for (int i = 0; i < 12; i++) { - std::cout << "unrelated"; - }┤`, - ], - 'cpp' - ) - - expect(requests).toHaveLength(3) - expect(requests[0].stopSequences).not.toContain('\n') - expect(completions[0].insertText).toMatchInlineSnapshot(` - "std::cout << i; - } else if (i % 3 == 0) { - std::cout << \\"Multiple of 3: \\" << i; - } else { - std::cout << \\"ODD \\" << i; - }" - `) - }) - - it('works with c', async () => { - const { completions, requests } = await complete( - dedent` - for (int i = 0; i < 11; i++) { - if (i % 2 == 0) { - █ - `, - [ - completion` - ├printf("%d", i); - } else if (i % 3 == 0) { - printf("Multiple of 3: %d", i); - } else { - printf("ODD %d", i); - } - } - - for (int i = 0; i < 12; i++) { - printf("unrelated"); - }┤`, - ], - 'c' - ) - - expect(requests).toHaveLength(3) - expect(requests[0].stopSequences).not.toContain('\n') - expect(completions[0].insertText).toMatchInlineSnapshot(` - "printf(\\"%d\\", i); - } else if (i % 3 == 0) { - printf(\\"Multiple of 3: %d\\", i); - } else { - printf(\\"ODD %d\\", i); - }" - `) - }) - - it('works with php', async () => { - const { completions, requests } = await complete( - dedent` - for ($i = 0; $i < 11; $i++) { - if ($i % 2 == 0) { - █ - `, - [ - completion` - ├echo $i; - } else if ($i % 3 == 0) { - echo "Multiple of 3: " . $i; - } else { - echo "ODD " . $i; - } - } - - for ($i = 0; $i < 12; $i++) { - echo "unrelated"; - }┤`, - ], - 'c' - ) - - expect(requests).toHaveLength(3) - expect(requests[0].stopSequences).not.toContain('\n') - expect(completions[0].insertText).toMatchInlineSnapshot(` - "echo $i; - } else if ($i % 3 == 0) { - echo \\"Multiple of 3: \\" . $i; - } else { - echo \\"ODD \\" . $i; - }" - `) - }) - - it('works with dart', async () => { - const { completions, requests } = await complete( - dedent` - for (int i = 0; i < 11; i++) { - if (i % 2 == 0) { - █ - `, - [ - completion` - ├print(i); - } else if (i % 3 == 0) { - print('Multiple of 3: $i'); - } else { - print('ODD $i'); - } - } - - for (int i = 0; i < 12; i++) { - print('unrelated'); - }┤`, - ], - 'dart' - ) - - expect(requests).toHaveLength(3) - expect(requests[0].stopSequences).not.toContain('\n') - expect(completions[0].insertText).toMatchInlineSnapshot(` - "print(i); - } else if (i % 3 == 0) { - print('Multiple of 3: $i'); - } else { - print('ODD $i'); - }" - `) - }) - - it('skips over empty lines', async () => { - const { completions } = await complete( - dedent` - class Foo { - constructor() { - █ - } - } - `, - [ - completion` - ├console.log('foo') - - console.log('bar') - - console.log('baz')┤ - ┴┴┴┴┴┴┴┴`, - ] - ) - - expect(completions[0].insertText).toMatchInlineSnapshot(` - "console.log('foo') - - console.log('bar') - - console.log('baz')" - `) - }) - - it('skips over else blocks', async () => { - const { completions } = await complete( - dedent` - if (check) { - █ - } - `, - [ - completion` - ├console.log('one') - } else { - console.log('two') - }┤`, - ] - ) - - expect(completions[0].insertText).toMatchInlineSnapshot(` - "console.log('one') - } else { - console.log('two')" - `) - }) + // No lastInlineCompletionResult is provided on the 1st call. + await provider.provideInlineCompletionItems(document, position, DUMMY_CONTEXT) + expect(fn.mock.calls.map(call => call[0].lastCandidate)).toEqual([undefined]) + fn.mockReset() - it('includes closing parentheses in the completion', async () => { - const { completions } = await complete( - dedent` - if (check) { - █ - `, - [ - completion` - ├console.log('one') - }┤`, - ] - ) + // But it is returned and saved. + expect(provider.lastCandidate?.result.items).toEqual([item]) - expect(completions[0].insertText).toMatchInlineSnapshot(` - "console.log('one') - }" - `) - }) - - it('stops when the next non-empty line of the suffix matches', async () => { - const { completions } = await complete( - dedent` - function myFunction() { - █ - console.log('three') - } - `, - [ - completion` - ├console.log('one') - console.log('two') - console.log('three') - console.log('four') - }┤`, - ] - ) - - expect(completions.length).toBe(0) - }) - - it('stops when the next non-empty line of the suffix matches exactly with one line completion', async () => { - const { completions } = await complete( - dedent` - function myFunction() { - console.log('one') - █ - console.log('three') - } - `, - [ - completion` - ├console.log('three') - }┤`, - ] - ) - - expect(completions.length).toBe(0) - }) - - it('cuts off a matching line with the next line even if the completion is longer', async () => { - const { completions } = await complete( - dedent` - function bubbleSort() { - █ - do { - swapped = false; - for (let i = 0; i < array.length - 1; i++) { - if (array[i] > array[i + 1]) { - let temp = array[i]; - array[i] = array[i + 1]; - array[i + 1] = temp; - swapped = true; - } - } - } while (swapped); - }`, - [ - completion` - ├let swapped; - do { - swapped = false; - for (let i = 0; i < array.length - 1; i++) { - if (array[i] > array[i + 1]) { - let temp = array[i]; - array[i] = array[i + 1]; - array[i + 1] = temp; - swapped = true; - } - } - } while (swapped);┤ - ┴┴┴┴`, - ] - ) - - expect(completions[0].insertText).toBe('let swapped;') - }) - - describe('stops when the next non-empty line of the suffix matches partially', () => { - it('simple example', async () => { - const { completions } = await complete( - dedent` - path: $GITHUB_WORKSPACE/vscode/.vscode-test/█ - key: {{ runner.os }}-pnpm-store-{{ hashFiles('**/pnpm-lock.yaml') }}`, - [ - completion` - ├pnpm-store - key: {{ runner.os }}-pnpm-{{ steps.pnpm-cache.outputs.STORE_PATH }}┤`, - ] - ) - - expect(completions[0].insertText).toBe('pnpm-store') - }) - - it('example with return', async () => { - const { completions } = await complete( - dedent` - console.log('<< stop completion: █') - return [] - `, - [ - completion` - lastChange was delete') - return [] - `, - ] - ) - - expect(completions[0].insertText).toBe("lastChange was delete')") - }) - - it('example with inline comment', async () => { - const { completions } = await complete( - dedent` - // █ - const currentFilePath = path.normalize(document.fileName) - `, - [ - completion` - Get the file path - const filePath = normalize(document.fileName) - `, - ] - ) - - expect(completions[0].insertText).toBe('Get the file path') - }) - }) - - it('ranks results by number of lines', async () => { - const { completions } = await complete( - dedent` - function test() { - █ - `, - [ - completion` - ├console.log('foo') - console.log('foo')┤ - ┴┴┴┴ - `, - completion` - ├console.log('foo') - console.log('foo') - console.log('foo') - console.log('foo') - console.log('foo')┤ - ┴┴┴┴`, - completion` - ├console.log('foo')┤ - `, - ] - ) - - expect(completions[0].insertText).toMatchInlineSnapshot(` - "console.log('foo') - console.log('foo') - console.log('foo') - console.log('foo') - console.log('foo')" - `) - expect(completions[1].insertText).toMatchInlineSnapshot(` - "console.log('foo') - console.log('foo')" - `) - expect(completions[2].insertText).toBe("console.log('foo')") - }) - - it('dedupes duplicate results', async () => { - const { completions } = await complete( - dedent` - function test() { - █ - `, - [completion`return true`, completion`return true`, completion`return true`] - ) - - expect(completions.length).toBe(1) - expect(completions[0].insertText).toBe('return true') - }) - - it('handles tab/newline interop in completion truncation', async () => { - const { completions } = await complete( - dedent` - class Foo { - constructor() { - █ - `, - [ - completion` - ├console.log('foo') - ${T}${T}if (yes) { - ${T}${T} sure() - ${T}${T}} - ${T}} - - ${T}add() {┤ - ┴┴┴┴`, - ] - ) - - expect(completions[0].insertText).toMatchInlineSnapshot(` - "console.log('foo') - \t\tif (yes) { - \t\t sure() - \t\t} - \t}" - `) - }) - - it('does not include block end character if there is already content in the block', async () => { - const { completions } = await complete( - dedent` - if (check) { - █ - const d = 5; - `, - [ - completion` - ├console.log('one') - }┤`, - ] - ) - - expect(completions[0].insertText).toBe("console.log('one')") - }) - - it('does not include block end character if there is already closed bracket', async () => { - const { completions } = await complete( - ` - if (check) { - ${CURSOR_MARKER} - }`, - [completion`}`] - ) - - expect(completions.length).toBe(0) - }) - - it('does not include block end character if there is already closed bracket [sort example]', async () => { - const { completions } = await complete( - ` - function bubbleSort(arr: number[]): number[] { - for (let i = 0; i < arr.length; i++) { - for (let j = 0; j < (arr.length - i - 1); j++) { - if (arr[j] > arr[j + 1]) { - // swap elements - let temp = arr[j]; - arr[j] = arr[j + 1]; - arr[j + 1] = temp; - } - ${CURSOR_MARKER} - } - } - return arr; - }`, - [completion`}`] - ) - - expect(completions.length).toBe(0) - }) - - it('normalizes Cody responses starting with an empty line and following the exact same indentation as the start line', async () => { - const { completions } = await complete( - dedent` - function test() { - █ - `, - [ - completion` - ├ - console.log('foo')┤ - ┴┴┴┴`, - ] - ) - - expect(completions[0].insertText).toBe("console.log('foo')") - }) + // On the 2nd call, lastInlineCompletionResult is provided. + await provider.provideInlineCompletionItems(document, position, DUMMY_CONTEXT) + expect(fn.mock.calls.map(call => call[0].lastCandidate?.result.items)).toEqual([[item]]) }) }) diff --git a/vscode/src/completions/vscodeInlineCompletionItemProvider.ts b/vscode/src/completions/vscodeInlineCompletionItemProvider.ts index e14563d303d6..824271107ad4 100644 --- a/vscode/src/completions/vscodeInlineCompletionItemProvider.ts +++ b/vscode/src/completions/vscodeInlineCompletionItemProvider.ts @@ -1,6 +1,3 @@ -import path from 'path' - -import { LRUCache } from 'lru-cache' import * as vscode from 'vscode' import { CodebaseContext } from '@sourcegraph/cody-shared/src/codebase-context' @@ -9,15 +6,17 @@ import { debug } from '../log' import { CodyStatusBar } from '../services/StatusBar' import { getContext, GetContextOptions, GetContextResult } from './context' -import { getCurrentDocContext } from './document' +import { + getInlineCompletions, + InlineCompletionsParams, + InlineCompletionsResultSource, + LastInlineCompletionCandidate, +} from './getInlineCompletions' import { DocumentHistory } from './history' -import * as CompletionLogger from './logger' -import { detectMultiline } from './multiline' -import { CompletionProviderTracer, Provider, ProviderConfig, ProviderOptions } from './providers/provider' +import { ProviderConfig } from './providers/provider' import { RequestManager } from './request-manager' -import { sharedPostProcess } from './shared-post-process' import { ProvideInlineCompletionItemsTracer, ProvideInlineCompletionsItemTraceData } from './tracer' -import { isAbortError, SNIPPET_WINDOW_SIZE } from './utils' +import { InlineCompletionItem } from './types' interface CodyCompletionItemProviderConfig { providerConfig: ProviderConfig @@ -27,7 +26,6 @@ interface CodyCompletionItemProviderConfig { responsePercentage?: number prefixPercentage?: number suffixPercentage?: number - disableTimeouts?: boolean isEmbeddingsContextEnabled?: boolean completeSuggestWidgetSelection?: boolean tracer?: ProvideInlineCompletionItemsTracer | null @@ -39,20 +37,21 @@ export class InlineCompletionItemProvider implements vscode.InlineCompletionItem private maxPrefixChars: number private maxSuffixChars: number private abortOpenCompletions: () => void = () => {} - private stopLoading: () => void = () => {} - private lastContentChanges: LRUCache = new LRUCache({ - max: 10, - }) private readonly config: Required private requestManager: RequestManager + /** Mockable (for testing only). */ + protected getInlineCompletions = getInlineCompletions + + /** Accessible for testing only. */ + protected lastCandidate: LastInlineCompletionCandidate | undefined + constructor({ responsePercentage = 0.1, prefixPercentage = 0.6, suffixPercentage = 0.1, - disableTimeouts = false, isEmbeddingsContextEnabled = true, completeSuggestWidgetSelection = false, tracer = null, @@ -63,7 +62,6 @@ export class InlineCompletionItemProvider implements vscode.InlineCompletionItem responsePercentage, prefixPercentage, suffixPercentage, - disableTimeouts, isEmbeddingsContextEnabled, completeSuggestWidgetSelection, tracer, @@ -94,18 +92,6 @@ export class InlineCompletionItemProvider implements vscode.InlineCompletionItem this.requestManager = new RequestManager() debug('CodyCompletionProvider:initialized', `provider: ${this.config.providerConfig.identifier}`) - - vscode.workspace.onDidChangeTextDocument(event => { - const document = event.document - const changes = event.contentChanges - - if (changes.length <= 0) { - return - } - - const text = changes[0].text - this.lastContentChanges.set(document.fileName, text.length > 0 ? 'add' : 'del') - }) } /** Set the tracer (or unset it with `null`). */ @@ -120,34 +106,16 @@ export class InlineCompletionItemProvider implements vscode.InlineCompletionItem // Making it optional here to execute multiple suggestion in parallel from the CLI script. token?: vscode.CancellationToken ): Promise { - const tracer = this.config.tracer ? createTracerForInvocation(this.config.tracer) : null - - try { - const result = await this.provideInlineCompletionItemsInner(document, position, context, token, tracer) - tracer?.({ result }) - return result - } catch (unknownError: unknown) { - const error = unknownError instanceof Error ? unknownError : new Error(unknownError as any) - tracer?.({ error: error.toString() }) - this.stopLoading() - - if (!isAbortError(error)) { - console.error(error) - debug('CodyCompletionProvider:inline:error', `${error.toString()}\n${error.stack}`) + const tracer = this.config.tracer ? createTracerForInvocation(this.config.tracer) : undefined + + let stopLoading: () => void | undefined + const setIsLoading = (isLoading: boolean): void => { + if (isLoading) { + stopLoading = this.config.statusBar.startLoading('Completions are being generated') + } else { + stopLoading?.() } - - throw error } - } - - private async provideInlineCompletionItemsInner( - document: vscode.TextDocument, - position: vscode.Position, - context: vscode.InlineCompletionContext, - token: vscode.CancellationToken | undefined, - tracer: SingleInvocationTracer | null - ): Promise { - tracer?.({ params: { document, position, context } }) const abortController = new AbortController() this.abortOpenCompletions() @@ -156,289 +124,89 @@ export class InlineCompletionItemProvider implements vscode.InlineCompletionItem this.abortOpenCompletions = () => abortController.abort() } - const docContext = getCurrentDocContext(document, position, this.maxPrefixChars, this.maxSuffixChars) - if (!docContext) { - return emptyCompletions() - } - - const multiline = detectMultiline( - docContext, - document.languageId, - this.config.providerConfig.enableExtendedMultilineTriggers - ) - - // If we have a suffix in the same line as the cursor and the suffix contains any word - // characters, do not attempt to make a completion. This means we only make completions if - // we have a suffix in the same line for special characters like `)]}` etc. - // - // VS Code will attempt to merge the remainder of the current line by characters but for - // words this will easily get very confusing. - if (/\w/.test(docContext.currentLineSuffix)) { - return emptyCompletions() - } - - const logId = CompletionLogger.create({ - multiline, - providerIdentifier: this.config.providerConfig.identifier, - languageId: document.languageId, - }) - tracer?.({ cacheHit: false }) - - const completers: Provider[] = [] - let timeout: number - - const sharedProviderOptions: Omit = { - prefix: docContext.prefix, - suffix: docContext.suffix, - fileName: path.normalize(vscode.workspace.asRelativePath(document.fileName ?? '')), - languageId: document.languageId, + const result = await this.getInlineCompletions({ + document, + position, + context, + promptChars: this.promptChars, + maxPrefixChars: this.maxPrefixChars, + maxSuffixChars: this.maxSuffixChars, + providerConfig: this.config.providerConfig, responsePercentage: this.config.responsePercentage, prefixPercentage: this.config.prefixPercentage, suffixPercentage: this.config.suffixPercentage, - } - - if (multiline) { - timeout = 100 - completers.push( - this.config.providerConfig.create({ - id: 'multiline', - ...sharedProviderOptions, - n: 3, // 3 vs. 1 does not meaningfully affect perf - multiline: true, - }) - ) - } else { - timeout = 20 - completers.push( - this.config.providerConfig.create({ - id: 'single-line-suffix', - ...sharedProviderOptions, - // Show more if manually triggered (but only showing 1 is faster, so we use it - // in the automatic trigger case). - n: context.triggerKind === vscode.InlineCompletionTriggerKind.Automatic ? 1 : 3, - multiline: false, - }) - ) - } - tracer?.({ completers: completers.map(({ options }) => options) }) - - if (!this.config.disableTimeouts && context.triggerKind !== vscode.InlineCompletionTriggerKind.Invoke) { - await delay(timeout) - } - - // We don't need to make a request at all if the signal is already aborted after the - // debounce - if (abortController.signal.aborted) { - return emptyCompletions() - } - - CompletionLogger.start(logId) - - const stopLoading = this.config.statusBar.startLoading('Completions are being generated') - this.stopLoading = stopLoading - // Overwrite the abort handler to also update the loading state - const previousAbort = this.abortOpenCompletions - this.abortOpenCompletions = () => { - previousAbort() - stopLoading() - } - - const contextResult = await this.config.contextFetcher({ - document, - prefix: docContext.prefix, - suffix: docContext.suffix, - history: this.config.history, - jaccardDistanceWindowSize: SNIPPET_WINDOW_SIZE, - maxChars: this.promptChars, - codebaseContext: this.config.codebaseContext, isEmbeddingsContextEnabled: this.config.isEmbeddingsContextEnabled, + toWorkspaceRelativePath: uri => vscode.workspace.asRelativePath(uri), + contextFetcher: this.config.contextFetcher, + codebaseContext: this.config.codebaseContext, + documentHistory: this.config.history, + requestManager: this.requestManager, + lastCandidate: this.lastCandidate, + debounceInterval: { singleLine: 25, multiLine: 125 }, + setIsLoading, + abortSignal: abortController.signal, + tracer, }) - if (abortController.signal.aborted) { - return emptyCompletions() - } - tracer?.({ context: contextResult }) - CompletionLogger.networkRequestStarted(logId, contextResult.logSummary) - - const completions = await this.requestManager.request( - document.uri.toString(), - logId, - docContext.prefix, - completers, - contextResult.context, - abortController.signal, - tracer ? createCompletionProviderTracer(tracer) : undefined - ) - - stopLoading() - return this.prepareCompletions( - logId, - completions, - document, - context, - position, - docContext.prefix, - docContext.suffix, - multiline, - document.languageId, - false, - abortController.signal - ) - } - - private async prepareCompletions( - logId: string, - completions: Completion[], - document: vscode.TextDocument, - context: vscode.InlineCompletionContext, - position: vscode.Position, - prefix: string, - suffix: string, - multiline: boolean, - languageId: string, - isCacheHit: boolean, - abortSignal: AbortSignal - ): Promise { - const results = processCompletions(completions, prefix, suffix, multiline, languageId) - - // We usually resolve cached results instantly. However, if the inserted completion would - // include more than one line, this can create a lot of visible UI churn. To avoid this, we - // debounce these results and wait for the user to stop typing for a bit before applying - // them. - // - // The duration we wait is longer than the debounce time for new requests because we do not - // have network latency for cache completion - const visibleResult = results[0] - if ( - isCacheHit && - visibleResult?.content.includes('\n') && - !this.config.disableTimeouts && - context.triggerKind !== vscode.InlineCompletionTriggerKind.Invoke - ) { - await delay(400) - if (abortSignal.aborted) { - return { items: [] } - } + // Track the last candidate completion (that is shown as ghost text in the editor) so that + // we can reuse it if the user types in such a way that it is still valid (such as by typing + // `ab` if the ghost text suggests `abcd`). + if (result && result.source !== InlineCompletionsResultSource.LastCandidate) { + this.lastCandidate = + result?.items.length > 0 + ? { + uri: document.uri, + originalTriggerPosition: position, + originalTriggerLinePrefix: document.lineAt(position).text.slice(0, position.character), + result: { + logId: result.logId, + items: result.items, + }, + } + : undefined } - if (results.length > 0) { - // When the VS Code completion popup is open and we suggest a completion that does not match - // the currently selected completion, VS Code won't display it. For now we make sure to not - // log these completions as displayed. - // - // TODO: Take this into account when creating the completion prefix. - let isCompletionVisible = true - if (context.selectedCompletionInfo) { - const currentText = document.getText(context.selectedCompletionInfo.range) - const selectedText = context.selectedCompletionInfo.text - if (!(currentText + results[0].content).startsWith(selectedText)) { - isCompletionVisible = false - } - } - - if (isCompletionVisible) { - CompletionLogger.suggest(logId, isCompletionVisible) - } - - return toInlineCompletionItems(logId, document, position, results) + return { + items: result ? processInlineCompletionsForVSCode(result.logId, document, position, result.items) : [], } - - CompletionLogger.noResponse(logId) - return emptyCompletions() } } -export interface Completion { - prefix: string - content: string - stopReason?: string -} - -function processCompletions( - completions: Completion[], - prefix: string, - suffix: string, - multiline: boolean, - languageId: string -): Completion[] { - // Shared post-processing logic - const processedCompletions = completions.map(completion => - sharedPostProcess({ prefix, suffix, multiline, languageId, completion }) - ) - - // Filter results - const visibleResults = filterCompletions(processedCompletions) - - // Remove duplicate results - const uniqueResults = [...new Map(visibleResults.map(c => [c.content, c])).values()] - - // Rank results - const rankedResults = rankCompletions(uniqueResults) - - return rankedResults -} - -function toInlineCompletionItems( +/** + * Process completions items in VS Code-specific ways. + */ +function processInlineCompletionsForVSCode( logId: string, document: vscode.TextDocument, position: vscode.Position, - completions: Completion[] -): vscode.InlineCompletionList { - return { - items: completions.map(completion => { - // Return the completion from the start of the current line (instead of starting at the - // given position). This avoids UI jitter in VS Code; when typing or deleting individual - // characters, VS Code reuses the existing completion while it waits for the new one to - // come in. - const currentLine = document.lineAt(position) - const currentLinePrefix = document.getText(currentLine.range.with({ end: position })) - return new vscode.InlineCompletionItem(currentLinePrefix + completion.content, currentLine.range, { - title: 'Completion accepted', - command: 'cody.autocomplete.inline.accepted', - arguments: [{ codyLogId: logId, codyLines: completion.content.split(/\r\n|\r|\n/).length }], - }) - }), - } -} - -function rankCompletions(completions: Completion[]): Completion[] { - // TODO(philipp-spiess): Improve ranking to something more complex then just length - return completions.sort((a, b) => b.content.split('\n').length - a.content.split('\n').length) -} - -function filterCompletions(completions: Completion[]): Completion[] { - return completions.filter(c => c.content.trim() !== '') + items: InlineCompletionItem[] +): vscode.InlineCompletionItem[] { + return items.map(completion => { + // Return the completion from the start of the current line (instead of starting at the + // given position). This avoids UI jitter in VS Code; when typing or deleting individual + // characters, VS Code reuses the existing completion while it waits for the new one to + // come in. + const currentLine = document.lineAt(position) + const currentLinePrefix = document.getText(currentLine.range.with({ end: position })) + return new vscode.InlineCompletionItem(currentLinePrefix + completion.insertText, currentLine.range, { + title: 'Completion accepted', + command: 'cody.autocomplete.inline.accepted', + arguments: [{ codyLogId: logId, codyLines: completion.insertText.split(/\r\n|\r|\n/).length }], + }) + }) } let globalInvocationSequenceForTracer = 0 -type SingleInvocationTracer = (data: Partial) => void - /** * Creates a tracer for a single invocation of * {@link CodyCompletionItemProvider.provideInlineCompletionItems} that accumulates all of the data * for that invocation. */ -function createTracerForInvocation(tracer: ProvideInlineCompletionItemsTracer): SingleInvocationTracer { +function createTracerForInvocation(tracer: ProvideInlineCompletionItemsTracer): InlineCompletionsParams['tracer'] { let data: ProvideInlineCompletionsItemTraceData = { invocationSequence: ++globalInvocationSequenceForTracer } return (update: Partial) => { data = { ...data, ...update } tracer(data) } } - -function emptyCompletions(): vscode.InlineCompletionList { - CompletionLogger.clear() - return { items: [] } -} - -function createCompletionProviderTracer(tracer: SingleInvocationTracer): CompletionProviderTracer { - return { - params: data => tracer({ completionProviderCallParams: data }), - result: data => tracer({ completionProviderCallResult: data }), - } -} - -function delay(milliseconds: number): Promise { - return new Promise(resolve => setTimeout(resolve, milliseconds)) -} diff --git a/vscode/src/testutils/textDocument.ts b/vscode/src/testutils/textDocument.ts index cdcab1aeaed9..a5f15e968b33 100644 --- a/vscode/src/testutils/textDocument.ts +++ b/vscode/src/testutils/textDocument.ts @@ -52,3 +52,7 @@ function createTextLine(text: string, range: Range): TextLine { isEmptyOrWhitespace: /^\s*$/.test(text), } } + +export function range(startLine: number, startCharacter: number, endLine?: number, endCharacter?: number): Range { + return new vsCodeMocks.Range(startLine, startCharacter, endLine || startLine, endCharacter || 0) +} diff --git a/vscode/test/completions/run-code-completions-on-dataset.ts b/vscode/test/completions/run-code-completions-on-dataset.ts index 59e554840c9e..e863b3d331b8 100644 --- a/vscode/test/completions/run-code-completions-on-dataset.ts +++ b/vscode/test/completions/run-code-completions-on-dataset.ts @@ -62,7 +62,6 @@ async function initCompletionsProvider(context: GetContextResult): Promise Promise.resolve(context), })