diff --git a/package-lock.json b/package-lock.json index 3cde8bc9641..285194fdc67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "plugins/*" ], "dependencies": { - "@aws/language-server-runtimes": "^0.2.125", + "@aws/language-server-runtimes": "^0.2.128", "@types/node": "^22.7.5", "jaro-winkler": "^0.2.8", "vscode-nls": "^5.2.0", @@ -18391,9 +18391,9 @@ } }, "node_modules/@aws/language-server-runtimes": { - "version": "0.2.125", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.125.tgz", - "integrity": "sha512-tjXJEagZ6rm09fcgJGu1zbFNzi0+7R1mdNFa6zCIv68c76xq5JHjc++Hne9aOgp61O6BM9uNnX3KR57v9/0E1g==", + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.128.tgz", + "integrity": "sha512-C666VAvY2PQ8CQkDzjL/+N9rfcFzY6vuGe733drMwwRVHt8On0B0PQPjy31ZjxHUUcjVp78Nb9vmSUEVBfxGTQ==", "license": "Apache-2.0", "dependencies": { "@aws/language-server-runtimes-types": "^0.1.56", diff --git a/package.json b/package.json index 3769279de1c..e713bfeb762 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "webpack-merge": "^5.10.0" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.125", + "@aws/language-server-runtimes": "^0.2.128", "@types/node": "^22.7.5", "jaro-winkler": "^0.2.8", "vscode-nls": "^5.2.0", diff --git a/packages/amazonq/src/app/inline/activation.ts b/packages/amazonq/src/app/inline/activation.ts index 13f451d5716..63038e0fd3c 100644 --- a/packages/amazonq/src/app/inline/activation.ts +++ b/packages/amazonq/src/app/inline/activation.ts @@ -5,29 +5,73 @@ import vscode from 'vscode' import { + acceptSuggestion, AuthUtil, + CodeSuggestionsState, + CodeWhispererCodeCoverageTracker, CodeWhispererConstants, + CodeWhispererSettings, + ConfigurationEntry, + DefaultCodeWhispererClient, + invokeRecommendation, isInlineCompletionEnabled, + KeyStrokeHandler, + RecommendationHandler, runtimeLanguageContext, + TelemetryHelper, UserWrittenCodeTracker, vsCodeState, } from 'aws-core-vscode/codewhisperer' -import { globals, sleep } from 'aws-core-vscode/shared' +import { Commands, getLogger, globals, sleep } from 'aws-core-vscode/shared' +import { LanguageClient } from 'vscode-languageclient' -export async function activate() { - if (isInlineCompletionEnabled()) { - // Debugging purpose: only initialize NextEditPredictionPanel when development - // NextEditPredictionPanel.getInstance() +export async function activate(languageClient: LanguageClient) { + const codewhispererSettings = CodeWhispererSettings.instance + const client = new DefaultCodeWhispererClient() + if (isInlineCompletionEnabled()) { await setSubscriptionsforInlineCompletion() await AuthUtil.instance.setVscodeContextProps() + RecommendationHandler.instance.setLanguageClient(languageClient) + } + + function getAutoTriggerStatus(): boolean { + return CodeSuggestionsState.instance.isSuggestionsEnabled() + } + + async function getConfigEntry(): Promise { + const isShowMethodsEnabled: boolean = + vscode.workspace.getConfiguration('editor').get('suggest.showMethods') || false + const isAutomatedTriggerEnabled: boolean = getAutoTriggerStatus() + const isManualTriggerEnabled: boolean = true + const isSuggestionsWithCodeReferencesEnabled = codewhispererSettings.isSuggestionsWithCodeReferencesEnabled() + + // TODO:remove isManualTriggerEnabled + return { + isShowMethodsEnabled, + isManualTriggerEnabled, + isAutomatedTriggerEnabled, + isSuggestionsWithCodeReferencesEnabled, + } } async function setSubscriptionsforInlineCompletion() { + RecommendationHandler.instance.subscribeSuggestionCommands() + /** * Automated trigger */ globals.context.subscriptions.push( + acceptSuggestion.register(globals.context), + vscode.window.onDidChangeActiveTextEditor(async (editor) => { + await RecommendationHandler.instance.onEditorChange() + }), + vscode.window.onDidChangeWindowState(async (e) => { + await RecommendationHandler.instance.onFocusChange() + }), + vscode.window.onDidChangeTextEditorSelection(async (e) => { + await RecommendationHandler.instance.onCursorChange(e) + }), vscode.workspace.onDidChangeTextDocument(async (e) => { const editor = vscode.window.activeTextEditor if (!editor) { @@ -40,6 +84,7 @@ export async function activate() { return } + CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e) UserWrittenCodeTracker.instance.onTextDocumentChange(e) /** * Handle this keystroke event only when @@ -51,6 +96,11 @@ export async function activate() { return } + if (vsCodeState.lastUserModificationTime) { + TelemetryHelper.instance.setTimeSinceLastModification( + performance.now() - vsCodeState.lastUserModificationTime + ) + } vsCodeState.lastUserModificationTime = performance.now() /** * Important: Doing this sleep(10) is to make sure @@ -59,6 +109,19 @@ export async function activate() { * Then this event can be processed by our code. */ await sleep(CodeWhispererConstants.vsCodeCursorUpdateDelay) + if (!RecommendationHandler.instance.isSuggestionVisible()) { + await KeyStrokeHandler.instance.processKeyStroke(e, editor, client, await getConfigEntry()) + } + }), + // manual trigger + Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { + invokeRecommendation( + vscode.window.activeTextEditor as vscode.TextEditor, + client, + await getConfigEntry() + ).catch((e: Error) => { + getLogger().error('invokeRecommendation failed: %s', (e as Error).message) + }) }) ) } diff --git a/packages/amazonq/src/lsp/client.ts b/packages/amazonq/src/lsp/client.ts index bc065c8f620..654b68fb914 100644 --- a/packages/amazonq/src/lsp/client.ts +++ b/packages/amazonq/src/lsp/client.ts @@ -21,6 +21,7 @@ import { import { AuthUtil, CodeWhispererSettings, + FeatureConfigProvider, getSelectedCustomization, TelemetryHelper, vsCodeState, @@ -45,6 +46,7 @@ import { } from 'aws-core-vscode/shared' import { processUtils } from 'aws-core-vscode/shared' import { activate } from './chat/activation' +import { activate as activateInline } from '../app/inline/activation' import { AmazonQResourcePaths } from './lspInstaller' import { ConfigSection, isValidConfigSection, pushConfigUpdate, toAmazonQLSPLogLevel } from './config' import { activate as activateInlineChat } from '../inlineChat/activation' @@ -338,8 +340,42 @@ async function onLanguageServerReady( // tutorial for inline chat const inlineChatTutorialAnnotation = new InlineChatTutorialAnnotation(inlineTutorialAnnotation) - const inlineManager = new InlineCompletionManager(client, sessionManager, lineTracker, inlineTutorialAnnotation) - inlineManager.registerInlineCompletion() + const enableInlineRollback = FeatureConfigProvider.instance.getPreFlareRollbackGroup() === 'treatment' + if (enableInlineRollback) { + // use VSC inline + getLogger().info('Entering preflare logic') + await activateInline(client) + } else { + // use language server for inline completion + getLogger().info('Entering postflare logic') + const inlineManager = new InlineCompletionManager(client, sessionManager, lineTracker, inlineTutorialAnnotation) + inlineManager.registerInlineCompletion() + toDispose.push( + inlineManager, + Commands.register('aws.amazonq.showPrev', async () => { + await sessionManager.maybeRefreshSessionUx() + await vscode.commands.executeCommand('editor.action.inlineSuggest.showPrevious') + sessionManager.onPrevSuggestion() + }), + Commands.register('aws.amazonq.showNext', async () => { + await sessionManager.maybeRefreshSessionUx() + await vscode.commands.executeCommand('editor.action.inlineSuggest.showNext') + sessionManager.onNextSuggestion() + }), + // this is a workaround since handleDidShowCompletionItem is not public API + Commands.register('aws.amazonq.checkInlineSuggestionVisibility', async () => { + sessionManager.checkInlineSuggestionVisibility() + }), + Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { + vsCodeState.lastManualTriggerTime = performance.now() + await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger') + }), + vscode.workspace.onDidCloseTextDocument(async () => { + await vscode.commands.executeCommand('aws.amazonq.rejectCodeSuggestion') + }) + ) + } + activateInlineChat(extensionContext, client, encryptionKey, inlineChatTutorialAnnotation) if (Experiments.instance.get('amazonqChatLSP', true)) { @@ -354,25 +390,6 @@ async function onLanguageServerReady( await initializeLanguageServerConfiguration(client, 'startup') toDispose.push( - inlineManager, - Commands.register('aws.amazonq.showPrev', async () => { - await sessionManager.maybeRefreshSessionUx() - await vscode.commands.executeCommand('editor.action.inlineSuggest.showPrevious') - sessionManager.onPrevSuggestion() - }), - Commands.register('aws.amazonq.showNext', async () => { - await sessionManager.maybeRefreshSessionUx() - await vscode.commands.executeCommand('editor.action.inlineSuggest.showNext') - sessionManager.onNextSuggestion() - }), - // this is a workaround since handleDidShowCompletionItem is not public API - Commands.register('aws.amazonq.checkInlineSuggestionVisibility', async () => { - sessionManager.checkInlineSuggestionVisibility() - }), - Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { - vsCodeState.lastManualTriggerTime = performance.now() - await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger') - }), Commands.register('aws.amazonq.refreshAnnotation', async (forceProceed: boolean) => { telemetry.record({ traceId: TelemetryHelper.instance.traceId, @@ -398,9 +415,6 @@ async function onLanguageServerReady( getLogger().debug(`codewhisperer: user dismiss tutorial.`) } }), - vscode.workspace.onDidCloseTextDocument(async () => { - await vscode.commands.executeCommand('aws.amazonq.rejectCodeSuggestion') - }), AuthUtil.instance.auth.onDidChangeActiveConnection(async () => { await auth.refreshConnection() }), diff --git a/packages/amazonq/test/unit/codewhisperer/commands/invokeRecommendation.test.ts b/packages/amazonq/test/unit/codewhisperer/commands/invokeRecommendation.test.ts new file mode 100644 index 00000000000..68cebe37bb1 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/commands/invokeRecommendation.test.ts @@ -0,0 +1,43 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { resetCodeWhispererGlobalVariables, createMockTextEditor } from 'aws-core-vscode/test' +import { + ConfigurationEntry, + invokeRecommendation, + InlineCompletionService, + isInlineCompletionEnabled, + DefaultCodeWhispererClient, +} from 'aws-core-vscode/codewhisperer' + +describe('invokeRecommendation', function () { + describe('invokeRecommendation', function () { + let getRecommendationStub: sinon.SinonStub + let mockClient: DefaultCodeWhispererClient + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + getRecommendationStub = sinon.stub(InlineCompletionService.instance, 'getPaginatedRecommendation') + }) + + afterEach(function () { + sinon.restore() + }) + + it('Should call getPaginatedRecommendation with OnDemand as trigger type when inline completion is enabled', async function () { + const mockEditor = createMockTextEditor() + const config: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: true, + isSuggestionsWithCodeReferencesEnabled: true, + } + await invokeRecommendation(mockEditor, mockClient, config) + assert.strictEqual(getRecommendationStub.called, isInlineCompletionEnabled()) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/commands/onAcceptance.test.ts b/packages/amazonq/test/unit/codewhisperer/commands/onAcceptance.test.ts new file mode 100644 index 00000000000..0471aaa3601 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/commands/onAcceptance.test.ts @@ -0,0 +1,64 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import { onAcceptance, AcceptedSuggestionEntry, session, CodeWhispererTracker } from 'aws-core-vscode/codewhisperer' +import { resetCodeWhispererGlobalVariables, createMockTextEditor } from 'aws-core-vscode/test' + +describe('onAcceptance', function () { + describe('onAcceptance', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + session.reset() + }) + + afterEach(function () { + sinon.restore() + session.reset() + }) + + it('Should enqueue an event object to tracker', async function () { + const mockEditor = createMockTextEditor() + const trackerSpy = sinon.spy(CodeWhispererTracker.prototype, 'enqueue') + const fakeReferences = [ + { + message: '', + licenseName: 'MIT', + repository: 'http://github.com/fake', + recommendationContentSpan: { + start: 0, + end: 10, + }, + }, + ] + await onAcceptance({ + editor: mockEditor, + range: new vscode.Range(new vscode.Position(1, 0), new vscode.Position(1, 26)), + effectiveRange: new vscode.Range(new vscode.Position(1, 0), new vscode.Position(1, 26)), + acceptIndex: 0, + recommendation: "print('Hello World!')", + requestId: '', + sessionId: '', + triggerType: 'OnDemand', + completionType: 'Line', + language: 'python', + references: fakeReferences, + }) + const actualArg = trackerSpy.getCall(0).args[0] as AcceptedSuggestionEntry + assert.ok(trackerSpy.calledOnce) + assert.strictEqual(actualArg.originalString, 'def two_sum(nums, target):') + assert.strictEqual(actualArg.requestId, '') + assert.strictEqual(actualArg.sessionId, '') + assert.strictEqual(actualArg.triggerType, 'OnDemand') + assert.strictEqual(actualArg.completionType, 'Line') + assert.strictEqual(actualArg.language, 'python') + assert.deepStrictEqual(actualArg.startPosition, new vscode.Position(1, 0)) + assert.deepStrictEqual(actualArg.endPosition, new vscode.Position(1, 26)) + assert.strictEqual(actualArg.index, 0) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/commands/onInlineAcceptance.test.ts b/packages/amazonq/test/unit/codewhisperer/commands/onInlineAcceptance.test.ts new file mode 100644 index 00000000000..ed3bc99fa34 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/commands/onInlineAcceptance.test.ts @@ -0,0 +1,43 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import { resetCodeWhispererGlobalVariables, createMockTextEditor } from 'aws-core-vscode/test' +import { onInlineAcceptance, RecommendationHandler, session } from 'aws-core-vscode/codewhisperer' + +describe('onInlineAcceptance', function () { + describe('onInlineAcceptance', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + session.reset() + }) + + afterEach(function () { + sinon.restore() + session.reset() + }) + + it('Should dispose inline completion provider', async function () { + const mockEditor = createMockTextEditor() + const spy = sinon.spy(RecommendationHandler.instance, 'disposeInlineCompletion') + await onInlineAcceptance({ + editor: mockEditor, + range: new vscode.Range(new vscode.Position(1, 0), new vscode.Position(1, 21)), + effectiveRange: new vscode.Range(new vscode.Position(1, 0), new vscode.Position(1, 21)), + acceptIndex: 0, + recommendation: "print('Hello World!')", + requestId: '', + sessionId: '', + triggerType: 'OnDemand', + completionType: 'Line', + language: 'python', + references: undefined, + }) + assert.ok(spy.calledWith()) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/service/inlineCompletionService.test.ts b/packages/amazonq/test/unit/codewhisperer/service/inlineCompletionService.test.ts new file mode 100644 index 00000000000..a35677408c4 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/service/inlineCompletionService.test.ts @@ -0,0 +1,173 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import assert from 'assert' +import * as sinon from 'sinon' +import { + InlineCompletionService, + ReferenceInlineProvider, + RecommendationHandler, + ConfigurationEntry, + CWInlineCompletionItemProvider, + session, + DefaultCodeWhispererClient, +} from 'aws-core-vscode/codewhisperer' +import { createMockTextEditor, resetCodeWhispererGlobalVariables, createMockDocument } from 'aws-core-vscode/test' + +describe('inlineCompletionService', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + + describe('getPaginatedRecommendation', function () { + const config: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: true, + isSuggestionsWithCodeReferencesEnabled: true, + } + + let mockClient: DefaultCodeWhispererClient + + beforeEach(async function () { + mockClient = new DefaultCodeWhispererClient() + await resetCodeWhispererGlobalVariables() + }) + + afterEach(function () { + sinon.restore() + }) + + it('should call checkAndResetCancellationTokens before showing inline and next token to be null', async function () { + const mockEditor = createMockTextEditor() + sinon.stub(RecommendationHandler.instance, 'getRecommendations').resolves({ + result: 'Succeeded', + errorMessage: undefined, + recommendationCount: 1, + }) + const checkAndResetCancellationTokensStub = sinon.stub( + RecommendationHandler.instance, + 'checkAndResetCancellationTokens' + ) + session.recommendations = [{ content: "\n\t\tconsole.log('Hello world!');\n\t}" }, { content: '' }] + await InlineCompletionService.instance.getPaginatedRecommendation( + mockClient, + mockEditor, + 'OnDemand', + config + ) + assert.ok(checkAndResetCancellationTokensStub.called) + assert.strictEqual(RecommendationHandler.instance.hasNextToken(), false) + }) + }) + + describe('clearInlineCompletionStates', function () { + it('should remove inline reference and recommendations', async function () { + const fakeReferences = [ + { + message: '', + licenseName: 'MIT', + repository: 'http://github.com/fake', + recommendationContentSpan: { + start: 0, + end: 10, + }, + }, + ] + ReferenceInlineProvider.instance.setInlineReference(1, 'test', fakeReferences) + session.recommendations = [{ content: "\n\t\tconsole.log('Hello world!');\n\t}" }, { content: '' }] + session.language = 'python' + + assert.ok(session.recommendations.length > 0) + await RecommendationHandler.instance.clearInlineCompletionStates() + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 0) + assert.strictEqual(session.recommendations.length, 0) + }) + }) + + describe('truncateOverlapWithRightContext', function () { + const fileName = 'test.py' + const language = 'python' + const rightContext = 'return target\n' + const doc = `import math\ndef two_sum(nums, target):\n` + const provider = new CWInlineCompletionItemProvider(0, 0, [], '', new vscode.Position(0, 0), '') + + it('removes overlap with right context from suggestion', async function () { + const mockSuggestion = 'return target\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, '') + }) + + it('only removes the overlap part from suggestion', async function () { + const mockSuggestion = 'print(nums)\nreturn target\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, 'print(nums)\n') + }) + + it('only removes the last overlap pattern from suggestion', async function () { + const mockSuggestion = 'return target\nprint(nums)\nreturn target\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, 'return target\nprint(nums)\n') + }) + + it('returns empty string if the remaining suggestion only contains white space', async function () { + const mockSuggestion = 'return target\n ' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, '') + }) + + it('returns the original suggestion if no match found', async function () { + const mockSuggestion = 'import numpy\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, 'import numpy\n') + }) + + it('ignores the space at the end of recommendation', async function () { + const mockSuggestion = 'return target\n\n\n\n\n' + const mockEditor = createMockTextEditor(`${doc}${rightContext}`, fileName, language) + const cursorPosition = new vscode.Position(2, 0) + const result = provider.truncateOverlapWithRightContext(mockEditor.document, mockSuggestion, cursorPosition) + assert.strictEqual(result, '') + }) + }) +}) + +describe('CWInlineCompletionProvider', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + + describe('provideInlineCompletionItems', function () { + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + + afterEach(function () { + sinon.restore() + }) + + it('should return undefined if position is before RecommendationHandler start pos', async function () { + const position = new vscode.Position(0, 0) + const document = createMockDocument() + const fakeContext = { triggerKind: 0, selectedCompletionInfo: undefined } + const token = new vscode.CancellationTokenSource().token + const provider = new CWInlineCompletionItemProvider(0, 0, [], '', new vscode.Position(1, 1), '') + const result = await provider.provideInlineCompletionItems(document, position, fakeContext, token) + + assert.ok(result === undefined) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts b/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts new file mode 100644 index 00000000000..4b6a5291f22 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/service/keyStrokeHandler.test.ts @@ -0,0 +1,237 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import * as codewhispererSdkClient from 'aws-core-vscode/codewhisperer' +import { + createMockTextEditor, + createTextDocumentChangeEvent, + resetCodeWhispererGlobalVariables, +} from 'aws-core-vscode/test' +import * as EditorContext from 'aws-core-vscode/codewhisperer' +import { + ConfigurationEntry, + DocumentChangedSource, + KeyStrokeHandler, + DefaultDocumentChangedType, + RecommendationService, + ClassifierTrigger, + isInlineCompletionEnabled, + RecommendationHandler, + InlineCompletionService, +} from 'aws-core-vscode/codewhisperer' + +describe('keyStrokeHandler', function () { + const config: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: true, + isSuggestionsWithCodeReferencesEnabled: true, + } + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + describe('processKeyStroke', async function () { + let invokeSpy: sinon.SinonStub + let startTimerSpy: sinon.SinonStub + let mockClient: codewhispererSdkClient.DefaultCodeWhispererClient + beforeEach(async function () { + invokeSpy = sinon.stub(KeyStrokeHandler.instance, 'invokeAutomatedTrigger') + startTimerSpy = sinon.stub(KeyStrokeHandler.instance, 'startIdleTimeTriggerTimer') + sinon.spy(RecommendationHandler.instance, 'getRecommendations') + mockClient = new codewhispererSdkClient.DefaultCodeWhispererClient() + await resetCodeWhispererGlobalVariables() + sinon.stub(mockClient, 'listRecommendations') + sinon.stub(mockClient, 'generateRecommendations') + }) + afterEach(function () { + sinon.restore() + }) + + it('Whatever the input is, should skip when automatic trigger is turned off, should not call invokeAutomatedTrigger', async function () { + const mockEditor = createMockTextEditor() + const mockEvent: vscode.TextDocumentChangeEvent = createTextDocumentChangeEvent( + mockEditor.document, + new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 1)), + ' ' + ) + const cfg: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: false, + isSuggestionsWithCodeReferencesEnabled: true, + } + const keyStrokeHandler = new KeyStrokeHandler() + await keyStrokeHandler.processKeyStroke(mockEvent, mockEditor, mockClient, cfg) + assert.ok(!invokeSpy.called) + assert.ok(!startTimerSpy.called) + }) + + it('Should not call invokeAutomatedTrigger when changed text across multiple lines', async function () { + await testShouldInvoke('\nprint(n', false) + }) + + it('Should not call invokeAutomatedTrigger when doing delete or undo (empty changed text)', async function () { + await testShouldInvoke('', false) + }) + + it('Should call invokeAutomatedTrigger with Enter when inputing \n', async function () { + await testShouldInvoke('\n', true) + }) + + it('Should call invokeAutomatedTrigger with Enter when inputing \r\n', async function () { + await testShouldInvoke('\r\n', true) + }) + + it('Should call invokeAutomatedTrigger with SpecialCharacter when inputing {', async function () { + await testShouldInvoke('{', true) + }) + + it('Should not call invokeAutomatedTrigger for non-special characters for classifier language if classifier says no', async function () { + sinon.stub(ClassifierTrigger.instance, 'shouldTriggerFromClassifier').returns(false) + await testShouldInvoke('a', false) + }) + + it('Should call invokeAutomatedTrigger for non-special characters for classifier language if classifier says yes', async function () { + sinon.stub(ClassifierTrigger.instance, 'shouldTriggerFromClassifier').returns(true) + await testShouldInvoke('a', true) + }) + + it('Should skip invoking if there is immediate right context on the same line and not a single }', async function () { + const casesForSuppressTokenFilling = [ + { + rightContext: 'add', + shouldInvoke: false, + }, + { + rightContext: '}', + shouldInvoke: true, + }, + { + rightContext: '} ', + shouldInvoke: true, + }, + { + rightContext: ')', + shouldInvoke: true, + }, + { + rightContext: ') ', + shouldInvoke: true, + }, + { + rightContext: ' add', + shouldInvoke: true, + }, + { + rightContext: ' ', + shouldInvoke: true, + }, + { + rightContext: '\naddTwo', + shouldInvoke: true, + }, + ] + + for (const o of casesForSuppressTokenFilling) { + await testShouldInvoke('{', o.shouldInvoke, o.rightContext) + } + }) + + async function testShouldInvoke(input: string, shouldTrigger: boolean, rightContext: string = '') { + const mockEditor = createMockTextEditor(rightContext, 'test.js', 'javascript', 0, 0) + const mockEvent: vscode.TextDocumentChangeEvent = createTextDocumentChangeEvent( + mockEditor.document, + new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 1)), + input + ) + await KeyStrokeHandler.instance.processKeyStroke(mockEvent, mockEditor, mockClient, config) + assert.strictEqual( + invokeSpy.called, + shouldTrigger, + `invokeAutomatedTrigger ${shouldTrigger ? 'NOT' : 'WAS'} called for rightContext: "${rightContext}"` + ) + } + }) + + describe('invokeAutomatedTrigger', function () { + let mockClient: codewhispererSdkClient.DefaultCodeWhispererClient + beforeEach(async function () { + sinon.restore() + mockClient = new codewhispererSdkClient.DefaultCodeWhispererClient() + await resetCodeWhispererGlobalVariables() + sinon.stub(mockClient, 'listRecommendations') + sinon.stub(mockClient, 'generateRecommendations') + }) + afterEach(function () { + sinon.restore() + }) + + it('should call getPaginatedRecommendation when inline completion is enabled', async function () { + const mockEditor = createMockTextEditor() + const keyStrokeHandler = new KeyStrokeHandler() + const mockEvent: vscode.TextDocumentChangeEvent = createTextDocumentChangeEvent( + mockEditor.document, + new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 1)), + ' ' + ) + const getRecommendationsStub = sinon.stub(InlineCompletionService.instance, 'getPaginatedRecommendation') + await keyStrokeHandler.invokeAutomatedTrigger('Enter', mockEditor, mockClient, config, mockEvent) + assert.strictEqual(getRecommendationsStub.called, isInlineCompletionEnabled()) + }) + }) + + describe('shouldTriggerIdleTime', function () { + it('should return false when inline is enabled and inline completion is in progress ', function () { + const keyStrokeHandler = new KeyStrokeHandler() + sinon.stub(RecommendationService.instance, 'isRunning').get(() => true) + const result = keyStrokeHandler.shouldTriggerIdleTime() + assert.strictEqual(result, !isInlineCompletionEnabled()) + }) + }) + + describe('test checkChangeSource', function () { + const tabStr = ' '.repeat(EditorContext.getTabSize()) + + const cases: [string, DocumentChangedSource][] = [ + ['\n ', DocumentChangedSource.EnterKey], + ['\n', DocumentChangedSource.EnterKey], + ['(', DocumentChangedSource.SpecialCharsKey], + ['()', DocumentChangedSource.SpecialCharsKey], + ['{}', DocumentChangedSource.SpecialCharsKey], + ['(a, b):', DocumentChangedSource.Unknown], + [':', DocumentChangedSource.SpecialCharsKey], + ['a', DocumentChangedSource.RegularKey], + [tabStr, DocumentChangedSource.TabKey], + [' ', DocumentChangedSource.Reformatting], + ['def add(a,b):\n return a + b\n', DocumentChangedSource.Unknown], + ['function suggestedByIntelliSense():', DocumentChangedSource.Unknown], + ] + + for (const tuple of cases) { + const input = tuple[0] + const expected = tuple[1] + it(`test input ${input} should return ${expected}`, function () { + const actual = new DefaultDocumentChangedType( + createFakeDocumentChangeEvent(tuple[0]) + ).checkChangeSource() + assert.strictEqual(actual, expected) + }) + } + + function createFakeDocumentChangeEvent(str: string): ReadonlyArray { + return [ + { + range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 5)), + rangeOffset: 0, + rangeLength: 0, + text: str, + }, + ] + } + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts b/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts new file mode 100644 index 00000000000..86dfc5e514c --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/service/recommendationHandler.test.ts @@ -0,0 +1,271 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import { + ReferenceInlineProvider, + session, + AuthUtil, + DefaultCodeWhispererClient, + RecommendationsList, + ConfigurationEntry, + RecommendationHandler, + CodeWhispererCodeCoverageTracker, + supplementalContextUtil, +} from 'aws-core-vscode/codewhisperer' +import { + assertTelemetryCurried, + stub, + createMockTextEditor, + resetCodeWhispererGlobalVariables, +} from 'aws-core-vscode/test' +// import * as supplementalContextUtil from 'aws-core-vscode/codewhisperer' + +describe('recommendationHandler', function () { + const config: ConfigurationEntry = { + isShowMethodsEnabled: true, + isManualTriggerEnabled: true, + isAutomatedTriggerEnabled: true, + isSuggestionsWithCodeReferencesEnabled: true, + } + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + }) + + describe('getRecommendations', async function () { + const mockClient = stub(DefaultCodeWhispererClient) + const mockEditor = createMockTextEditor() + const testStartUrl = 'testStartUrl' + + beforeEach(async function () { + sinon.restore() + await resetCodeWhispererGlobalVariables() + mockClient.listRecommendations.resolves({}) + mockClient.generateRecommendations.resolves({}) + RecommendationHandler.instance.clearRecommendations() + sinon.stub(AuthUtil.instance, 'startUrl').value(testStartUrl) + }) + + afterEach(function () { + sinon.restore() + }) + + it('should assign correct recommendations given input', async function () { + assert.strictEqual(CodeWhispererCodeCoverageTracker.instances.size, 0) + assert.strictEqual( + CodeWhispererCodeCoverageTracker.getTracker(mockEditor.document.languageId)?.serviceInvocationCount, + 0 + ) + + const mockServerResult = { + recommendations: [{ content: "print('Hello World!')" }, { content: '' }], + $response: { + requestId: 'test_request', + httpResponse: { + headers: { + 'x-amzn-sessionid': 'test_request', + }, + }, + }, + } + const handler = new RecommendationHandler() + sinon.stub(handler, 'getServerResponse').resolves(mockServerResult) + await handler.getRecommendations(mockClient, mockEditor, 'AutoTrigger', config, 'Enter', false) + const actual = session.recommendations + const expected: RecommendationsList = [{ content: "print('Hello World!')" }, { content: '' }] + assert.deepStrictEqual(actual, expected) + assert.strictEqual( + CodeWhispererCodeCoverageTracker.getTracker(mockEditor.document.languageId)?.serviceInvocationCount, + 1 + ) + }) + + it('should assign request id correctly', async function () { + const mockServerResult = { + recommendations: [{ content: "print('Hello World!')" }, { content: '' }], + $response: { + requestId: 'test_request', + httpResponse: { + headers: { + 'x-amzn-sessionid': 'test_request', + }, + }, + }, + } + const handler = new RecommendationHandler() + sinon.stub(handler, 'getServerResponse').resolves(mockServerResult) + sinon.stub(handler, 'isCancellationRequested').returns(false) + await handler.getRecommendations(mockClient, mockEditor, 'AutoTrigger', config, 'Enter', false) + assert.strictEqual(handler.requestId, 'test_request') + assert.strictEqual(session.sessionId, 'test_request') + assert.strictEqual(session.triggerType, 'AutoTrigger') + }) + + it('should call telemetry function that records a CodeWhisperer service invocation', async function () { + const mockServerResult = { + recommendations: [{ content: "print('Hello World!')" }, { content: '' }], + $response: { + requestId: 'test_request', + httpResponse: { + headers: { + 'x-amzn-sessionid': 'test_request', + }, + }, + }, + } + const handler = new RecommendationHandler() + sinon.stub(handler, 'getServerResponse').resolves(mockServerResult) + sinon.stub(supplementalContextUtil, 'fetchSupplementalContext').resolves({ + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: [], + contentsLength: 100, + latency: 0, + strategy: 'empty', + }) + sinon.stub(performance, 'now').returns(0.0) + session.startPos = new vscode.Position(1, 0) + session.startCursorOffset = 2 + await handler.getRecommendations(mockClient, mockEditor, 'AutoTrigger', config, 'Enter') + const assertTelemetry = assertTelemetryCurried('codewhisperer_serviceInvocation') + assertTelemetry({ + codewhispererRequestId: 'test_request', + codewhispererSessionId: 'test_request', + codewhispererLastSuggestionIndex: 1, + codewhispererTriggerType: 'AutoTrigger', + codewhispererAutomatedTriggerType: 'Enter', + codewhispererImportRecommendationEnabled: true, + result: 'Succeeded', + codewhispererLineNumber: 1, + codewhispererCursorOffset: 38, + codewhispererLanguage: 'python', + credentialStartUrl: testStartUrl, + codewhispererSupplementalContextIsUtg: false, + codewhispererSupplementalContextTimeout: false, + codewhispererSupplementalContextLatency: 0, + codewhispererSupplementalContextLength: 100, + }) + }) + }) + + describe('isValidResponse', function () { + afterEach(function () { + sinon.restore() + }) + it('should return true if any response is not empty', function () { + const handler = new RecommendationHandler() + session.recommendations = [ + { + content: + '\n // Use the console to output debug info…n of the command with the "command" variable', + }, + { content: '' }, + ] + assert.ok(handler.isValidResponse()) + }) + + it('should return false if response is empty', function () { + const handler = new RecommendationHandler() + session.recommendations = [] + assert.ok(!handler.isValidResponse()) + }) + + it('should return false if all response has no string length', function () { + const handler = new RecommendationHandler() + session.recommendations = [{ content: '' }, { content: '' }] + assert.ok(!handler.isValidResponse()) + }) + }) + + describe('setCompletionType/getCompletionType', function () { + beforeEach(function () { + sinon.restore() + }) + + it('should set the completion type to block given a multi-line suggestion', function () { + session.setCompletionType(0, { content: 'test\n\n \t\r\nanother test' }) + assert.strictEqual(session.getCompletionType(0), 'Block') + + session.setCompletionType(0, { content: 'test\ntest\n' }) + assert.strictEqual(session.getCompletionType(0), 'Block') + + session.setCompletionType(0, { content: '\n \t\r\ntest\ntest' }) + assert.strictEqual(session.getCompletionType(0), 'Block') + }) + + it('should set the completion type to line given a single-line suggestion', function () { + session.setCompletionType(0, { content: 'test' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + + session.setCompletionType(0, { content: 'test\r\t ' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + }) + + it('should set the completion type to line given a multi-line completion but only one-lien of non-blank sequence', function () { + session.setCompletionType(0, { content: 'test\n\t' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + + session.setCompletionType(0, { content: 'test\n ' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + + session.setCompletionType(0, { content: 'test\n\r' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + + session.setCompletionType(0, { content: '\n\n\n\ntest' }) + assert.strictEqual(session.getCompletionType(0), 'Line') + }) + }) + + describe('on event change', async function () { + beforeEach(function () { + const fakeReferences = [ + { + message: '', + licenseName: 'MIT', + repository: 'http://github.com/fake', + recommendationContentSpan: { + start: 0, + end: 10, + }, + }, + ] + ReferenceInlineProvider.instance.setInlineReference(1, 'test', fakeReferences) + session.sessionId = '' + RecommendationHandler.instance.requestId = '' + }) + + it('should remove inline reference onEditorChange', async function () { + session.sessionId = 'aSessionId' + RecommendationHandler.instance.requestId = 'aRequestId' + await RecommendationHandler.instance.onEditorChange() + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 0) + }) + it('should remove inline reference onFocusChange', async function () { + session.sessionId = 'aSessionId' + RecommendationHandler.instance.requestId = 'aRequestId' + await RecommendationHandler.instance.onFocusChange() + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 0) + }) + it('should not remove inline reference on cursor change from typing', async function () { + await RecommendationHandler.instance.onCursorChange({ + textEditor: createMockTextEditor(), + selections: [], + kind: vscode.TextEditorSelectionChangeKind.Keyboard, + }) + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 1) + }) + + it('should remove inline reference on cursor change from mouse movement', async function () { + await RecommendationHandler.instance.onCursorChange({ + textEditor: vscode.window.activeTextEditor!, + selections: [], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }) + assert.strictEqual(ReferenceInlineProvider.instance.refs.length, 0) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/tracker/codewhispererCodeCoverageTracker.test.ts b/packages/amazonq/test/unit/codewhisperer/tracker/codewhispererCodeCoverageTracker.test.ts new file mode 100644 index 00000000000..ee001b3328d --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/tracker/codewhispererCodeCoverageTracker.test.ts @@ -0,0 +1,560 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { + CodeWhispererCodeCoverageTracker, + vsCodeState, + TelemetryHelper, + AuthUtil, + getUnmodifiedAcceptedTokens, +} from 'aws-core-vscode/codewhisperer' +import { createMockDocument, createMockTextEditor, resetCodeWhispererGlobalVariables } from 'aws-core-vscode/test' +import { globals } from 'aws-core-vscode/shared' +import { assertTelemetryCurried } from 'aws-core-vscode/test' + +describe('codewhispererCodecoverageTracker', function () { + const language = 'python' + + describe('test getTracker', function () { + afterEach(async function () { + await resetCodeWhispererGlobalVariables() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('unsupported language', function () { + assert.strictEqual(CodeWhispererCodeCoverageTracker.getTracker('vb'), undefined) + assert.strictEqual(CodeWhispererCodeCoverageTracker.getTracker('ipynb'), undefined) + }) + + it('supported language', function () { + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('python'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('javascriptreact'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('java'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('javascript'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('cpp'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('ruby'), undefined) + assert.notStrictEqual(CodeWhispererCodeCoverageTracker.getTracker('go'), undefined) + }) + + it('supported language and should return singleton object per language', function () { + let instance1: CodeWhispererCodeCoverageTracker | undefined + let instance2: CodeWhispererCodeCoverageTracker | undefined + instance1 = CodeWhispererCodeCoverageTracker.getTracker('java') + instance2 = CodeWhispererCodeCoverageTracker.getTracker('java') + assert.notStrictEqual(instance1, undefined) + assert.strictEqual(Object.is(instance1, instance2), true) + + instance1 = CodeWhispererCodeCoverageTracker.getTracker('python') + instance2 = CodeWhispererCodeCoverageTracker.getTracker('python') + assert.notStrictEqual(instance1, undefined) + assert.strictEqual(Object.is(instance1, instance2), true) + + instance1 = CodeWhispererCodeCoverageTracker.getTracker('javascriptreact') + instance2 = CodeWhispererCodeCoverageTracker.getTracker('javascriptreact') + assert.notStrictEqual(instance1, undefined) + assert.strictEqual(Object.is(instance1, instance2), true) + }) + }) + + describe('test isActive', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + afterEach(async function () { + await resetCodeWhispererGlobalVariables() + CodeWhispererCodeCoverageTracker.instances.clear() + sinon.restore() + }) + + it('inactive case: telemetryEnable = true, isConnected = false', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(true) + sinon.stub(AuthUtil.instance, 'isConnected').returns(false) + + tracker = CodeWhispererCodeCoverageTracker.getTracker('python') + if (!tracker) { + assert.fail() + } + + assert.strictEqual(tracker.isActive(), false) + }) + + it('inactive case: telemetryEnabled = false, isConnected = false', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(false) + sinon.stub(AuthUtil.instance, 'isConnected').returns(false) + + tracker = CodeWhispererCodeCoverageTracker.getTracker('java') + if (!tracker) { + assert.fail() + } + + assert.strictEqual(tracker.isActive(), false) + }) + + it('active case: telemetryEnabled = true, isConnected = true', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(true) + sinon.stub(AuthUtil.instance, 'isConnected').returns(true) + + tracker = CodeWhispererCodeCoverageTracker.getTracker('javascript') + if (!tracker) { + assert.fail() + } + assert.strictEqual(tracker.isActive(), true) + }) + }) + + describe('updateAcceptedTokensCount', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should compute edit distance to update the accepted tokens', function () { + if (!tracker) { + assert.fail() + } + const editor = createMockTextEditor('def addTwoNumbers(a, b):\n') + + tracker.addAcceptedTokens(editor.document.fileName, { + range: new vscode.Range(0, 0, 0, 25), + text: `def addTwoNumbers(x, y):\n`, + accepted: 25, + }) + tracker.addTotalTokens(editor.document.fileName, 100) + tracker.updateAcceptedTokensCount(editor) + assert.strictEqual(tracker?.acceptedTokens[editor.document.fileName][0].accepted, 23) + }) + }) + + describe('getUnmodifiedAcceptedTokens', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should return correct unmodified accepted tokens count', function () { + assert.strictEqual(getUnmodifiedAcceptedTokens('foo', 'fou'), 2) + assert.strictEqual(getUnmodifiedAcceptedTokens('foo', 'f11111oo'), 3) + assert.strictEqual(getUnmodifiedAcceptedTokens('foo', 'fo'), 2) + assert.strictEqual(getUnmodifiedAcceptedTokens('helloworld', 'HelloWorld'), 8) + assert.strictEqual(getUnmodifiedAcceptedTokens('helloworld', 'World'), 4) + assert.strictEqual(getUnmodifiedAcceptedTokens('CodeWhisperer', 'CODE'), 1) + assert.strictEqual(getUnmodifiedAcceptedTokens('CodeWhisperer', 'CodeWhispererGood'), 13) + }) + }) + + describe('countAcceptedTokens', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should skip when tracker is not active', function () { + if (!tracker) { + assert.fail() + } + tracker.countAcceptedTokens(new vscode.Range(0, 0, 0, 1), 'a', 'test.py') + const spy = sinon.spy(CodeWhispererCodeCoverageTracker.prototype, 'addAcceptedTokens') + assert.ok(!spy.called) + }) + + it('Should increase AcceptedTokens', function () { + if (!tracker) { + assert.fail() + } + tracker.countAcceptedTokens(new vscode.Range(0, 0, 0, 1), 'a', 'test.py') + assert.deepStrictEqual(tracker.acceptedTokens['test.py'][0], { + range: new vscode.Range(0, 0, 0, 1), + text: 'a', + accepted: 1, + }) + }) + it('Should increase TotalTokens', function () { + if (!tracker) { + assert.fail() + } + tracker.countAcceptedTokens(new vscode.Range(0, 0, 0, 1), 'a', 'test.py') + tracker.countAcceptedTokens(new vscode.Range(0, 0, 0, 1), 'b', 'test.py') + assert.deepStrictEqual(tracker.totalTokens['test.py'], 2) + }) + }) + + describe('countTotalTokens', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should skip when content change size is more than 50', function () { + if (!tracker) { + assert.fail() + } + tracker.countTotalTokens({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 600), + rangeOffset: 0, + rangeLength: 600, + text: 'def twoSum(nums, target):\nfor '.repeat(20), + }, + ], + }) + assert.strictEqual(Object.keys(tracker.totalTokens).length, 0) + }) + + it('Should not skip when content change size is less than 50', function () { + if (!tracker) { + assert.fail() + } + tracker.countTotalTokens({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 49), + rangeOffset: 0, + rangeLength: 49, + text: 'a = 123'.repeat(7), + }, + ], + }) + assert.strictEqual(Object.keys(tracker.totalTokens).length, 1) + assert.strictEqual(Object.values(tracker.totalTokens)[0], 49) + }) + + it('Should skip when CodeWhisperer is editing', function () { + if (!tracker) { + assert.fail() + } + vsCodeState.isCodeWhispererEditing = true + tracker.countTotalTokens({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 30), + rangeOffset: 0, + rangeLength: 30, + text: 'def twoSum(nums, target):\nfor', + }, + ], + }) + const startedSpy = sinon.spy(CodeWhispererCodeCoverageTracker.prototype, 'addTotalTokens') + assert.ok(!startedSpy.called) + }) + + it('Should not reduce tokens when delete', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('import math', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'a', + }, + ], + }) + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'b', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 2) + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 1, + rangeLength: 1, + text: '', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 2) + }) + + it('Should add tokens when type', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('import math', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'a', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 1) + }) + + it('Should add tokens when hitting enter with indentation', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('def h():', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 8), + rangeOffset: 0, + rangeLength: 0, + text: '\n ', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 1) + }) + + it('Should add tokens when hitting enter with indentation in Windows', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('def h():', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 8), + rangeOffset: 0, + rangeLength: 0, + text: '\r\n ', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 1) + }) + + it('Should add tokens when hitting enter with indentation in Java', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('class A() {', 'test.java', 'java') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 11), + rangeOffset: 0, + rangeLength: 0, + text: '', + }, + { + range: new vscode.Range(0, 0, 0, 11), + rangeOffset: 0, + rangeLength: 0, + text: '\n\t\t', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 1) + }) + + it('Should add tokens when inserting closing brackets', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('a=', 'test.py', 'python') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 3), + rangeOffset: 0, + rangeLength: 0, + text: '[]', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 2) + }) + + it('Should add tokens when inserting closing brackets in Java', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('class A ', 'test.java', 'java') + tracker.countTotalTokens({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 8), + rangeOffset: 0, + rangeLength: 0, + text: '{}', + }, + { + range: new vscode.Range(0, 0, 0, 8), + rangeOffset: 0, + rangeLength: 0, + text: '', + }, + ], + }) + assert.strictEqual(tracker?.totalTokens[doc.fileName], 2) + }) + }) + + describe('flush', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('Should not send codecoverage telemetry if tracker is not active', function () { + if (!tracker) { + assert.fail() + } + sinon.restore() + sinon.stub(tracker, 'isActive').returns(false) + + tracker.addAcceptedTokens(`test.py`, { range: new vscode.Range(0, 0, 0, 7), text: `print()`, accepted: 7 }) + tracker.addTotalTokens(`test.py`, 100) + tracker.flush() + const data = globals.telemetry.logger.query({ + metricName: 'codewhisperer_codePercentage', + excludeKeys: ['awsAccount'], + }) + assert.strictEqual(data.length, 0) + }) + }) + + describe('emitCodeWhispererCodeContribution', function () { + let tracker: CodeWhispererCodeCoverageTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + CodeWhispererCodeCoverageTracker.instances.clear() + }) + + it('should emit correct code coverage telemetry in python file', async function () { + const tracker = CodeWhispererCodeCoverageTracker.getTracker(language) + + const assertTelemetry = assertTelemetryCurried('codewhisperer_codePercentage') + tracker?.incrementServiceInvocationCount() + tracker?.addAcceptedTokens(`test.py`, { range: new vscode.Range(0, 0, 0, 7), text: `print()`, accepted: 7 }) + tracker?.addTotalTokens(`test.py`, 100) + tracker?.emitCodeWhispererCodeContribution() + assertTelemetry({ + codewhispererTotalTokens: 100, + codewhispererLanguage: language, + codewhispererAcceptedTokens: 7, + codewhispererSuggestedTokens: 7, + codewhispererPercentage: 7, + successCount: 1, + }) + }) + + it('should emit correct code coverage telemetry when success count = 0', async function () { + const tracker = CodeWhispererCodeCoverageTracker.getTracker('java') + + const assertTelemetry = assertTelemetryCurried('codewhisperer_codePercentage') + tracker?.addAcceptedTokens(`test.java`, { + range: new vscode.Range(0, 0, 0, 18), + text: `public static main`, + accepted: 18, + }) + tracker?.incrementServiceInvocationCount() + tracker?.incrementServiceInvocationCount() + tracker?.addTotalTokens(`test.java`, 30) + tracker?.emitCodeWhispererCodeContribution() + assertTelemetry({ + codewhispererTotalTokens: 30, + codewhispererLanguage: 'java', + codewhispererAcceptedTokens: 18, + codewhispererSuggestedTokens: 18, + codewhispererPercentage: 60, + successCount: 2, + }) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/bm25.test.ts b/packages/amazonq/test/unit/codewhisperer/util/bm25.test.ts new file mode 100644 index 00000000000..0a3c4b17d60 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/bm25.test.ts @@ -0,0 +1,117 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { BM25Okapi } from 'aws-core-vscode/codewhisperer' + +describe('bm25', function () { + it('simple case 1', function () { + const query = 'windy London' + const corpus = ['Hello there good man!', 'It is quite windy in London', 'How is the weather today?'] + + const sut = new BM25Okapi(corpus) + const actual = sut.score(query) + + assert.deepStrictEqual(actual, [ + { + content: 'Hello there good man!', + index: 0, + score: 0, + }, + { + content: 'It is quite windy in London', + index: 1, + score: 0.937294722506405, + }, + { + content: 'How is the weather today?', + index: 2, + score: 0, + }, + ]) + + assert.deepStrictEqual(sut.topN(query, 1), [ + { + content: 'It is quite windy in London', + index: 1, + score: 0.937294722506405, + }, + ]) + }) + + it('simple case 2', function () { + const query = 'codewhisperer is a machine learning powered code generator' + const corpus = [ + 'codewhisperer goes GA at April 2023', + 'machine learning tool is the trending topic!!! :)', + 'codewhisperer is good =))))', + 'codewhisperer vs. copilot, which code generator better?', + 'copilot is a AI code generator too', + 'it is so amazing!!', + ] + + const sut = new BM25Okapi(corpus) + const actual = sut.score(query) + + assert.deepStrictEqual(actual, [ + { + content: 'codewhisperer goes GA at April 2023', + index: 0, + score: 0, + }, + { + content: 'machine learning tool is the trending topic!!! :)', + index: 1, + score: 2.597224531416621, + }, + { + content: 'codewhisperer is good =))))', + index: 2, + score: 0.3471790843435529, + }, + { + content: 'codewhisperer vs. copilot, which code generator better?', + index: 3, + score: 1.063018436525109, + }, + { + content: 'copilot is a AI code generator too', + index: 4, + score: 2.485359418462239, + }, + { + content: 'it is so amazing!!', + index: 5, + score: 0.3154033715392277, + }, + ]) + + assert.deepStrictEqual(sut.topN(query, 1), [ + { + content: 'machine learning tool is the trending topic!!! :)', + index: 1, + score: 2.597224531416621, + }, + ]) + + assert.deepStrictEqual(sut.topN(query, 3), [ + { + content: 'machine learning tool is the trending topic!!! :)', + index: 1, + score: 2.597224531416621, + }, + { + content: 'copilot is a AI code generator too', + index: 4, + score: 2.485359418462239, + }, + { + content: 'codewhisperer vs. copilot, which code generator better?', + index: 3, + score: 1.063018436525109, + }, + ]) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/codeParsingUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/codeParsingUtil.test.ts new file mode 100644 index 00000000000..2a2ad8bb34e --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/codeParsingUtil.test.ts @@ -0,0 +1,327 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + PlatformLanguageId, + extractClasses, + extractFunctions, + isTestFile, + utgLanguageConfigs, +} from 'aws-core-vscode/codewhisperer' +import assert from 'assert' +import { createTestWorkspaceFolder, toTextDocument } from 'aws-core-vscode/test' + +describe('RegexValidationForPython', () => { + it('should extract all function names from a python file content', () => { + // TODO: Replace this variable based testing to read content from File. + // const filePath = vscode.Uri.file('./testData/samplePython.py').fsPath; + // const fileContent = fs.readFileSync('./testData/samplePython.py' , 'utf-8'); + // const regex = /function\s+(\w+)/g; + + const result = extractFunctions(pythonFileContent, utgLanguageConfigs['python'].functionExtractionPattern) + assert.strictEqual(result.length, 13) + assert.deepStrictEqual(result, [ + 'hello_world', + 'add_numbers', + 'multiply_numbers', + 'sum_numbers', + 'divide_numbers', + '__init__', + 'add', + 'multiply', + 'square', + 'from_sum', + '__init__', + 'triple', + 'main', + ]) + }) + + it('should extract all class names from a file content', () => { + const result = extractClasses(pythonFileContent, utgLanguageConfigs['python'].classExtractionPattern) + assert.deepStrictEqual(result, ['Calculator']) + }) +}) + +describe('RegexValidationForJava', () => { + it('should extract all function names from a java file content', () => { + // TODO: Replace this variable based testing to read content from File. + // const filePath = vscode.Uri.file('./testData/samplePython.py').fsPath; + // const fileContent = fs.readFileSync('./testData/samplePython.py' , 'utf-8'); + // const regex = /function\s+(\w+)/g; + + const result = extractFunctions(javaFileContent, utgLanguageConfigs['java'].functionExtractionPattern) + assert.strictEqual(result.length, 5) + assert.deepStrictEqual(result, ['sayHello', 'doSomething', 'square', 'manager', 'ABCFUNCTION']) + }) + + it('should extract all class names from a java file content', () => { + const result = extractClasses(javaFileContent, utgLanguageConfigs['java'].classExtractionPattern) + assert.deepStrictEqual(result, ['Test']) + }) +}) + +describe('isTestFile', () => { + let testWsFolder: string + beforeEach(async function () { + testWsFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + it('validate by file path', async function () { + const langs = new Map([ + ['java', '.java'], + ['python', '.py'], + ['typescript', '.ts'], + ['javascript', '.js'], + ['typescriptreact', '.tsx'], + ['javascriptreact', '.jsx'], + ]) + const testFilePathsWithoutExt = [ + '/test/MyClass', + '/test/my_class', + '/tst/MyClass', + '/tst/my_class', + '/tests/MyClass', + '/tests/my_class', + ] + + const srcFilePathsWithoutExt = [ + '/src/MyClass', + 'MyClass', + 'foo/bar/MyClass', + 'foo/my_class', + 'my_class', + 'anyFolderOtherThanTest/foo/myClass', + ] + + for (const [languageId, ext] of langs) { + const testFilePaths = testFilePathsWithoutExt.map((it) => it + ext) + for (const testFilePath of testFilePaths) { + const actual = await isTestFile(testFilePath, { languageId: languageId }) + assert.strictEqual(actual, true) + } + + const srcFilePaths = srcFilePathsWithoutExt.map((it) => it + ext) + for (const srcFilePath of srcFilePaths) { + const actual = await isTestFile(srcFilePath, { languageId: languageId }) + assert.strictEqual(actual, false) + } + } + }) + + async function assertIsTestFile( + fileNames: string[], + config: { languageId: PlatformLanguageId }, + expected: boolean + ) { + for (const fileName of fileNames) { + const document = await toTextDocument('', fileName, testWsFolder) + const actual = await isTestFile(document.uri.fsPath, { languageId: config.languageId }) + assert.strictEqual(actual, expected) + } + } + + it('validate by file name', async function () { + const camelCaseSrc = ['Foo.java', 'Bar.java', 'Baz.java'] + await assertIsTestFile(camelCaseSrc, { languageId: 'java' }, false) + + const camelCaseTst = ['FooTest.java', 'BarTests.java'] + await assertIsTestFile(camelCaseTst, { languageId: 'java' }, true) + + const snakeCaseSrc = ['foo.py', 'bar.py'] + await assertIsTestFile(snakeCaseSrc, { languageId: 'python' }, false) + + const snakeCaseTst = ['test_foo.py', 'bar_test.py'] + await assertIsTestFile(snakeCaseTst, { languageId: 'python' }, true) + + const javascriptSrc = ['Foo.js', 'bar.js'] + await assertIsTestFile(javascriptSrc, { languageId: 'javascript' }, false) + + const javascriptTst = ['Foo.test.js', 'Bar.spec.js'] + await assertIsTestFile(javascriptTst, { languageId: 'javascript' }, true) + + const typescriptSrc = ['Foo.ts', 'bar.ts'] + await assertIsTestFile(typescriptSrc, { languageId: 'typescript' }, false) + + const typescriptTst = ['Foo.test.ts', 'Bar.spec.ts'] + await assertIsTestFile(typescriptTst, { languageId: 'typescript' }, true) + + const jsxSrc = ['Foo.jsx', 'Bar.jsx'] + await assertIsTestFile(jsxSrc, { languageId: 'javascriptreact' }, false) + + const jsxTst = ['Foo.test.jsx', 'Bar.spec.jsx'] + await assertIsTestFile(jsxTst, { languageId: 'javascriptreact' }, true) + }) + + it('should return true if the file name matches the test filename pattern - Java', async () => { + const filePaths = ['/path/to/MyClassTest.java', '/path/to/TestMyClass.java', '/path/to/MyClassTests.java'] + const language = 'java' + + for (const filePath of filePaths) { + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, true) + } + }) + + it('should return false if the file name does not match the test filename pattern - Java', async () => { + const filePaths = ['/path/to/MyClass.java', '/path/to/MyClass_test.java', '/path/to/test_MyClass.java'] + const language = 'java' + + for (const filePath of filePaths) { + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, false) + } + }) + + it('should return true if the file name does not match the test filename pattern - Python', async () => { + const filePaths = ['/path/to/util_test.py', '/path/to/test_util.py'] + const language = 'python' + + for (const filePath of filePaths) { + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, true) + } + }) + + it('should return false if the file name does not match the test filename pattern - Python', async () => { + const filePaths = ['/path/to/util.py', '/path/to/utilTest.java', '/path/to/Testutil.java'] + const language = 'python' + + for (const filePath of filePaths) { + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, false) + } + }) + + it('should return false if the language is not supported', async () => { + const filePath = '/path/to/MyClass.cpp' + const language = 'c++' + const result = await isTestFile(filePath, { languageId: language }) + assert.strictEqual(result, false) + }) +}) + +const pythonFileContent = ` +# Single-line import statements +import os +import numpy as np +from typing import List, Tuple + +# Multi-line import statements +from collections import ( + defaultdict, + Counter +) + +# Relative imports +from . import module1 +from ..subpackage import module2 + +# Wildcard imports +from mypackage import * +from mypackage.module import * + +# Aliased imports +import pandas as pd +from mypackage import module1 as m1, module2 as m2 + +def hello_world(): + print("Hello, world!") + +def add_numbers(x, y): + return x + y + +def multiply_numbers(x=1, y=1): + return x * y + +def sum_numbers(*args): + total = 0 + for num in args: + total += num + return total + +def divide_numbers(x, y=1, *args, **kwargs): + result = x / y + for arg in args: + result /= arg + for _, value in kwargs.items(): + result /= value + return result + +class Calculator: + def __init__(self, x, y): + self.x = x + self.y = y + + def add(self): + return self.x + self.y + + def multiply(self): + return self.x * self.y + + @staticmethod + def square(x): + return x ** 2 + + @classmethod + def from_sum(cls, x, y): + return cls(x+y, 0) + + class InnerClass: + def __init__(self, z): + self.z = z + + def triple(self): + return self.z * 3 + +def main(): + print(hello_world()) + print(add_numbers(3, 5)) + print(multiply_numbers(3, 5)) + print(sum_numbers(1, 2, 3, 4, 5)) + print(divide_numbers(10, 2, 5, 2, a=2, b=3)) + + calc = Calculator(3, 5) + print(calc.add()) + print(calc.multiply()) + print(Calculator.square(3)) + print(Calculator.from_sum(2, 3).add()) + + inner = Calculator.InnerClass(5) + print(inner.triple()) + +if __name__ == "__main__": + main() +` + +const javaFileContent = ` +@Annotation +public class Test { + Test() { + // Do something here + } + + //Additional commenting + public static void sayHello() { + System.out.println("Hello, World!"); + } + + private void doSomething(int x, int y) throws Exception { + int z = x + y; + System.out.println("The sum of " + x + " and " + y + " is " + z); + } + + protected static int square(int x) { + return x * x; + } + + private static void manager(int a, int b) { + return a+b; + } + + public int ABCFUNCTION( int ABC, int PQR) { + return ABC + PQR; + } +}` diff --git a/packages/amazonq/test/unit/codewhisperer/util/commonUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/commonUtil.test.ts new file mode 100644 index 00000000000..5694b33365d --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/commonUtil.test.ts @@ -0,0 +1,81 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { + JsonConfigFileNamingConvention, + checkLeftContextKeywordsForJson, + getPrefixSuffixOverlap, +} from 'aws-core-vscode/codewhisperer' + +describe('commonUtil', function () { + describe('getPrefixSuffixOverlap', function () { + it('Should return correct overlap', async function () { + assert.strictEqual(getPrefixSuffixOverlap('32rasdgvdsg', 'sg462ydfgbs'), `sg`) + assert.strictEqual(getPrefixSuffixOverlap('32rasdgbreh', 'brehsega'), `breh`) + assert.strictEqual(getPrefixSuffixOverlap('42y24hsd', '42y24hsdzqq23'), `42y24hsd`) + assert.strictEqual(getPrefixSuffixOverlap('ge23yt1', 'ge23yt1'), `ge23yt1`) + assert.strictEqual(getPrefixSuffixOverlap('1sgdbsfbwsergsa', 'a1sgdbsfbwsergs'), `a`) + assert.strictEqual(getPrefixSuffixOverlap('xxa', 'xa'), `xa`) + }) + + it('Should return empty overlap for prefix suffix not matching cases', async function () { + assert.strictEqual(getPrefixSuffixOverlap('1sgdbsfbwsergsa', '1sgdbsfbwsergs'), ``) + assert.strictEqual(getPrefixSuffixOverlap('1sgdbsfbwsergsab', '1sgdbsfbwsergs'), ``) + assert.strictEqual(getPrefixSuffixOverlap('2135t12', 'v2135t12'), ``) + assert.strictEqual(getPrefixSuffixOverlap('2135t12', 'zv2135t12'), ``) + assert.strictEqual(getPrefixSuffixOverlap('xa', 'xxa'), ``) + }) + + it('Should return empty overlap for empty string input', async function () { + assert.strictEqual(getPrefixSuffixOverlap('ergwsghws', ''), ``) + assert.strictEqual(getPrefixSuffixOverlap('', 'asfegw4eh'), ``) + }) + }) + + describe('checkLeftContextKeywordsForJson', function () { + it('Should return true for valid left context keywords', async function () { + assert.strictEqual( + checkLeftContextKeywordsForJson('foo.json', 'Create an S3 Bucket named CodeWhisperer', 'json'), + true + ) + }) + it('Should return false for invalid left context keywords', async function () { + assert.strictEqual( + checkLeftContextKeywordsForJson( + 'foo.json', + 'Create an S3 Bucket named CodeWhisperer in Cloudformation', + 'json' + ), + false + ) + }) + + for (const jsonConfigFile of JsonConfigFileNamingConvention) { + it(`should evalute by filename ${jsonConfigFile}`, function () { + assert.strictEqual(checkLeftContextKeywordsForJson(jsonConfigFile, 'foo', 'json'), false) + + assert.strictEqual(checkLeftContextKeywordsForJson(jsonConfigFile.toUpperCase(), 'bar', 'json'), false) + + assert.strictEqual(checkLeftContextKeywordsForJson(jsonConfigFile.toUpperCase(), 'baz', 'json'), false) + }) + + const upperCaseFilename = jsonConfigFile.toUpperCase() + it(`should evalute by filename and case insensitive ${upperCaseFilename}`, function () { + assert.strictEqual(checkLeftContextKeywordsForJson(upperCaseFilename, 'foo', 'json'), false) + + assert.strictEqual( + checkLeftContextKeywordsForJson(upperCaseFilename.toUpperCase(), 'bar', 'json'), + false + ) + + assert.strictEqual( + checkLeftContextKeywordsForJson(upperCaseFilename.toUpperCase(), 'baz', 'json'), + false + ) + }) + } + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts new file mode 100644 index 00000000000..4c2ca1190ca --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/crossFileContextUtil.test.ts @@ -0,0 +1,417 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as FakeTimers from '@sinonjs/fake-timers' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import * as crossFile from 'aws-core-vscode/codewhisperer' +import { + aLongStringWithLineCount, + aStringWithLineCount, + createMockTextEditor, + installFakeClock, +} from 'aws-core-vscode/test' +import { FeatureConfigProvider, crossFileContextConfig } from 'aws-core-vscode/codewhisperer' +import { + assertTabCount, + closeAllEditors, + createTestWorkspaceFolder, + toTextEditor, + shuffleList, + toFile, +} from 'aws-core-vscode/test' +import { areEqual, normalize } from 'aws-core-vscode/shared' +import * as path from 'path' + +let tempFolder: string + +describe('crossFileContextUtil', function () { + const fakeCancellationToken: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: sinon.spy(), + } + + let mockEditor: vscode.TextEditor + let clock: FakeTimers.InstalledClock + + before(function () { + clock = installFakeClock() + }) + + after(function () { + clock.uninstall() + }) + + afterEach(function () { + sinon.restore() + }) + + describe('fetchSupplementalContextForSrc', function () { + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + sinon.restore() + }) + + it.skip('for control group, should return opentabs context where there will be 3 chunks and each chunk should contains 50 lines', async function () { + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('control') + await toTextEditor(aStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) + const myCurrentEditor = await toTextEditor('', 'TargetFile.java', tempFolder, { + preview: false, + }) + + await assertTabCount(2) + + const actual = await crossFile.fetchSupplementalContextForSrc(myCurrentEditor, fakeCancellationToken) + assert.ok(actual) + assert.strictEqual(actual.supplementalContextItems.length, 3) + assert.strictEqual(actual.supplementalContextItems[0].content.split('\n').length, 50) + assert.strictEqual(actual.supplementalContextItems[1].content.split('\n').length, 50) + assert.strictEqual(actual.supplementalContextItems[2].content.split('\n').length, 50) + }) + + it('for t1 group, should return repomap + opentabs context, should not exceed 20k total length', async function () { + await toTextEditor(aLongStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) + const myCurrentEditor = await toTextEditor('', 'TargetFile.java', tempFolder, { + preview: false, + }) + + await assertTabCount(2) + + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('t1') + + const mockLanguageClient = { + sendRequest: sinon.stub().resolves([ + { + content: 'foo'.repeat(3000), + score: 0, + filePath: 'q-inline', + }, + ]), + } as any + + const actual = await crossFile.fetchSupplementalContextForSrc( + myCurrentEditor, + fakeCancellationToken, + mockLanguageClient + ) + assert.ok(actual) + assert.strictEqual(actual.supplementalContextItems.length, 3) + assert.strictEqual(actual?.strategy, 'codemap') + assert.deepEqual(actual?.supplementalContextItems[0], { + content: 'foo'.repeat(3000), + score: 0, + filePath: 'q-inline', + }) + assert.strictEqual(actual.supplementalContextItems[1].content.split('\n').length, 50) + assert.strictEqual(actual.supplementalContextItems[2].content.split('\n').length, 50) + }) + + it.skip('for t2 group, should return global bm25 context and no repomap', async function () { + await toTextEditor(aStringWithLineCount(200), 'CrossFile.java', tempFolder, { preview: false }) + const myCurrentEditor = await toTextEditor('', 'TargetFile.java', tempFolder, { + preview: false, + }) + + await assertTabCount(2) + + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('t2') + + const actual = await crossFile.fetchSupplementalContextForSrc(myCurrentEditor, fakeCancellationToken) + assert.ok(actual) + assert.strictEqual(actual.supplementalContextItems.length, 5) + assert.strictEqual(actual?.strategy, 'bm25') + + assert.deepEqual(actual?.supplementalContextItems[0], { + content: 'foo', + score: 5, + filePath: 'foo.java', + }) + + assert.deepEqual(actual?.supplementalContextItems[1], { + content: 'bar', + score: 4, + filePath: 'bar.java', + }) + assert.deepEqual(actual?.supplementalContextItems[2], { + content: 'baz', + score: 3, + filePath: 'baz.java', + }) + + assert.deepEqual(actual?.supplementalContextItems[3], { + content: 'qux', + score: 2, + filePath: 'qux.java', + }) + + assert.deepEqual(actual?.supplementalContextItems[4], { + content: 'quux', + score: 1, + filePath: 'quux.java', + }) + }) + }) + + describe('non supported language should return undefined', function () { + it('c++', async function () { + mockEditor = createMockTextEditor('content', 'fileName', 'cpp') + const actual = await crossFile.fetchSupplementalContextForSrc(mockEditor, fakeCancellationToken) + assert.strictEqual(actual, undefined) + }) + + it('ruby', async function () { + mockEditor = createMockTextEditor('content', 'fileName', 'ruby') + + const actual = await crossFile.fetchSupplementalContextForSrc(mockEditor, fakeCancellationToken) + + assert.strictEqual(actual, undefined) + }) + }) + + describe('getCrossFileCandidate', function () { + before(async function () { + this.timeout(60000) + }) + + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + await closeAllEditors() + }) + + it('should return opened files, exclude test files and sorted ascendingly by file distance', async function () { + const targetFile = path.join('src', 'service', 'microService', 'CodeWhispererFileContextProvider.java') + const fileWithDistance3 = path.join('src', 'service', 'CodewhispererRecommendationService.java') + const fileWithDistance5 = path.join('src', 'util', 'CodeWhispererConstants.java') + const fileWithDistance6 = path.join('src', 'ui', 'popup', 'CodeWhispererPopupManager.java') + const fileWithDistance7 = path.join('src', 'ui', 'popup', 'components', 'CodeWhispererPopup.java') + const fileWithDistance8 = path.join( + 'src', + 'ui', + 'popup', + 'components', + 'actions', + 'AcceptRecommendationAction.java' + ) + const testFile1 = path.join('test', 'service', 'CodeWhispererFileContextProviderTest.java') + const testFile2 = path.join('test', 'ui', 'CodeWhispererPopupManagerTest.java') + + const expectedFilePaths = [ + fileWithDistance3, + fileWithDistance5, + fileWithDistance6, + fileWithDistance7, + fileWithDistance8, + ] + + const shuffledFilePaths = shuffleList(expectedFilePaths) + + for (const filePath of shuffledFilePaths) { + await toTextEditor('', filePath, tempFolder, { preview: false }) + } + + await toTextEditor('', testFile1, tempFolder, { preview: false }) + await toTextEditor('', testFile2, tempFolder, { preview: false }) + const editor = await toTextEditor('', targetFile, tempFolder, { preview: false }) + + await assertTabCount(shuffledFilePaths.length + 3) + + const actual = await crossFile.getCrossFileCandidates(editor) + + assert.ok(actual.length === 5) + for (const [index, actualFile] of actual.entries()) { + const expectedFile = path.join(tempFolder, expectedFilePaths[index]) + assert.strictEqual(normalize(expectedFile), normalize(actualFile)) + assert.ok(areEqual(tempFolder, actualFile, expectedFile)) + } + }) + }) + + describe.skip('partial support - control group', function () { + const fileExtLists: string[] = [] + + before(async function () { + this.timeout(60000) + }) + + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + await closeAllEditors() + }) + + for (const fileExt of fileExtLists) { + it('should be empty if userGroup is control', async function () { + const editor = await toTextEditor('content-1', `file-1.${fileExt}`, tempFolder) + await toTextEditor('content-2', `file-2.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-3', `file-3.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-4', `file-4.${fileExt}`, tempFolder, { preview: false }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContextForSrc(editor, fakeCancellationToken) + + assert.ok(actual && actual.supplementalContextItems.length === 0) + }) + } + }) + + describe.skip('partial support - crossfile group', function () { + const fileExtLists: string[] = [] + + before(async function () { + this.timeout(60000) + }) + + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + await closeAllEditors() + }) + + for (const fileExt of fileExtLists) { + it('should be non empty if usergroup is Crossfile', async function () { + const editor = await toTextEditor('content-1', `file-1.${fileExt}`, tempFolder) + await toTextEditor('content-2', `file-2.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-3', `file-3.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-4', `file-4.${fileExt}`, tempFolder, { preview: false }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContextForSrc(editor, fakeCancellationToken) + + assert.ok(actual && actual.supplementalContextItems.length !== 0) + }) + } + }) + + describe('full support', function () { + const fileExtLists = ['java', 'js', 'ts', 'py', 'tsx', 'jsx'] + + before(async function () { + this.timeout(60000) + }) + + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + afterEach(async function () { + sinon.restore() + await closeAllEditors() + }) + + for (const fileExt of fileExtLists) { + it(`supplemental context for file ${fileExt} should be non empty`, async function () { + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('control') + const editor = await toTextEditor('content-1', `file-1.${fileExt}`, tempFolder) + await toTextEditor('content-2', `file-2.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-3', `file-3.${fileExt}`, tempFolder, { preview: false }) + await toTextEditor('content-4', `file-4.${fileExt}`, tempFolder, { preview: false }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContextForSrc(editor, fakeCancellationToken) + + assert.ok(actual && actual.supplementalContextItems.length !== 0) + }) + } + }) + + describe('splitFileToChunks', function () { + beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + it('should split file to a chunk of 2 lines', async function () { + const filePath = path.join(tempFolder, 'file.txt') + await toFile('line_1\nline_2\nline_3\nline_4\nline_5\nline_6\nline_7', filePath) + + const chunks = await crossFile.splitFileToChunks(filePath, 2) + + assert.strictEqual(chunks.length, 4) + assert.strictEqual(chunks[0].content, 'line_1\nline_2') + assert.strictEqual(chunks[1].content, 'line_3\nline_4') + assert.strictEqual(chunks[2].content, 'line_5\nline_6') + assert.strictEqual(chunks[3].content, 'line_7') + }) + + it('should split file to a chunk of 5 lines', async function () { + const filePath = path.join(tempFolder, 'file.txt') + await toFile('line_1\nline_2\nline_3\nline_4\nline_5\nline_6\nline_7', filePath) + + const chunks = await crossFile.splitFileToChunks(filePath, 5) + + assert.strictEqual(chunks.length, 2) + assert.strictEqual(chunks[0].content, 'line_1\nline_2\nline_3\nline_4\nline_5') + assert.strictEqual(chunks[1].content, 'line_6\nline_7') + }) + + it('codewhisperer crossfile config should use 50 lines', async function () { + const filePath = path.join(tempFolder, 'file.txt') + await toFile(aStringWithLineCount(210), filePath) + + const chunks = await crossFile.splitFileToChunks(filePath, crossFileContextConfig.numberOfLinesEachChunk) + + // (210 / 50) + 1 + assert.strictEqual(chunks.length, 5) + // line0 -> line49 + assert.strictEqual(chunks[0].content, aStringWithLineCount(50, 0)) + // line50 -> line99 + assert.strictEqual(chunks[1].content, aStringWithLineCount(50, 50)) + // line100 -> line149 + assert.strictEqual(chunks[2].content, aStringWithLineCount(50, 100)) + // line150 -> line199 + assert.strictEqual(chunks[3].content, aStringWithLineCount(50, 150)) + // line 200 -> line209 + assert.strictEqual(chunks[4].content, aStringWithLineCount(10, 200)) + }) + + it('linkChunks should add another chunk which will link to the first chunk and chunk.nextContent should reflect correct value', async function () { + const filePath = path.join(tempFolder, 'file.txt') + await toFile(aStringWithLineCount(210), filePath) + + const chunks = await crossFile.splitFileToChunks(filePath, crossFileContextConfig.numberOfLinesEachChunk) + const linkedChunks = crossFile.linkChunks(chunks) + + // 210 / 50 + 2 + assert.strictEqual(linkedChunks.length, 6) + + // 0th + assert.strictEqual(linkedChunks[0].content, aStringWithLineCount(3, 0)) + assert.strictEqual(linkedChunks[0].nextContent, aStringWithLineCount(50, 0)) + + // 1st + assert.strictEqual(linkedChunks[1].content, aStringWithLineCount(50, 0)) + assert.strictEqual(linkedChunks[1].nextContent, aStringWithLineCount(50, 50)) + + // 2nd + assert.strictEqual(linkedChunks[2].content, aStringWithLineCount(50, 50)) + assert.strictEqual(linkedChunks[2].nextContent, aStringWithLineCount(50, 100)) + + // 3rd + assert.strictEqual(linkedChunks[3].content, aStringWithLineCount(50, 100)) + assert.strictEqual(linkedChunks[3].nextContent, aStringWithLineCount(50, 150)) + + // 4th + assert.strictEqual(linkedChunks[4].content, aStringWithLineCount(50, 150)) + assert.strictEqual(linkedChunks[4].nextContent, aStringWithLineCount(10, 200)) + + // 5th + assert.strictEqual(linkedChunks[5].content, aStringWithLineCount(10, 200)) + assert.strictEqual(linkedChunks[5].nextContent, aStringWithLineCount(10, 200)) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts b/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts new file mode 100644 index 00000000000..3875dbbd0f2 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/editorContext.test.ts @@ -0,0 +1,392 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert' +import * as codewhispererClient from 'aws-core-vscode/codewhisperer' +import * as EditorContext from 'aws-core-vscode/codewhisperer' +import { + createMockDocument, + createMockTextEditor, + createMockClientRequest, + resetCodeWhispererGlobalVariables, + toTextEditor, + createTestWorkspaceFolder, + closeAllEditors, +} from 'aws-core-vscode/test' +import { globals } from 'aws-core-vscode/shared' +import { GenerateCompletionsRequest } from 'aws-core-vscode/codewhisperer' +import * as vscode from 'vscode' + +export function createNotebookCell( + document: vscode.TextDocument = createMockDocument('def example():\n return "test"'), + kind: vscode.NotebookCellKind = vscode.NotebookCellKind.Code, + notebook: vscode.NotebookDocument = {} as any, + index: number = 0, + outputs: vscode.NotebookCellOutput[] = [], + metadata: { readonly [key: string]: any } = {}, + executionSummary?: vscode.NotebookCellExecutionSummary +): vscode.NotebookCell { + return { + document, + kind, + notebook, + index, + outputs, + metadata, + executionSummary, + } +} + +describe('editorContext', function () { + let telemetryEnabledDefault: boolean + let tempFolder: string + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + telemetryEnabledDefault = globals.telemetry.telemetryEnabled + }) + + afterEach(async function () { + await globals.telemetry.setTelemetryEnabled(telemetryEnabledDefault) + }) + + describe('extractContextForCodeWhisperer', function () { + it('Should return expected context', function () { + const editor = createMockTextEditor('import math\ndef two_sum(nums, target):\n', 'test.py', 'python', 1, 17) + const actual = EditorContext.extractContextForCodeWhisperer(editor) + const expected: codewhispererClient.FileContext = { + fileUri: 'file:///test.py', + filename: 'test.py', + programmingLanguage: { + languageName: 'python', + }, + leftFileContent: 'import math\ndef two_sum(nums,', + rightFileContent: ' target):\n', + } + assert.deepStrictEqual(actual, expected) + }) + + it('Should return expected context within max char limit', function () { + const editor = createMockTextEditor( + 'import math\ndef ' + 'a'.repeat(10340) + 'two_sum(nums, target):\n', + 'test.py', + 'python', + 1, + 17 + ) + const actual = EditorContext.extractContextForCodeWhisperer(editor) + const expected: codewhispererClient.FileContext = { + fileUri: 'file:///test.py', + filename: 'test.py', + programmingLanguage: { + languageName: 'python', + }, + leftFileContent: 'import math\ndef aaaaaaaaaaaaa', + rightFileContent: 'a'.repeat(10240), + } + assert.deepStrictEqual(actual, expected) + }) + + it('in a notebook, includes context from other cells', async function () { + const cells: vscode.NotebookCellData[] = [ + new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, 'Previous cell', 'python'), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'import numpy as np\nimport pandas as pd\n\ndef analyze_data(df):\n # Current cell with cursor here', + 'python' + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + '# Process the data\nresult = analyze_data(df)\nprint(result)', + 'python' + ), + ] + + const document = await vscode.workspace.openNotebookDocument( + 'jupyter-notebook', + new vscode.NotebookData(cells) + ) + const editor: any = { + document: document.cellAt(1).document, + selection: { active: new vscode.Position(4, 13) }, + } + + const actual = EditorContext.extractContextForCodeWhisperer(editor) + const expected: codewhispererClient.FileContext = { + fileUri: editor.document.uri.toString(), + filename: 'Untitled-1.py', + programmingLanguage: { + languageName: 'python', + }, + leftFileContent: + '# Previous cell\nimport numpy as np\nimport pandas as pd\n\ndef analyze_data(df):\n # Current', + rightFileContent: + ' cell with cursor here\n# Process the data\nresult = analyze_data(df)\nprint(result)\n', + } + assert.deepStrictEqual(actual, expected) + }) + }) + + describe('getFileName', function () { + it('Should return expected filename given a document reading test.py', function () { + const editor = createMockTextEditor('', 'test.py', 'python', 1, 17) + const actual = EditorContext.getFileName(editor) + const expected = 'test.py' + assert.strictEqual(actual, expected) + }) + + it('Should return expected filename for a long filename', async function () { + const editor = createMockTextEditor('', 'a'.repeat(1500), 'python', 1, 17) + const actual = EditorContext.getFileName(editor) + const expected = 'a'.repeat(1024) + assert.strictEqual(actual, expected) + }) + }) + + describe('getFileRelativePath', function () { + this.beforeEach(async function () { + tempFolder = (await createTestWorkspaceFolder()).uri.fsPath + }) + + it('Should return a new filename with correct extension given a .ipynb file', function () { + const languageToExtension = new Map([ + ['python', 'py'], + ['rust', 'rs'], + ['javascript', 'js'], + ['typescript', 'ts'], + ['c', 'c'], + ]) + + for (const [language, extension] of languageToExtension.entries()) { + const editor = createMockTextEditor('', 'test.ipynb', language, 1, 17) + const actual = EditorContext.getFileRelativePath(editor) + const expected = 'test.' + extension + assert.strictEqual(actual, expected) + } + }) + + it('Should return relative path', async function () { + const editor = await toTextEditor('tttt', 'test.py', tempFolder) + const actual = EditorContext.getFileRelativePath(editor) + const expected = 'test.py' + assert.strictEqual(actual, expected) + }) + + afterEach(async function () { + await closeAllEditors() + }) + }) + + describe('getNotebookCellContext', function () { + it('Should return cell text for python code cells when language is python', function () { + const mockCodeCell = createNotebookCell(createMockDocument('def example():\n return "test"')) + const result = EditorContext.getNotebookCellContext(mockCodeCell, 'python') + assert.strictEqual(result, 'def example():\n return "test"') + }) + + it('Should return java comments for python code cells when language is java', function () { + const mockCodeCell = createNotebookCell(createMockDocument('def example():\n return "test"')) + const result = EditorContext.getNotebookCellContext(mockCodeCell, 'java') + assert.strictEqual(result, '// def example():\n// return "test"') + }) + + it('Should return python comments for java code cells when language is python', function () { + const mockCodeCell = createNotebookCell(createMockDocument('println(1 + 1);', 'somefile.ipynb', 'java')) + const result = EditorContext.getNotebookCellContext(mockCodeCell, 'python') + assert.strictEqual(result, '# println(1 + 1);') + }) + + it('Should add python comment prefixes for markdown cells when language is python', function () { + const mockMarkdownCell = createNotebookCell( + createMockDocument('# Heading\nThis is a markdown cell'), + vscode.NotebookCellKind.Markup + ) + const result = EditorContext.getNotebookCellContext(mockMarkdownCell, 'python') + assert.strictEqual(result, '# # Heading\n# This is a markdown cell') + }) + + it('Should add java comment prefixes for markdown cells when language is java', function () { + const mockMarkdownCell = createNotebookCell( + createMockDocument('# Heading\nThis is a markdown cell'), + vscode.NotebookCellKind.Markup + ) + const result = EditorContext.getNotebookCellContext(mockMarkdownCell, 'java') + assert.strictEqual(result, '// # Heading\n// This is a markdown cell') + }) + }) + + describe('getNotebookCellsSliceContext', function () { + it('Should extract content from cells in reverse order up to maxLength from prefix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('First cell content')), + createNotebookCell(createMockDocument('Second cell content')), + createNotebookCell(createMockDocument('Third cell content')), + ] + + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', false) + assert.strictEqual(result, 'First cell content\nSecond cell content\nThird cell content\n') + }) + + it('Should extract content from cells in reverse order up to maxLength from suffix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('First cell content')), + createNotebookCell(createMockDocument('Second cell content')), + createNotebookCell(createMockDocument('Third cell content')), + ] + + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', true) + assert.strictEqual(result, 'First cell content\nSecond cell content\nThird cell content\n') + }) + + it('Should respect maxLength parameter from prefix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('First')), + createNotebookCell(createMockDocument('Second')), + createNotebookCell(createMockDocument('Third')), + createNotebookCell(createMockDocument('Fourth')), + ] + // Should only include part of second cell and the last two cells + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 15, 'python', false) + assert.strictEqual(result, 'd\nThird\nFourth\n') + }) + + it('Should respect maxLength parameter from suffix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('First')), + createNotebookCell(createMockDocument('Second')), + createNotebookCell(createMockDocument('Third')), + createNotebookCell(createMockDocument('Fourth')), + ] + + // Should only include first cell and part of second cell + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 15, 'python', true) + assert.strictEqual(result, 'First\nSecond\nTh') + }) + + it('Should handle empty cells array from prefix cells', function () { + const result = EditorContext.getNotebookCellsSliceContext([], 100, 'python', false) + assert.strictEqual(result, '') + }) + + it('Should handle empty cells array from suffix cells', function () { + const result = EditorContext.getNotebookCellsSliceContext([], 100, 'python', true) + assert.strictEqual(result, '') + }) + + it('Should add python comments to markdown prefix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('# Heading\nThis is markdown'), vscode.NotebookCellKind.Markup), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', false) + assert.strictEqual(result, '# # Heading\n# This is markdown\ndef example():\n return "test"\n') + }) + + it('Should add python comments to markdown suffix cells', function () { + const mockCells = [ + createNotebookCell(createMockDocument('# Heading\nThis is markdown'), vscode.NotebookCellKind.Markup), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', true) + assert.strictEqual(result, '# # Heading\n# This is markdown\ndef example():\n return "test"\n') + }) + + it('Should add java comments to markdown and python prefix cells when language is java', function () { + const mockCells = [ + createNotebookCell(createMockDocument('# Heading\nThis is markdown'), vscode.NotebookCellKind.Markup), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'java', false) + assert.strictEqual(result, '// # Heading\n// This is markdown\n// def example():\n// return "test"\n') + }) + + it('Should add java comments to markdown and python suffix cells when language is java', function () { + const mockCells = [ + createNotebookCell(createMockDocument('# Heading\nThis is markdown'), vscode.NotebookCellKind.Markup), + createNotebookCell(createMockDocument('println(1 + 1);', 'somefile.ipynb', 'java')), + ] + + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'java', true) + assert.strictEqual(result, '// # Heading\n// This is markdown\nprintln(1 + 1);\n') + }) + + it('Should handle code prefix cells with different languages', function () { + const mockCells = [ + createNotebookCell( + createMockDocument('println(1 + 1);', 'somefile.ipynb', 'java'), + vscode.NotebookCellKind.Code + ), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', false) + assert.strictEqual(result, '# println(1 + 1);\ndef example():\n return "test"\n') + }) + + it('Should handle code suffix cells with different languages', function () { + const mockCells = [ + createNotebookCell( + createMockDocument('println(1 + 1);', 'somefile.ipynb', 'java'), + vscode.NotebookCellKind.Code + ), + createNotebookCell(createMockDocument('def example():\n return "test"')), + ] + const result = EditorContext.getNotebookCellsSliceContext(mockCells, 100, 'python', true) + assert.strictEqual(result, '# println(1 + 1);\ndef example():\n return "test"\n') + }) + }) + + describe('validateRequest', function () { + it('Should return false if request filename.length is invalid', function () { + const req = createMockClientRequest() + req.fileContext.filename = '' + assert.ok(!EditorContext.validateRequest(req)) + }) + + it('Should return false if request programming language is invalid', function () { + const req = createMockClientRequest() + req.fileContext.programmingLanguage.languageName = '' + assert.ok(!EditorContext.validateRequest(req)) + req.fileContext.programmingLanguage.languageName = 'a'.repeat(200) + assert.ok(!EditorContext.validateRequest(req)) + }) + + it('Should return false if request left or right context exceeds max length', function () { + const req = createMockClientRequest() + req.fileContext.leftFileContent = 'a'.repeat(256000) + assert.ok(!EditorContext.validateRequest(req)) + req.fileContext.leftFileContent = 'a' + req.fileContext.rightFileContent = 'a'.repeat(256000) + assert.ok(!EditorContext.validateRequest(req)) + }) + + it('Should return true if above conditions are not met', function () { + const req = createMockClientRequest() + assert.ok(EditorContext.validateRequest(req)) + }) + }) + + describe('getLeftContext', function () { + it('Should return expected left context', function () { + const editor = createMockTextEditor('import math\ndef two_sum(nums, target):\n', 'test.py', 'python', 1, 17) + const actual = EditorContext.getLeftContext(editor, 1) + const expected = '...wo_sum(nums, target)' + assert.strictEqual(actual, expected) + }) + }) + + describe('buildListRecommendationRequest', function () { + it('Should return expected fields for optOut, nextToken and reference config', async function () { + const nextToken = 'testToken' + const optOutPreference = false + await globals.telemetry.setTelemetryEnabled(false) + const editor = createMockTextEditor('import math\ndef two_sum(nums, target):\n', 'test.py', 'python', 1, 17) + const actual = await EditorContext.buildListRecommendationRequest(editor, nextToken, optOutPreference) + + assert.strictEqual(actual.request.nextToken, nextToken) + assert.strictEqual((actual.request as GenerateCompletionsRequest).optOutPreference, 'OPTOUT') + assert.strictEqual(actual.request.referenceTrackerConfiguration?.recommendationsWithReferences, 'BLOCK') + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/globalStateUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/globalStateUtil.test.ts new file mode 100644 index 00000000000..24062a81b7c --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/globalStateUtil.test.ts @@ -0,0 +1,42 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { resetCodeWhispererGlobalVariables } from 'aws-core-vscode/test' +import { getLogger } from 'aws-core-vscode/shared' +import { resetIntelliSenseState, vsCodeState } from 'aws-core-vscode/codewhisperer' + +describe('globalStateUtil', function () { + let loggerSpy: sinon.SinonSpy + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + vsCodeState.isIntelliSenseActive = true + loggerSpy = sinon.spy(getLogger(), 'info') + }) + + this.afterEach(function () { + sinon.restore() + }) + + it('Should skip when CodeWhisperer is turned off', async function () { + const isManualTriggerEnabled = false + const isAutomatedTriggerEnabled = false + resetIntelliSenseState(isManualTriggerEnabled, isAutomatedTriggerEnabled, true) + assert.ok(!loggerSpy.called) + }) + + it('Should skip when invocationContext is not active', async function () { + vsCodeState.isIntelliSenseActive = false + resetIntelliSenseState(false, false, true) + assert.ok(!loggerSpy.called) + }) + + it('Should skip when no valid recommendations', async function () { + resetIntelliSenseState(true, true, false) + assert.ok(!loggerSpy.called) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts new file mode 100644 index 00000000000..cf2fd151262 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/supplemetalContextUtil.test.ts @@ -0,0 +1,254 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as FakeTimers from '@sinonjs/fake-timers' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import * as os from 'os' +import * as crossFile from 'aws-core-vscode/codewhisperer' +import { TestFolder, assertTabCount, installFakeClock } from 'aws-core-vscode/test' +import { CodeWhispererSupplementalContext, FeatureConfigProvider } from 'aws-core-vscode/codewhisperer' +import { toTextEditor } from 'aws-core-vscode/test' + +const newLine = os.EOL + +describe('supplementalContextUtil', function () { + let testFolder: TestFolder + let clock: FakeTimers.InstalledClock + + const fakeCancellationToken: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: sinon.spy(), + } + + before(function () { + clock = installFakeClock() + }) + + after(function () { + clock.uninstall() + }) + + beforeEach(async function () { + testFolder = await TestFolder.create() + sinon.stub(FeatureConfigProvider.instance, 'getProjectContextGroup').returns('control') + }) + + afterEach(function () { + sinon.restore() + }) + + describe('fetchSupplementalContext', function () { + describe('openTabsContext', function () { + it('opentabContext should include chunks if non empty', async function () { + await toTextEditor('class Foo', 'Foo.java', testFolder.path, { preview: false }) + await toTextEditor('class Bar', 'Bar.java', testFolder.path, { preview: false }) + await toTextEditor('class Baz', 'Baz.java', testFolder.path, { preview: false }) + + const editor = await toTextEditor('public class Foo {}', 'Query.java', testFolder.path, { + preview: false, + }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContext(editor, fakeCancellationToken) + assert.ok(actual?.supplementalContextItems.length === 3) + }) + + it('opentabsContext should filter out empty chunks', async function () { + // open 3 files as supplemental context candidate files but none of them have contents + await toTextEditor('', 'Foo.java', testFolder.path, { preview: false }) + await toTextEditor('', 'Bar.java', testFolder.path, { preview: false }) + await toTextEditor('', 'Baz.java', testFolder.path, { preview: false }) + + const editor = await toTextEditor('public class Foo {}', 'Query.java', testFolder.path, { + preview: false, + }) + + await assertTabCount(4) + + const actual = await crossFile.fetchSupplementalContext(editor, fakeCancellationToken) + assert.ok(actual?.supplementalContextItems.length === 0) + }) + }) + }) + + describe('truncation', function () { + it('truncate context should do nothing if everything fits in constraint', function () { + const chunkA: crossFile.CodeWhispererSupplementalContextItem = { + content: 'a', + filePath: 'a.java', + score: 0, + } + const chunkB: crossFile.CodeWhispererSupplementalContextItem = { + content: 'b', + filePath: 'b.java', + score: 1, + } + const chunks = [chunkA, chunkB] + + const supplementalContext: CodeWhispererSupplementalContext = { + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: chunks, + contentsLength: 25000, + latency: 0, + strategy: 'codemap', + } + + const actual = crossFile.truncateSuppelementalContext(supplementalContext) + assert.strictEqual(actual.supplementalContextItems.length, 2) + assert.strictEqual(actual.supplementalContextItems[0].content, 'a') + assert.strictEqual(actual.supplementalContextItems[1].content, 'b') + }) + + it('truncateLineByLine should drop the last line if max length is greater than threshold', function () { + const input = + repeatString('a', 11) + + newLine + + repeatString('b', 11) + + newLine + + repeatString('c', 11) + + newLine + + repeatString('d', 11) + + newLine + + repeatString('e', 11) + + assert.ok(input.length > 50) + const actual = crossFile.truncateLineByLine(input, 50) + assert.ok(actual.length <= 50) + + const input2 = repeatString(`b${newLine}`, 10) + const actual2 = crossFile.truncateLineByLine(input2, 8) + assert.ok(actual2.length <= 8) + }) + + it('truncation context should make context length per item lte 10240 cap', function () { + const chunkA: crossFile.CodeWhispererSupplementalContextItem = { + content: repeatString(`a${newLine}`, 4000), + filePath: 'a.java', + score: 0, + } + const chunkB: crossFile.CodeWhispererSupplementalContextItem = { + content: repeatString(`b${newLine}`, 6000), + filePath: 'b.java', + score: 1, + } + const chunkC: crossFile.CodeWhispererSupplementalContextItem = { + content: repeatString(`c${newLine}`, 1000), + filePath: 'c.java', + score: 2, + } + const chunkD: crossFile.CodeWhispererSupplementalContextItem = { + content: repeatString(`d${newLine}`, 1500), + filePath: 'd.java', + score: 3, + } + + assert.ok( + chunkA.content.length + chunkB.content.length + chunkC.content.length + chunkD.content.length > 20480 + ) + + const supplementalContext: CodeWhispererSupplementalContext = { + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: [chunkA, chunkB, chunkC, chunkD], + contentsLength: 25000, + latency: 0, + strategy: 'codemap', + } + + const actual = crossFile.truncateSuppelementalContext(supplementalContext) + assert.strictEqual(actual.supplementalContextItems.length, 3) + assert.ok(actual.contentsLength <= 20480) + assert.strictEqual(actual.strategy, 'codemap') + }) + + it('truncate context should make context items lte 5', function () { + const chunkA: crossFile.CodeWhispererSupplementalContextItem = { + content: 'a', + filePath: 'a.java', + score: 0, + } + const chunkB: crossFile.CodeWhispererSupplementalContextItem = { + content: 'b', + filePath: 'b.java', + score: 1, + } + const chunkC: crossFile.CodeWhispererSupplementalContextItem = { + content: 'c', + filePath: 'c.java', + score: 2, + } + const chunkD: crossFile.CodeWhispererSupplementalContextItem = { + content: 'd', + filePath: 'd.java', + score: 3, + } + const chunkE: crossFile.CodeWhispererSupplementalContextItem = { + content: 'e', + filePath: 'e.java', + score: 4, + } + const chunkF: crossFile.CodeWhispererSupplementalContextItem = { + content: 'f', + filePath: 'f.java', + score: 5, + } + const chunkG: crossFile.CodeWhispererSupplementalContextItem = { + content: 'g', + filePath: 'g.java', + score: 6, + } + const chunks = [chunkA, chunkB, chunkC, chunkD, chunkE, chunkF, chunkG] + + assert.strictEqual(chunks.length, 7) + + const supplementalContext: CodeWhispererSupplementalContext = { + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: chunks, + contentsLength: 25000, + latency: 0, + strategy: 'codemap', + } + + const actual = crossFile.truncateSuppelementalContext(supplementalContext) + assert.strictEqual(actual.supplementalContextItems.length, 5) + }) + + describe('truncate line by line', function () { + it('should return empty if empty string is provided', function () { + const input = '' + const actual = crossFile.truncateLineByLine(input, 50) + assert.strictEqual(actual, '') + }) + + it('should return empty if 0 max length is provided', function () { + const input = 'aaaaa' + const actual = crossFile.truncateLineByLine(input, 0) + assert.strictEqual(actual, '') + }) + + it('should flip the value if negative max length is provided', function () { + const input = `aaaaa${newLine}bbbbb` + const actual = crossFile.truncateLineByLine(input, -6) + const expected = crossFile.truncateLineByLine(input, 6) + assert.strictEqual(actual, expected) + assert.strictEqual(actual, 'aaaaa') + }) + }) + }) +}) + +function repeatString(s: string, n: number): string { + let output = '' + for (let i = 0; i < n; i++) { + output += s + } + + return output +} diff --git a/packages/amazonq/test/unit/codewhisperer/util/telemetryHelper.test.ts b/packages/amazonq/test/unit/codewhisperer/util/telemetryHelper.test.ts index b5d233060df..9293f637935 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/telemetryHelper.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/telemetryHelper.test.ts @@ -4,9 +4,40 @@ */ import assert from 'assert' -import { TelemetryHelper, session } from 'aws-core-vscode/codewhisperer' +import { assertTelemetryCurried, resetCodeWhispererGlobalVariables } from 'aws-core-vscode/test' +import { TelemetryHelper, Completion, session } from 'aws-core-vscode/codewhisperer' +import { + CodewhispererCompletionType, + CodewhispererSuggestionState, + CodewhispererUserDecision, +} from 'aws-core-vscode/shared' import sinon from 'sinon' +// TODO: improve and move the following test utils to codewhisperer/testUtils.ts +function aUserDecision( + completionType: CodewhispererCompletionType, + codewhispererSuggestionIndex: number, + codewhispererSuggestionState: CodewhispererSuggestionState +): CodewhispererUserDecision { + return { + codewhispererCompletionType: completionType, + codewhispererLanguage: 'python', + codewhispererRequestId: 'aFakeRequestId', + codewhispererSessionId: 'aFakeSessionId', + codewhispererSuggestionIndex: codewhispererSuggestionIndex, + codewhispererSuggestionReferenceCount: 0, + codewhispererSuggestionState: codewhispererSuggestionState, + codewhispererTriggerType: 'OnDemand', + credentialStartUrl: 'https://www.amazon.com', + } +} + +function aCompletion(): Completion { + return { + content: 'aFakeContent', + } +} + describe('telemetryHelper', function () { describe('clientComponentLatency', function () { let sut: TelemetryHelper @@ -48,4 +79,207 @@ describe('telemetryHelper', function () { assert.ok(resetStub.calledOnce) }) }) + + describe('aggregateUserDecisionByRequest', function () { + let sut: TelemetryHelper + + beforeEach(function () { + sut = new TelemetryHelper() + }) + + it('should return Line and Accept', function () { + const decisions: CodewhispererUserDecision[] = [ + aUserDecision('Line', 0, 'Accept'), + aUserDecision('Line', 1, 'Discard'), + aUserDecision('Block', 2, 'Ignore'), + aUserDecision('Block', 3, 'Ignore'), + ] + + const actual = sut.aggregateUserDecisionByRequest(decisions, 'aFakeRequestId', 'aFakeSessionId') + assert.ok(actual) + assert.strictEqual(actual?.codewhispererCompletionType, 'Line') + assert.strictEqual(actual?.codewhispererSuggestionState, 'Accept') + }) + + it('should return Line and Reject', function () { + const decisions: CodewhispererUserDecision[] = [ + aUserDecision('Line', 0, 'Discard'), + aUserDecision('Line', 1, 'Reject'), + aUserDecision('Line', 2, 'Unseen'), + aUserDecision('Line', 3, 'Unseen'), + ] + + const actual = sut.aggregateUserDecisionByRequest(decisions, 'aFakeRequestId', 'aFakeSessionId') + assert.ok(actual) + assert.strictEqual(actual?.codewhispererCompletionType, 'Line') + assert.strictEqual(actual?.codewhispererSuggestionState, 'Reject') + }) + + it('should return Block and Accept', function () { + const decisions: CodewhispererUserDecision[] = [ + aUserDecision('Block', 0, 'Discard'), + aUserDecision('Block', 1, 'Accept'), + aUserDecision('Block', 2, 'Discard'), + aUserDecision('Block', 3, 'Ignore'), + ] + + const actual = sut.aggregateUserDecisionByRequest(decisions, 'aFakeRequestId', 'aFakeSessionId') + assert.ok(actual) + assert.strictEqual(actual?.codewhispererCompletionType, 'Block') + assert.strictEqual(actual?.codewhispererSuggestionState, 'Accept') + }) + }) + + describe('sendUserTriggerDecisionTelemetry', function () { + let sut: TelemetryHelper + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + sut = new TelemetryHelper() + }) + + it('should return Line and Accept', function () { + sut.recordUserDecisionTelemetry( + ['aFakeRequestId', 'aFakeRequestId', 'aFakeRequestId2'], + 'aFakeSessionId', + [aCompletion(), aCompletion(), aCompletion(), aCompletion()], + 0, + 0, + new Map([ + [0, 'Line'], + [1, 'Line'], + [2, 'Block'], + [3, 'Block'], + ]) + ) + + sut.sendUserTriggerDecisionTelemetry('aFakeSessionId', aCompletion().content, 0) + const assertTelemetry = assertTelemetryCurried('codewhisperer_userTriggerDecision') + assertTelemetry({ + codewhispererSessionId: 'aFakeSessionId', + codewhispererFirstRequestId: 'aFakeRequestId', + codewhispererLanguage: 'python', + codewhispererTriggerType: 'OnDemand', + codewhispererLineNumber: 0, + codewhispererCursorOffset: 0, + codewhispererSuggestionCount: 4, + codewhispererSuggestionImportCount: 0, + codewhispererSuggestionState: 'Accept', + codewhispererCompletionType: 'Line', + codewhispererTypeaheadLength: 0, + codewhispererCharactersAccepted: aCompletion().content.length, + }) + }) + + it('should return Line and Accept 2', function () { + sut.recordUserDecisionTelemetry( + ['aFakeRequestId', 'aFakeRequestId', 'aFakeRequestId2'], + 'aFakeSessionId', + [aCompletion(), aCompletion(), aCompletion(), aCompletion()], + 3, + 0, + new Map([ + [0, 'Line'], + [1, 'Line'], + [2, 'Line'], + [3, 'Line'], + ]) + ) + + sut.sendUserTriggerDecisionTelemetry('aFakeSessionId', aCompletion().content, 0) + const assertTelemetry = assertTelemetryCurried('codewhisperer_userTriggerDecision') + assertTelemetry({ + codewhispererSessionId: 'aFakeSessionId', + codewhispererFirstRequestId: 'aFakeRequestId', + codewhispererLanguage: 'python', + codewhispererTriggerType: 'OnDemand', + codewhispererLineNumber: 0, + codewhispererCursorOffset: 0, + codewhispererSuggestionCount: 4, + codewhispererSuggestionImportCount: 0, + codewhispererSuggestionState: 'Accept', + codewhispererCompletionType: 'Line', + codewhispererTypeaheadLength: 0, + codewhispererCharactersAccepted: aCompletion().content.length, + }) + }) + + it('should return Line and Reject', function () { + sut.recordUserDecisionTelemetry( + ['aFakeRequestId', 'aFakeRequestId', 'aFakeRequestId2'], + 'aFakeSessionId', + [aCompletion(), aCompletion(), aCompletion(), aCompletion()], + -1, + 0, + new Map([ + [0, 'Line'], + [1, 'Line'], + [2, 'Line'], + [3, 'Line'], + ]) + ) + + sut.sendUserTriggerDecisionTelemetry('aFakeSessionId', '', 0) + const assertTelemetry = assertTelemetryCurried('codewhisperer_userTriggerDecision') + assertTelemetry({ + codewhispererSessionId: 'aFakeSessionId', + codewhispererFirstRequestId: 'aFakeRequestId', + codewhispererLanguage: 'python', + codewhispererTriggerType: 'OnDemand', + codewhispererLineNumber: 0, + codewhispererCursorOffset: 0, + codewhispererSuggestionCount: 4, + codewhispererSuggestionImportCount: 0, + codewhispererSuggestionState: 'Reject', + codewhispererCompletionType: 'Line', + codewhispererTypeaheadLength: 0, + codewhispererCharactersAccepted: 0, + }) + }) + }) + + describe('getSuggestionState', function () { + let telemetryHelper = new TelemetryHelper() + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + telemetryHelper = new TelemetryHelper() + }) + + it('user event is discard when recommendation state is Discarded with accept index = -1', function () { + const actual = telemetryHelper.getSuggestionState(0, -1, new Map([[0, 'Discard']])) + assert.strictEqual(actual, 'Discard') + }) + + it('user event is reject when recommendation state is Showed with accept index = -1', function () { + const actual = telemetryHelper.getSuggestionState(0, -1, new Map([[0, 'Showed']])) + assert.strictEqual(actual, 'Reject') + }) + + it('user event is Accept when recommendation state is Showed with accept index matches', function () { + const actual = telemetryHelper.getSuggestionState(0, 0, new Map([[0, 'Showed']])) + assert.strictEqual(actual, 'Accept') + }) + + it('user event is Ignore when recommendation state is Showed with accept index does not match', function () { + const actual = telemetryHelper.getSuggestionState(0, 1, new Map([[0, 'Showed']])) + assert.strictEqual(actual, 'Ignore') + }) + + it('user event is Unseen when recommendation state is not Showed, is not Unseen when recommendation is showed', function () { + const actual0 = telemetryHelper.getSuggestionState(0, 1, new Map([[1, 'Showed']])) + assert.strictEqual(actual0, 'Unseen') + const actual1 = telemetryHelper.getSuggestionState(1, 1, new Map([[1, 'Showed']])) + assert.strictEqual(actual1, 'Accept') + }) + + it('user event is Filter when recommendation state is Filter', function () { + const actual = telemetryHelper.getSuggestionState(0, 1, new Map([[0, 'Filter']])) + assert.strictEqual(actual, 'Filter') + }) + + it('user event is Empty when recommendation state is Empty', function () { + const actual = telemetryHelper.getSuggestionState(0, 1, new Map([[0, 'Empty']])) + assert.strictEqual(actual, 'Empty') + }) + }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/util/utgUtils.test.ts b/packages/amazonq/test/unit/codewhisperer/util/utgUtils.test.ts new file mode 100644 index 00000000000..67359b8a6fc --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/util/utgUtils.test.ts @@ -0,0 +1,63 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as utgUtils from 'aws-core-vscode/codewhisperer' + +describe('shouldFetchUtgContext', () => { + it('fully supported language', function () { + assert.ok(utgUtils.shouldFetchUtgContext('java')) + }) + + it('partially supported language', () => { + assert.strictEqual(utgUtils.shouldFetchUtgContext('python'), false) + }) + + it('not supported language', () => { + assert.strictEqual(utgUtils.shouldFetchUtgContext('typescript'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('javascript'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('javascriptreact'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('typescriptreact'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('scala'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('shellscript'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('csharp'), undefined) + + assert.strictEqual(utgUtils.shouldFetchUtgContext('c'), undefined) + }) +}) + +describe('guessSrcFileName', function () { + it('should return undefined if no matching regex', function () { + assert.strictEqual(utgUtils.guessSrcFileName('Foo.java', 'java'), undefined) + assert.strictEqual(utgUtils.guessSrcFileName('folder1/foo.py', 'python'), undefined) + assert.strictEqual(utgUtils.guessSrcFileName('Bar.js', 'javascript'), undefined) + }) + + it('java', function () { + assert.strictEqual(utgUtils.guessSrcFileName('FooTest.java', 'java'), 'Foo.java') + assert.strictEqual(utgUtils.guessSrcFileName('FooTests.java', 'java'), 'Foo.java') + }) + + it('python', function () { + assert.strictEqual(utgUtils.guessSrcFileName('test_foo.py', 'python'), 'foo.py') + assert.strictEqual(utgUtils.guessSrcFileName('foo_test.py', 'python'), 'foo.py') + }) + + it('typescript', function () { + assert.strictEqual(utgUtils.guessSrcFileName('Foo.test.ts', 'typescript'), 'Foo.ts') + assert.strictEqual(utgUtils.guessSrcFileName('Foo.spec.ts', 'typescript'), 'Foo.ts') + }) + + it('javascript', function () { + assert.strictEqual(utgUtils.guessSrcFileName('Foo.test.js', 'javascript'), 'Foo.js') + assert.strictEqual(utgUtils.guessSrcFileName('Foo.spec.js', 'javascript'), 'Foo.js') + }) +}) diff --git a/packages/core/src/codewhisperer/commands/invokeRecommendation.ts b/packages/core/src/codewhisperer/commands/invokeRecommendation.ts new file mode 100644 index 00000000000..37fcb965774 --- /dev/null +++ b/packages/core/src/codewhisperer/commands/invokeRecommendation.ts @@ -0,0 +1,45 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { vsCodeState, ConfigurationEntry } from '../models/model' +import { resetIntelliSenseState } from '../util/globalStateUtil' +import { DefaultCodeWhispererClient } from '../client/codewhisperer' +import { RecommendationHandler } from '../service/recommendationHandler' +import { session } from '../util/codeWhispererSession' +import { RecommendationService } from '../service/recommendationService' + +/** + * This function is for manual trigger CodeWhisperer + */ + +export async function invokeRecommendation( + editor: vscode.TextEditor, + client: DefaultCodeWhispererClient, + config: ConfigurationEntry +) { + if (!editor || !config.isManualTriggerEnabled) { + return + } + + /** + * Skip when output channel gains focus and invoke + */ + if (editor.document.languageId === 'Log') { + return + } + /** + * When using intelliSense, if invocation position changed, reject previous active recommendations + */ + if (vsCodeState.isIntelliSenseActive && editor.selection.active !== session.startPos) { + resetIntelliSenseState( + config.isManualTriggerEnabled, + config.isAutomatedTriggerEnabled, + RecommendationHandler.instance.isValidResponse() + ) + } + + await RecommendationService.instance.generateRecommendation(client, editor, 'OnDemand', config, undefined) +} diff --git a/packages/core/src/codewhisperer/commands/onAcceptance.ts b/packages/core/src/codewhisperer/commands/onAcceptance.ts new file mode 100644 index 00000000000..e13c197cefd --- /dev/null +++ b/packages/core/src/codewhisperer/commands/onAcceptance.ts @@ -0,0 +1,85 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { vsCodeState, OnRecommendationAcceptanceEntry } from '../models/model' +import { runtimeLanguageContext } from '../util/runtimeLanguageContext' +import { CodeWhispererTracker } from '../tracker/codewhispererTracker' +import { CodeWhispererCodeCoverageTracker } from '../tracker/codewhispererCodeCoverageTracker' +import { getLogger } from '../../shared/logger/logger' +import { handleExtraBrackets } from '../util/closingBracketUtil' +import { RecommendationHandler } from '../service/recommendationHandler' +import { ReferenceLogViewProvider } from '../service/referenceLogViewProvider' +import { ReferenceHoverProvider } from '../service/referenceHoverProvider' +import path from 'path' + +/** + * This function is called when user accepts a intelliSense suggestion or an inline suggestion + */ +export async function onAcceptance(acceptanceEntry: OnRecommendationAcceptanceEntry) { + RecommendationHandler.instance.cancelPaginatedRequest() + /** + * Format document + */ + if (acceptanceEntry.editor) { + const languageContext = runtimeLanguageContext.getLanguageContext( + acceptanceEntry.editor.document.languageId, + path.extname(acceptanceEntry.editor.document.fileName) + ) + const start = acceptanceEntry.range.start + const end = acceptanceEntry.range.end + + // codewhisperer will be doing editing while formatting. + // formatting should not trigger consoals auto trigger + vsCodeState.isCodeWhispererEditing = true + /** + * Mitigation to right context handling mainly for auto closing bracket use case + */ + try { + await handleExtraBrackets(acceptanceEntry.editor, end, start) + } catch (error) { + getLogger().error(`${error} in handleAutoClosingBrackets`) + } + // move cursor to end of suggestion before doing code format + // after formatting, the end position will still be editor.selection.active + acceptanceEntry.editor.selection = new vscode.Selection(end, end) + + vsCodeState.isCodeWhispererEditing = false + CodeWhispererTracker.getTracker().enqueue({ + time: new Date(), + fileUrl: acceptanceEntry.editor.document.uri, + originalString: acceptanceEntry.editor.document.getText(new vscode.Range(start, end)), + startPosition: start, + endPosition: end, + requestId: acceptanceEntry.requestId, + sessionId: acceptanceEntry.sessionId, + index: acceptanceEntry.acceptIndex, + triggerType: acceptanceEntry.triggerType, + completionType: acceptanceEntry.completionType, + language: languageContext.language, + }) + const insertedCoderange = new vscode.Range(start, end) + CodeWhispererCodeCoverageTracker.getTracker(languageContext.language)?.countAcceptedTokens( + insertedCoderange, + acceptanceEntry.editor.document.getText(insertedCoderange), + acceptanceEntry.editor.document.fileName + ) + if (acceptanceEntry.references !== undefined) { + const referenceLog = ReferenceLogViewProvider.getReferenceLog( + acceptanceEntry.recommendation, + acceptanceEntry.references, + acceptanceEntry.editor + ) + ReferenceLogViewProvider.instance.addReferenceLog(referenceLog) + ReferenceHoverProvider.instance.addCodeReferences( + acceptanceEntry.recommendation, + acceptanceEntry.references + ) + } + } + + // at the end of recommendation acceptance, report user decisions and clear recommendations. + RecommendationHandler.instance.reportUserDecisions(acceptanceEntry.acceptIndex) +} diff --git a/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts b/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts new file mode 100644 index 00000000000..d193af056f7 --- /dev/null +++ b/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts @@ -0,0 +1,145 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as CodeWhispererConstants from '../models/constants' +import { vsCodeState, OnRecommendationAcceptanceEntry } from '../models/model' +import { runtimeLanguageContext } from '../util/runtimeLanguageContext' +import { CodeWhispererTracker } from '../tracker/codewhispererTracker' +import { CodeWhispererCodeCoverageTracker } from '../tracker/codewhispererCodeCoverageTracker' +import { getLogger } from '../../shared/logger/logger' +import { RecommendationHandler } from '../service/recommendationHandler' +import { sleep } from '../../shared/utilities/timeoutUtils' +import { handleExtraBrackets } from '../util/closingBracketUtil' +import { Commands } from '../../shared/vscode/commands2' +import { isInlineCompletionEnabled } from '../util/commonUtil' +import { onAcceptance } from './onAcceptance' +import * as codewhispererClient from '../client/codewhisperer' +import { + CodewhispererCompletionType, + CodewhispererLanguage, + CodewhispererTriggerType, +} from '../../shared/telemetry/telemetry.gen' +import { ReferenceLogViewProvider } from '../service/referenceLogViewProvider' +import { ReferenceHoverProvider } from '../service/referenceHoverProvider' +import { ImportAdderProvider } from '../service/importAdderProvider' +import { session } from '../util/codeWhispererSession' +import path from 'path' +import { RecommendationService } from '../service/recommendationService' +import { Container } from '../service/serviceContainer' +import { telemetry } from '../../shared/telemetry/telemetry' +import { TelemetryHelper } from '../util/telemetryHelper' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' + +export const acceptSuggestion = Commands.declare( + 'aws.amazonq.accept', + (context: vscode.ExtensionContext) => + async ( + range: vscode.Range, + effectiveRange: vscode.Range, + acceptIndex: number, + recommendation: string, + requestId: string, + sessionId: string, + triggerType: CodewhispererTriggerType, + completionType: CodewhispererCompletionType, + language: CodewhispererLanguage, + references: codewhispererClient.References + ) => { + telemetry.record({ + traceId: TelemetryHelper.instance.traceId, + }) + + RecommendationService.instance.incrementAcceptedCount() + const editor = vscode.window.activeTextEditor + await Container.instance.lineAnnotationController.refresh(editor, 'codewhisperer') + const onAcceptanceFunc = isInlineCompletionEnabled() ? onInlineAcceptance : onAcceptance + await onAcceptanceFunc({ + editor, + range, + effectiveRange, + acceptIndex, + recommendation, + requestId, + sessionId, + triggerType, + completionType, + language, + references, + }) + } +) +/** + * This function is called when user accepts a intelliSense suggestion or an inline suggestion + */ +export async function onInlineAcceptance(acceptanceEntry: OnRecommendationAcceptanceEntry) { + RecommendationHandler.instance.cancelPaginatedRequest() + RecommendationHandler.instance.disposeInlineCompletion() + + if (acceptanceEntry.editor) { + await sleep(CodeWhispererConstants.vsCodeCursorUpdateDelay) + const languageContext = runtimeLanguageContext.getLanguageContext( + acceptanceEntry.editor.document.languageId, + path.extname(acceptanceEntry.editor.document.fileName) + ) + const start = acceptanceEntry.range.start + const end = acceptanceEntry.editor.selection.active + + vsCodeState.isCodeWhispererEditing = true + /** + * Mitigation to right context handling mainly for auto closing bracket use case + */ + try { + // Do not handle extra bracket if there is a right context merge + if (acceptanceEntry.recommendation === session.recommendations[acceptanceEntry.acceptIndex].content) { + await handleExtraBrackets(acceptanceEntry.editor, end, acceptanceEntry.effectiveRange.start) + } + await ImportAdderProvider.instance.onAcceptRecommendation( + acceptanceEntry.editor, + session.recommendations[acceptanceEntry.acceptIndex], + start.line + ) + } catch (error) { + getLogger().error(`${error} in handling extra brackets or imports`) + } finally { + vsCodeState.isCodeWhispererEditing = false + } + + CodeWhispererTracker.getTracker().enqueue({ + time: new Date(), + fileUrl: acceptanceEntry.editor.document.uri, + originalString: acceptanceEntry.editor.document.getText(new vscode.Range(start, end)), + startPosition: start, + endPosition: end, + requestId: acceptanceEntry.requestId, + sessionId: acceptanceEntry.sessionId, + index: acceptanceEntry.acceptIndex, + triggerType: acceptanceEntry.triggerType, + completionType: acceptanceEntry.completionType, + language: languageContext.language, + }) + const insertedCoderange = new vscode.Range(start, end) + CodeWhispererCodeCoverageTracker.getTracker(languageContext.language)?.countAcceptedTokens( + insertedCoderange, + acceptanceEntry.editor.document.getText(insertedCoderange), + acceptanceEntry.editor.document.fileName + ) + UserWrittenCodeTracker.instance.onQFinishesEdits() + if (acceptanceEntry.references !== undefined) { + const referenceLog = ReferenceLogViewProvider.getReferenceLog( + acceptanceEntry.recommendation, + acceptanceEntry.references, + acceptanceEntry.editor + ) + ReferenceLogViewProvider.instance.addReferenceLog(referenceLog) + ReferenceHoverProvider.instance.addCodeReferences( + acceptanceEntry.recommendation, + acceptanceEntry.references + ) + } + + RecommendationHandler.instance.reportUserDecisions(acceptanceEntry.acceptIndex) + } +} diff --git a/packages/core/src/codewhisperer/index.ts b/packages/core/src/codewhisperer/index.ts index ac43fba46aa..066e5ca2fcb 100644 --- a/packages/core/src/codewhisperer/index.ts +++ b/packages/core/src/codewhisperer/index.ts @@ -36,6 +36,7 @@ export { codeWhispererClient, } from './client/codewhisperer' export { listCodeWhispererCommands, listCodeWhispererCommandsId } from './ui/statusBarMenu' +export { InlineCompletionService } from './service/inlineCompletionService' export { refreshStatusBar, CodeWhispererStatusBarManager } from './service/statusBar' export { SecurityIssueHoverProvider } from './service/securityIssueHoverProvider' export { SecurityIssueCodeActionProvider } from './service/securityIssueCodeActionProvider' @@ -46,30 +47,44 @@ export { IssueItem, SeverityItem, } from './service/securityIssueTreeViewProvider' +export { onAcceptance } from './commands/onAcceptance' export { CodeWhispererTracker } from './tracker/codewhispererTracker' export { CodeWhispererUserGroupSettings } from './util/userGroupUtil' export { session } from './util/codeWhispererSession' +export { onInlineAcceptance } from './commands/onInlineAcceptance' export { stopTransformByQ } from './commands/startTransformByQ' export { featureDefinitions, FeatureConfigProvider } from '../shared/featureConfig' export { ReferenceInlineProvider } from './service/referenceInlineProvider' export { ReferenceHoverProvider } from './service/referenceHoverProvider' +export { CWInlineCompletionItemProvider } from './service/inlineCompletionItemProvider' +export { ClassifierTrigger } from './service/classifierTrigger' export { ReferenceLogViewProvider } from './service/referenceLogViewProvider' +export { RecommendationService } from './service/recommendationService' export { ImportAdderProvider } from './service/importAdderProvider' export { LicenseUtil } from './util/licenseUtil' export { SecurityIssueProvider } from './service/securityIssueProvider' export { listScanResults, mapToAggregatedList, pollScanJobStatus } from './service/securityScanHandler' export { TelemetryHelper } from './util/telemetryHelper' export { LineSelection, LineTracker } from './tracker/lineTracker' +export { BM25Okapi } from './util/supplementalContext/rankBm25' export { runtimeLanguageContext, RuntimeLanguageContext } from './util/runtimeLanguageContext' export * as startSecurityScan from './commands/startSecurityScan' +export * from './util/supplementalContext/utgUtils' +export * from './util/supplementalContext/crossFileContextUtil' +export * from './util/editorContext' +export { acceptSuggestion } from './commands/onInlineAcceptance' export * from './util/showSsoPrompt' export * from './util/securityScanLanguageContext' export * from './util/importAdderUtil' +export * from './util/globalStateUtil' export * from './util/zipUtil' export * from './util/diagnosticsUtil' export * from './util/commonUtil' export * from './util/closingBracketUtil' +export * from './util/supplementalContext/codeParsingUtil' +export * from './util/supplementalContext/supplementalContextUtil' export * from './util/codewhispererSettings' +export * as supplementalContextUtil from './util/supplementalContext/supplementalContextUtil' export * from './service/diagnosticsProvider' export * as diagnosticsProvider from './service/diagnosticsProvider' export * from './ui/codeWhispererNodes' @@ -87,3 +102,7 @@ export * from './util/gitUtil' export * from './ui/prompters' export { UserWrittenCodeTracker } from './tracker/userWrittenCodeTracker' export { RegionProfileManager, defaultServiceConfig } from './region/regionProfileManager' +export { DocumentChangedSource, KeyStrokeHandler, DefaultDocumentChangedType } from './service/keyStrokeHandler' +export { RecommendationHandler } from './service/recommendationHandler' +export { CodeWhispererCodeCoverageTracker } from './tracker/codewhispererCodeCoverageTracker' +export { invokeRecommendation } from './commands/invokeRecommendation' diff --git a/packages/core/src/codewhisperer/models/constants.ts b/packages/core/src/codewhisperer/models/constants.ts index f3bbfb07d85..9d2ae70b44a 100644 --- a/packages/core/src/codewhisperer/models/constants.ts +++ b/packages/core/src/codewhisperer/models/constants.ts @@ -138,10 +138,16 @@ export const runningSecurityScan = 'Reviewing project for code issues...' export const runningFileScan = 'Reviewing current file for code issues...' +export const noSuggestions = 'No suggestions from Amazon Q' + export const noInlineSuggestionsMsg = 'No suggestions from Amazon Q' export const licenseFilter = 'Amazon Q suggestions were filtered due to reference settings' +/** + * the interval of the background thread invocation, which is triggered by the timer + */ +export const defaultCheckPeriodMillis = 1000 * 60 * 5 /** * Key bindings JSON file path */ diff --git a/packages/core/src/codewhisperer/service/classifierTrigger.ts b/packages/core/src/codewhisperer/service/classifierTrigger.ts new file mode 100644 index 00000000000..842d5312e68 --- /dev/null +++ b/packages/core/src/codewhisperer/service/classifierTrigger.ts @@ -0,0 +1,609 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'os' +import * as vscode from 'vscode' +import { CodewhispererAutomatedTriggerType } from '../../shared/telemetry/telemetry' +import { extractContextForCodeWhisperer } from '../util/editorContext' +import { TelemetryHelper } from '../util/telemetryHelper' +import { ProgrammingLanguage } from '../client/codewhispereruserclient' + +interface normalizedCoefficients { + readonly lineNum: number + readonly lenLeftCur: number + readonly lenLeftPrev: number + readonly lenRight: number +} +/* + uses ML classifier to determine if user input should trigger CWSPR service + */ +export class ClassifierTrigger { + static #instance: ClassifierTrigger + + public static get instance() { + return (this.#instance ??= new this()) + } + + // ML classifier trigger threshold + private triggerThreshold = 0.43 + + // ML classifier coefficients + // os coefficient + private osCoefficientMap: Readonly> = { + 'Mac OS X': -0.1552, + 'Windows 10': -0.0238, + Windows: 0.0412, + win32: -0.0559, + } + + // trigger type coefficient + private triggerTypeCoefficientMap: Readonly> = { + SpecialCharacters: 0.0209, + Enter: 0.2853, + } + + private languageCoefficientMap: Readonly> = { + java: -0.4622, + javascript: -0.4688, + python: -0.3052, + typescript: -0.6084, + tsx: -0.6084, + jsx: -0.4688, + shell: -0.4718, + ruby: -0.7356, + sql: -0.4937, + rust: -0.4309, + kotlin: -0.4739, + php: -0.3917, + csharp: -0.3475, + go: -0.3504, + scala: -0.534, + cpp: -0.1734, + json: 0, + yaml: -0.3, + tf: -0.55, + } + + // other metadata coefficient + private lineNumCoefficient = -0.0416 + private lengthOfLeftCurrentCoefficient = -1.1747 + private lengthOfLeftPrevCoefficient = 0.4033 + private lengthOfRightCoefficient = -0.3321 + private prevDecisionAcceptCoefficient = 0.5397 + private prevDecisionRejectCoefficient = -0.1656 + private prevDecisionOtherCoefficient = 0 + private ideVscode = -0.1905 + private lengthLeft0To5 = -0.8756 + private lengthLeft5To10 = -0.5463 + private lengthLeft10To20 = -0.4081 + private lengthLeft20To30 = -0.3272 + private lengthLeft30To40 = -0.2442 + private lengthLeft40To50 = -0.1471 + + // intercept of logistic regression classifier + private intercept = 0.3738713 + + private maxx: normalizedCoefficients = { + lineNum: 4631.0, + lenLeftCur: 157.0, + lenLeftPrev: 176.0, + lenRight: 10239.0, + } + + private minn: normalizedCoefficients = { + lineNum: 0.0, + lenLeftCur: 0.0, + lenLeftPrev: 0.0, + lenRight: 0.0, + } + + // character and keywords coefficient + private charCoefficient: Readonly> = { + throw: 1.5868, + ';': -1.268, + any: -1.1565, + '7': -1.1347, + false: -1.1307, + nil: -1.0653, + elif: 1.0122, + '9': -1.0098, + pass: -1.0058, + True: -1.0002, + False: -0.9434, + '6': -0.9222, + true: -0.9142, + None: -0.9027, + '8': -0.9013, + break: -0.8475, + '}': -0.847, + '5': -0.8414, + '4': -0.8197, + '1': -0.8085, + '\\': -0.8019, + static: -0.7748, + '0': -0.77, + end: -0.7617, + '(': 0.7239, + '/': -0.7104, + where: -0.6981, + readonly: -0.6741, + async: -0.6723, + '3': -0.654, + continue: -0.6413, + struct: -0.64, + try: -0.6369, + float: -0.6341, + using: 0.6079, + '@': 0.6016, + '|': 0.5993, + impl: 0.5808, + private: -0.5746, + for: 0.5741, + '2': -0.5634, + let: -0.5187, + foreach: 0.5186, + select: -0.5148, + export: -0.5, + mut: -0.4921, + ')': -0.463, + ']': -0.4611, + when: 0.4602, + virtual: -0.4583, + extern: -0.4465, + catch: 0.4446, + new: 0.4394, + val: -0.4339, + map: 0.4284, + case: 0.4271, + throws: 0.4221, + null: -0.4197, + protected: -0.4133, + q: 0.4125, + except: 0.4115, + ': ': 0.4072, + '^': -0.407, + ' ': 0.4066, + $: 0.3981, + this: 0.3962, + switch: 0.3947, + '*': -0.3931, + module: 0.3912, + array: 0.385, + '=': 0.3828, + p: 0.3728, + ON: 0.3708, + '`': 0.3693, + u: 0.3658, + a: 0.3654, + require: 0.3646, + '>': -0.3644, + const: -0.3476, + o: 0.3423, + sizeof: 0.3416, + object: 0.3362, + w: 0.3345, + print: 0.3344, + range: 0.3336, + if: 0.3324, + abstract: -0.3293, + var: -0.3239, + i: 0.321, + while: 0.3138, + J: 0.3137, + c: 0.3118, + await: -0.3072, + from: 0.3057, + f: 0.302, + echo: 0.2995, + '#': 0.2984, + e: 0.2962, + r: 0.2925, + mod: 0.2893, + loop: 0.2874, + t: 0.2832, + '~': 0.282, + final: -0.2816, + del: 0.2785, + override: -0.2746, + ref: -0.2737, + h: 0.2693, + m: 0.2681, + '{': 0.2674, + implements: 0.2672, + inline: -0.2642, + match: 0.2613, + with: -0.261, + x: 0.2597, + namespace: -0.2596, + operator: 0.2573, + double: -0.2563, + source: -0.2482, + import: -0.2419, + NULL: -0.2399, + l: 0.239, + or: 0.2378, + s: 0.2366, + then: 0.2354, + W: 0.2354, + y: 0.2333, + local: 0.2288, + is: 0.2282, + n: 0.2254, + '+': -0.2251, + G: 0.223, + public: -0.2229, + WHERE: 0.2224, + list: 0.2204, + Q: 0.2204, + '[': 0.2136, + VALUES: 0.2134, + H: 0.2105, + g: 0.2094, + else: -0.208, + bool: -0.2066, + long: -0.2059, + R: 0.2025, + S: 0.2021, + d: 0.2003, + V: 0.1974, + K: -0.1961, + '<': 0.1958, + debugger: -0.1929, + NOT: -0.1911, + b: 0.1907, + boolean: -0.1891, + z: -0.1866, + LIKE: -0.1793, + raise: 0.1782, + L: 0.1768, + fn: 0.176, + delete: 0.1714, + unsigned: -0.1675, + auto: -0.1648, + finally: 0.1616, + k: 0.1599, + as: 0.156, + instanceof: 0.1558, + '&': 0.1554, + E: 0.1551, + M: 0.1542, + I: 0.1503, + Y: 0.1493, + typeof: 0.1475, + j: 0.1445, + INTO: 0.1442, + IF: 0.1437, + next: 0.1433, + undef: -0.1427, + THEN: -0.1416, + v: 0.1415, + C: 0.1383, + P: 0.1353, + AND: -0.1345, + constructor: 0.1337, + void: -0.1336, + class: -0.1328, + defer: 0.1316, + begin: 0.1306, + FROM: -0.1304, + SET: 0.1291, + decimal: -0.1278, + friend: 0.1277, + SELECT: -0.1265, + event: 0.1259, + lambda: 0.1253, + enum: 0.1215, + A: 0.121, + lock: 0.1187, + ensure: 0.1184, + '%': 0.1177, + isset: 0.1175, + O: 0.1174, + '.': 0.1146, + UNION: -0.1145, + alias: -0.1129, + template: -0.1102, + WHEN: 0.1093, + rescue: 0.1083, + DISTINCT: -0.1074, + trait: -0.1073, + D: 0.1062, + in: 0.1045, + internal: -0.1029, + ',': 0.1027, + static_cast: 0.1016, + do: -0.1005, + OR: 0.1003, + AS: -0.1001, + interface: 0.0996, + super: 0.0989, + B: 0.0963, + U: 0.0962, + T: 0.0943, + CALL: -0.0918, + BETWEEN: -0.0915, + N: 0.0897, + yield: 0.0867, + done: -0.0857, + string: -0.0837, + out: -0.0831, + volatile: -0.0819, + retry: 0.0816, + '?': -0.0796, + number: -0.0791, + short: 0.0787, + sealed: -0.0776, + package: 0.0765, + OPEN: -0.0756, + base: 0.0735, + and: 0.0729, + exit: 0.0726, + _: 0.0721, + keyof: -0.072, + def: 0.0713, + crate: -0.0706, + '-': -0.07, + FUNCTION: 0.0692, + declare: -0.0678, + include: 0.0671, + COUNT: -0.0669, + INDEX: -0.0666, + CLOSE: -0.0651, + fi: -0.0644, + uint: 0.0624, + params: 0.0575, + HAVING: 0.0575, + byte: -0.0575, + clone: -0.0552, + char: -0.054, + func: 0.0538, + never: -0.053, + unset: -0.0524, + unless: -0.051, + esac: -0.0509, + shift: -0.0507, + require_once: 0.0486, + ELSE: -0.0477, + extends: 0.0461, + elseif: 0.0452, + mutable: -0.0451, + asm: 0.0449, + '!': 0.0446, + LIMIT: 0.0444, + ushort: -0.0438, + '"': -0.0433, + Z: 0.0431, + exec: -0.0431, + IS: -0.0429, + DECLARE: -0.0425, + __LINE__: -0.0424, + BEGIN: -0.0418, + typedef: 0.0414, + EXIT: -0.0412, + "'": 0.041, + function: -0.0393, + dyn: -0.039, + wchar_t: -0.0388, + unique: -0.0383, + include_once: 0.0367, + stackalloc: 0.0359, + RETURN: -0.0356, + const_cast: 0.035, + MAX: 0.0341, + assert: -0.0331, + JOIN: -0.0328, + use: 0.0318, + GET: 0.0317, + VIEW: 0.0314, + move: 0.0308, + typename: 0.0308, + die: 0.0305, + asserts: -0.0304, + reinterpret_cast: -0.0302, + USING: -0.0289, + elsif: -0.0285, + FIRST: -0.028, + self: -0.0278, + RETURNING: -0.0278, + symbol: -0.0273, + OFFSET: 0.0263, + bigint: 0.0253, + register: -0.0237, + union: -0.0227, + return: -0.0227, + until: -0.0224, + endfor: -0.0213, + implicit: -0.021, + LOOP: 0.0195, + pub: 0.0182, + global: 0.0179, + EXCEPTION: 0.0175, + delegate: 0.0173, + signed: -0.0163, + FOR: 0.0156, + unsafe: 0.014, + NEXT: -0.0133, + IN: 0.0129, + MIN: -0.0123, + go: -0.0112, + type: -0.0109, + explicit: -0.0107, + eval: -0.0104, + int: -0.0099, + CASE: -0.0096, + END: 0.0084, + UPDATE: 0.0074, + default: 0.0072, + chan: 0.0068, + fixed: 0.0066, + not: -0.0052, + X: -0.0047, + endforeach: 0.0031, + goto: 0.0028, + empty: 0.0022, + checked: 0.0012, + F: -0.001, + } + + public getThreshold() { + return this.triggerThreshold + } + + public recordClassifierResultForManualTrigger(editor: vscode.TextEditor) { + this.shouldTriggerFromClassifier(undefined, editor, undefined, true) + } + + public recordClassifierResultForAutoTrigger( + editor: vscode.TextEditor, + triggerType?: CodewhispererAutomatedTriggerType, + event?: vscode.TextDocumentChangeEvent + ) { + if (!triggerType) { + return + } + this.shouldTriggerFromClassifier(event, editor, triggerType, true) + } + + public shouldTriggerFromClassifier( + event: vscode.TextDocumentChangeEvent | undefined, + editor: vscode.TextEditor, + autoTriggerType: string | undefined, + shouldRecordResult: boolean = false + ): boolean { + const fileContext = extractContextForCodeWhisperer(editor) + const osPlatform = this.normalizeOsName(os.platform(), os.version()) + const char = event ? event.contentChanges[0].text : '' + const lineNum = editor.selection.active.line + const classifierResult = this.getClassifierResult( + fileContext.leftFileContent, + fileContext.rightFileContent, + osPlatform, + autoTriggerType, + char, + lineNum, + fileContext.programmingLanguage + ) + + const threshold = this.getThreshold() + + const shouldTrigger = classifierResult > threshold + if (shouldRecordResult) { + TelemetryHelper.instance.setClassifierResult(classifierResult) + TelemetryHelper.instance.setClassifierThreshold(threshold) + } + return shouldTrigger + } + + private getClassifierResult( + leftContext: string, + rightContext: string, + os: string, + triggerType: string | undefined, + char: string, + lineNum: number, + language: ProgrammingLanguage + ): number { + const leftContextLines = leftContext.split(/\r?\n/) + const leftContextAtCurrentLine = leftContextLines[leftContextLines.length - 1] + const tokens = leftContextAtCurrentLine.trim().split(' ') + let keyword = '' + const lastToken = tokens[tokens.length - 1] + if (lastToken && lastToken.length > 1) { + keyword = lastToken + } + const lengthOfLeftCurrent = leftContextLines[leftContextLines.length - 1].length + const lengthOfLeftPrev = leftContextLines[leftContextLines.length - 2]?.length ?? 0 + const lengthOfRight = rightContext.trim().length + + const triggerTypeCoefficient: number = this.triggerTypeCoefficientMap[triggerType || ''] ?? 0 + const osCoefficient: number = this.osCoefficientMap[os] ?? 0 + const charCoefficient: number = this.charCoefficient[char] ?? 0 + const keyWordCoefficient: number = this.charCoefficient[keyword] ?? 0 + const ideCoefficient = this.ideVscode + + const previousDecision = TelemetryHelper.instance.getLastTriggerDecisionForClassifier() + const languageCoefficients = Object.values(this.languageCoefficientMap) + const avrgCoefficient = + languageCoefficients.length > 0 + ? languageCoefficients.reduce((a, b) => a + b) / languageCoefficients.length + : 0 + const languageCoefficient = this.languageCoefficientMap[language.languageName] ?? avrgCoefficient + + let previousDecisionCoefficient = 0 + if (previousDecision === 'Accept') { + previousDecisionCoefficient = this.prevDecisionAcceptCoefficient + } else if (previousDecision === 'Reject') { + previousDecisionCoefficient = this.prevDecisionRejectCoefficient + } else if (previousDecision === 'Discard' || previousDecision === 'Empty') { + previousDecisionCoefficient = this.prevDecisionOtherCoefficient + } + + let leftContextLengthCoefficient = 0 + if (leftContext.length >= 0 && leftContext.length < 5) { + leftContextLengthCoefficient = this.lengthLeft0To5 + } else if (leftContext.length >= 5 && leftContext.length < 10) { + leftContextLengthCoefficient = this.lengthLeft5To10 + } else if (leftContext.length >= 10 && leftContext.length < 20) { + leftContextLengthCoefficient = this.lengthLeft10To20 + } else if (leftContext.length >= 20 && leftContext.length < 30) { + leftContextLengthCoefficient = this.lengthLeft20To30 + } else if (leftContext.length >= 30 && leftContext.length < 40) { + leftContextLengthCoefficient = this.lengthLeft30To40 + } else if (leftContext.length >= 40 && leftContext.length < 50) { + leftContextLengthCoefficient = this.lengthLeft40To50 + } + + const result = + (this.lengthOfRightCoefficient * (lengthOfRight - this.minn.lenRight)) / + (this.maxx.lenRight - this.minn.lenRight) + + (this.lengthOfLeftCurrentCoefficient * (lengthOfLeftCurrent - this.minn.lenLeftCur)) / + (this.maxx.lenLeftCur - this.minn.lenLeftCur) + + (this.lengthOfLeftPrevCoefficient * (lengthOfLeftPrev - this.minn.lenLeftPrev)) / + (this.maxx.lenLeftPrev - this.minn.lenLeftPrev) + + (this.lineNumCoefficient * (lineNum - this.minn.lineNum)) / (this.maxx.lineNum - this.minn.lineNum) + + osCoefficient + + triggerTypeCoefficient + + charCoefficient + + keyWordCoefficient + + ideCoefficient + + this.intercept + + previousDecisionCoefficient + + languageCoefficient + + leftContextLengthCoefficient + + return sigmoid(result) + } + + private normalizeOsName(name: string, version: string | undefined): string { + const lowercaseName = name.toLowerCase() + if (lowercaseName.includes('windows')) { + if (!version) { + return 'Windows' + } else if (version.includes('Windows NT 10') || version.startsWith('10')) { + return 'Windows 10' + } else if (version.includes('6.1')) { + return 'Windows 7' + } else if (version.includes('6.3')) { + return 'Windows 8.1' + } else { + return 'Windows' + } + } else if ( + lowercaseName.includes('macos') || + lowercaseName.includes('mac os') || + lowercaseName.includes('darwin') + ) { + return 'Mac OS X' + } else if (lowercaseName.includes('linux')) { + return 'Linux' + } else { + return name + } + } +} + +const sigmoid = (x: number) => { + return 1 / (1 + Math.exp(-x)) +} diff --git a/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts b/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts new file mode 100644 index 00000000000..a6c424c321d --- /dev/null +++ b/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts @@ -0,0 +1,194 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import vscode, { Position } from 'vscode' +import { getPrefixSuffixOverlap } from '../util/commonUtil' +import { Recommendation } from '../client/codewhisperer' +import { session } from '../util/codeWhispererSession' +import { TelemetryHelper } from '../util/telemetryHelper' +import { runtimeLanguageContext } from '../util/runtimeLanguageContext' +import { ReferenceInlineProvider } from './referenceInlineProvider' +import { ImportAdderProvider } from './importAdderProvider' +import { application } from '../util/codeWhispererApplication' +import path from 'path' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' + +export class CWInlineCompletionItemProvider implements vscode.InlineCompletionItemProvider { + private activeItemIndex: number | undefined + private nextMove: number + private recommendations: Recommendation[] + private requestId: string + private startPos: Position + private nextToken: string + + private _onDidShow: vscode.EventEmitter = new vscode.EventEmitter() + public readonly onDidShow: vscode.Event = this._onDidShow.event + + public constructor( + itemIndex: number | undefined, + firstMove: number, + recommendations: Recommendation[], + requestId: string, + startPos: Position, + nextToken: string + ) { + this.activeItemIndex = itemIndex + this.nextMove = firstMove + this.recommendations = recommendations + this.requestId = requestId + this.startPos = startPos + this.nextToken = nextToken + } + + get getActiveItemIndex() { + return this.activeItemIndex + } + + public clearActiveItemIndex() { + this.activeItemIndex = undefined + } + + // iterate suggestions and stop at index 0 or index len - 1 + private getIteratingIndexes() { + const len = this.recommendations.length + const startIndex = this.activeItemIndex ? this.activeItemIndex : 0 + const index = [] + if (this.nextMove === 0) { + for (let i = 0; i < len; i++) { + index.push((startIndex + i) % len) + } + } else if (this.nextMove === -1) { + for (let i = startIndex - 1; i >= 0; i--) { + index.push(i) + } + index.push(startIndex) + } else { + for (let i = startIndex + 1; i < len; i++) { + index.push(i) + } + index.push(startIndex) + } + return index + } + + truncateOverlapWithRightContext(document: vscode.TextDocument, suggestion: string, pos: vscode.Position): string { + const trimmedSuggestion = suggestion.trim() + // limit of 5000 for right context matching + const rightContext = document.getText(new vscode.Range(pos, document.positionAt(document.offsetAt(pos) + 5000))) + const overlap = getPrefixSuffixOverlap(trimmedSuggestion, rightContext) + const overlapIndex = suggestion.lastIndexOf(overlap) + if (overlapIndex >= 0) { + const truncated = suggestion.slice(0, overlapIndex) + return truncated.trim().length ? truncated : '' + } else { + return suggestion + } + } + + getInlineCompletionItem( + document: vscode.TextDocument, + r: Recommendation, + start: vscode.Position, + end: vscode.Position, + index: number, + prefix: string + ): vscode.InlineCompletionItem | undefined { + if (!r.content.startsWith(prefix)) { + return undefined + } + const effectiveStart = document.positionAt(document.offsetAt(start) + prefix.length) + const truncatedSuggestion = this.truncateOverlapWithRightContext(document, r.content, end) + if (truncatedSuggestion.length === 0) { + if (session.getSuggestionState(index) !== 'Showed') { + session.setSuggestionState(index, 'Discard') + } + return undefined + } + TelemetryHelper.instance.lastSuggestionInDisplay = truncatedSuggestion + return { + insertText: truncatedSuggestion, + range: new vscode.Range(start, end), + command: { + command: 'aws.amazonq.accept', + title: 'On acceptance', + arguments: [ + new vscode.Range(start, end), + new vscode.Range(effectiveStart, end), + index, + truncatedSuggestion, + this.requestId, + session.sessionId, + session.triggerType, + session.getCompletionType(index), + runtimeLanguageContext.getLanguageContext(document.languageId, path.extname(document.fileName)) + .language, + r.references, + ], + }, + } + } + + // the returned completion items will always only contain one valid item + // this is to trace the current index of visible completion item + // so that reference tracker can show + // This hack can be removed once inlineCompletionAdditions API becomes public + provideInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _context: vscode.InlineCompletionContext, + _token: vscode.CancellationToken + ): vscode.ProviderResult { + if (position.line < 0 || position.isBefore(this.startPos)) { + application()._clearCodeWhispererUIListener.fire() + this.activeItemIndex = undefined + return + } + + // There's a chance that the startPos is no longer valid in the current document (e.g. + // when CodeWhisperer got triggered by 'Enter', the original startPos is with indentation + // but then this indentation got removed by VSCode when another new line is inserted, + // before the code reaches here). In such case, we need to update the startPos to be a + // valid one. Otherwise, inline completion which utilizes this position will function + // improperly. + const start = document.validatePosition(this.startPos) + const end = position + const iteratingIndexes = this.getIteratingIndexes() + const prefix = document.getText(new vscode.Range(start, end)).replace(/\r\n/g, '\n') + const matchedCount = session.recommendations.filter( + (r) => r.content.length > 0 && r.content.startsWith(prefix) && r.content !== prefix + ).length + for (const i of iteratingIndexes) { + const r = session.recommendations[i] + const item = this.getInlineCompletionItem(document, r, start, end, i, prefix) + if (item === undefined) { + continue + } + this.activeItemIndex = i + session.setSuggestionState(i, 'Showed') + ReferenceInlineProvider.instance.setInlineReference(this.startPos.line, r.content, r.references) + ImportAdderProvider.instance.onShowRecommendation(document, this.startPos.line, r) + this.nextMove = 0 + TelemetryHelper.instance.setFirstSuggestionShowTime() + session.setPerceivedLatency() + UserWrittenCodeTracker.instance.onQStartsMakingEdits() + this._onDidShow.fire() + if (matchedCount >= 2 || this.nextToken !== '') { + const result = [item] + for (let j = 0; j < matchedCount - 1; j++) { + result.push({ + insertText: `${ + typeof item.insertText === 'string' ? item.insertText : item.insertText.value + }${j}`, + range: item.range, + }) + } + return result + } + return [item] + } + application()._clearCodeWhispererUIListener.fire() + this.activeItemIndex = undefined + return [] + } +} diff --git a/packages/core/src/codewhisperer/service/inlineCompletionService.ts b/packages/core/src/codewhisperer/service/inlineCompletionService.ts new file mode 100644 index 00000000000..18a7c014b6a --- /dev/null +++ b/packages/core/src/codewhisperer/service/inlineCompletionService.ts @@ -0,0 +1,163 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { CodeSuggestionsState, ConfigurationEntry, GetRecommendationsResponse, vsCodeState } from '../models/model' +import * as CodeWhispererConstants from '../models/constants' +import { DefaultCodeWhispererClient } from '../client/codewhisperer' +import { RecommendationHandler } from './recommendationHandler' +import { CodewhispererAutomatedTriggerType, CodewhispererTriggerType } from '../../shared/telemetry/telemetry' +import { showTimedMessage } from '../../shared/utilities/messages' +import { getLogger } from '../../shared/logger/logger' +import { TelemetryHelper } from '../util/telemetryHelper' +import { AuthUtil } from '../util/authUtil' +import { shared } from '../../shared/utilities/functionUtils' +import { ClassifierTrigger } from './classifierTrigger' +import { session } from '../util/codeWhispererSession' +import { noSuggestions } from '../models/constants' +import { CodeWhispererStatusBarManager } from './statusBar' + +export class InlineCompletionService { + private maxPage = 100 + private statusBar: CodeWhispererStatusBarManager + private _showRecommendationTimer?: NodeJS.Timer + + constructor(statusBar: CodeWhispererStatusBarManager = CodeWhispererStatusBarManager.instance) { + this.statusBar = statusBar + + RecommendationHandler.instance.onDidReceiveRecommendation((e) => { + this.startShowRecommendationTimer() + }) + + CodeSuggestionsState.instance.onDidChangeState(() => { + return this.statusBar.refreshStatusBar() + }) + } + + static #instance: InlineCompletionService + + public static get instance() { + return (this.#instance ??= new this()) + } + + filePath(): string | undefined { + return RecommendationHandler.instance.documentUri?.fsPath + } + + private sharedTryShowRecommendation = shared( + RecommendationHandler.instance.tryShowRecommendation.bind(RecommendationHandler.instance) + ) + + private startShowRecommendationTimer() { + if (this._showRecommendationTimer) { + clearInterval(this._showRecommendationTimer) + this._showRecommendationTimer = undefined + } + this._showRecommendationTimer = setInterval(() => { + const delay = performance.now() - vsCodeState.lastUserModificationTime + if (delay < CodeWhispererConstants.inlineSuggestionShowDelay) { + return + } + this.sharedTryShowRecommendation() + .catch((e) => { + getLogger().error('tryShowRecommendation failed: %s', (e as Error).message) + }) + .finally(() => { + if (this._showRecommendationTimer) { + clearInterval(this._showRecommendationTimer) + this._showRecommendationTimer = undefined + } + }) + }, CodeWhispererConstants.showRecommendationTimerPollPeriod) + } + + async getPaginatedRecommendation( + client: DefaultCodeWhispererClient, + editor: vscode.TextEditor, + triggerType: CodewhispererTriggerType, + config: ConfigurationEntry, + autoTriggerType?: CodewhispererAutomatedTriggerType, + event?: vscode.TextDocumentChangeEvent + ): Promise { + if (vsCodeState.isCodeWhispererEditing || RecommendationHandler.instance.isSuggestionVisible()) { + return { + result: 'Failed', + errorMessage: 'Amazon Q is already running', + recommendationCount: 0, + } + } + + // Call report user decisions once to report recommendations leftover from last invocation. + RecommendationHandler.instance.reportUserDecisions(-1) + TelemetryHelper.instance.setInvokeSuggestionStartTime() + ClassifierTrigger.instance.recordClassifierResultForAutoTrigger(editor, autoTriggerType, event) + + const triggerChar = event?.contentChanges[0]?.text + if (autoTriggerType === 'SpecialCharacters' && triggerChar) { + TelemetryHelper.instance.setTriggerCharForUserTriggerDecision(triggerChar) + } + const isAutoTrigger = triggerType === 'AutoTrigger' + if (AuthUtil.instance.isConnectionExpired()) { + await AuthUtil.instance.notifyReauthenticate(isAutoTrigger) + return { + result: 'Failed', + errorMessage: 'auth', + recommendationCount: 0, + } + } + + await this.statusBar.setLoading() + + RecommendationHandler.instance.checkAndResetCancellationTokens() + RecommendationHandler.instance.documentUri = editor.document.uri + let response: GetRecommendationsResponse = { + result: 'Failed', + errorMessage: undefined, + recommendationCount: 0, + } + try { + let page = 0 + while (page < this.maxPage) { + response = await RecommendationHandler.instance.getRecommendations( + client, + editor, + triggerType, + config, + autoTriggerType, + true, + page + ) + if (RecommendationHandler.instance.checkAndResetCancellationTokens()) { + RecommendationHandler.instance.reportUserDecisions(-1) + await vscode.commands.executeCommand('aws.amazonq.refreshStatusBar') + if (triggerType === 'OnDemand' && session.recommendations.length === 0) { + void showTimedMessage(response.errorMessage ? response.errorMessage : noSuggestions, 2000) + } + return { + result: 'Failed', + errorMessage: 'cancelled', + recommendationCount: 0, + } + } + if (!RecommendationHandler.instance.hasNextToken()) { + break + } + page++ + } + } catch (error) { + getLogger().error(`Error ${error} in getPaginatedRecommendation`) + } + await vscode.commands.executeCommand('aws.amazonq.refreshStatusBar') + if (triggerType === 'OnDemand' && session.recommendations.length === 0) { + void showTimedMessage(response.errorMessage ? response.errorMessage : noSuggestions, 2000) + } + TelemetryHelper.instance.tryRecordClientComponentLatency() + + return { + result: 'Succeeded', + errorMessage: undefined, + recommendationCount: session.recommendations.length, + } + } +} diff --git a/packages/core/src/codewhisperer/service/keyStrokeHandler.ts b/packages/core/src/codewhisperer/service/keyStrokeHandler.ts new file mode 100644 index 00000000000..49ef633a98f --- /dev/null +++ b/packages/core/src/codewhisperer/service/keyStrokeHandler.ts @@ -0,0 +1,267 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { DefaultCodeWhispererClient } from '../client/codewhisperer' +import * as CodeWhispererConstants from '../models/constants' +import { ConfigurationEntry } from '../models/model' +import { getLogger } from '../../shared/logger/logger' +import { RecommendationHandler } from './recommendationHandler' +import { CodewhispererAutomatedTriggerType } from '../../shared/telemetry/telemetry' +import { getTabSizeSetting } from '../../shared/utilities/editorUtilities' +import { isInlineCompletionEnabled } from '../util/commonUtil' +import { ClassifierTrigger } from './classifierTrigger' +import { extractContextForCodeWhisperer } from '../util/editorContext' +import { RecommendationService } from './recommendationService' + +/** + * This class is for CodeWhisperer auto trigger + */ +export class KeyStrokeHandler { + /** + * Special character which automated triggers codewhisperer + */ + public specialChar: string + /** + * Key stroke count for automated trigger + */ + + private idleTriggerTimer?: NodeJS.Timer + + public lastInvocationTime?: number + + constructor() { + this.specialChar = '' + } + + static #instance: KeyStrokeHandler + + public static get instance() { + return (this.#instance ??= new this()) + } + + public startIdleTimeTriggerTimer( + event: vscode.TextDocumentChangeEvent, + editor: vscode.TextEditor, + client: DefaultCodeWhispererClient, + config: ConfigurationEntry + ) { + if (this.idleTriggerTimer) { + clearInterval(this.idleTriggerTimer) + this.idleTriggerTimer = undefined + } + if (!this.shouldTriggerIdleTime()) { + return + } + this.idleTriggerTimer = setInterval(() => { + const duration = (performance.now() - RecommendationHandler.instance.lastInvocationTime) / 1000 + if (duration < CodeWhispererConstants.invocationTimeIntervalThreshold) { + return + } + + this.invokeAutomatedTrigger('IdleTime', editor, client, config, event) + .catch((e) => { + getLogger().error('invokeAutomatedTrigger failed: %s', (e as Error).message) + }) + .finally(() => { + if (this.idleTriggerTimer) { + clearInterval(this.idleTriggerTimer) + this.idleTriggerTimer = undefined + } + }) + }, CodeWhispererConstants.idleTimerPollPeriod) + } + + public shouldTriggerIdleTime(): boolean { + if (isInlineCompletionEnabled() && RecommendationService.instance.isRunning) { + return false + } + return true + } + + async processKeyStroke( + event: vscode.TextDocumentChangeEvent, + editor: vscode.TextEditor, + client: DefaultCodeWhispererClient, + config: ConfigurationEntry + ): Promise { + try { + if (!config.isAutomatedTriggerEnabled) { + return + } + + // Skip when output channel gains focus and invoke + if (editor.document.languageId === 'Log') { + return + } + + const { rightFileContent } = extractContextForCodeWhisperer(editor) + const rightContextLines = rightFileContent.split(/\r?\n/) + const rightContextAtCurrentLine = rightContextLines[0] + // we do not want to trigger when there is immediate right context on the same line + // with "}" being an exception because of IDE auto-complete + if ( + rightContextAtCurrentLine.length && + !rightContextAtCurrentLine.startsWith(' ') && + rightContextAtCurrentLine.trim() !== '}' && + rightContextAtCurrentLine.trim() !== ')' + ) { + return + } + + let triggerType: CodewhispererAutomatedTriggerType | undefined + const changedSource = new DefaultDocumentChangedType(event.contentChanges).checkChangeSource() + + switch (changedSource) { + case DocumentChangedSource.EnterKey: { + triggerType = 'Enter' + break + } + case DocumentChangedSource.SpecialCharsKey: { + triggerType = 'SpecialCharacters' + break + } + case DocumentChangedSource.RegularKey: { + triggerType = ClassifierTrigger.instance.shouldTriggerFromClassifier(event, editor, triggerType) + ? 'Classifier' + : undefined + break + } + default: { + break + } + } + + if (triggerType) { + await this.invokeAutomatedTrigger(triggerType, editor, client, config, event) + } + } catch (error) { + getLogger().verbose(`Automated Trigger Exception : ${error}`) + } + } + + async invokeAutomatedTrigger( + autoTriggerType: CodewhispererAutomatedTriggerType, + editor: vscode.TextEditor, + client: DefaultCodeWhispererClient, + config: ConfigurationEntry, + event: vscode.TextDocumentChangeEvent + ): Promise { + if (!editor) { + return + } + + // RecommendationHandler.instance.reportUserDecisionOfRecommendation(editor, -1) + await RecommendationService.instance.generateRecommendation( + client, + editor, + 'AutoTrigger', + config, + autoTriggerType + ) + } +} + +export abstract class DocumentChangedType { + constructor(protected readonly contentChanges: ReadonlyArray) { + this.contentChanges = contentChanges + } + + abstract checkChangeSource(): DocumentChangedSource + + // Enter key should always start with ONE '\n' or '\r\n' and potentially following spaces due to IDE reformat + protected isEnterKey(str: string): boolean { + if (str.length === 0) { + return false + } + return ( + (str.startsWith('\r\n') && str.substring(2).trim() === '') || + (str[0] === '\n' && str.substring(1).trim() === '') + ) + } + + // Tab should consist of space char only ' ' and the length % tabSize should be 0 + protected isTabKey(str: string): boolean { + const tabSize = getTabSizeSetting() + if (str.length % tabSize === 0 && str.trim() === '') { + return true + } + return false + } + + protected isUserTypingSpecialChar(str: string): boolean { + return ['(', '()', '[', '[]', '{', '{}', ':'].includes(str) + } + + protected isSingleLine(str: string): boolean { + let newLineCounts = 0 + for (const ch of str) { + if (ch === '\n') { + newLineCounts += 1 + } + } + + // since pressing Enter key possibly will generate string like '\n ' due to indention + if (this.isEnterKey(str)) { + return true + } + if (newLineCounts >= 1) { + return false + } + return true + } +} + +export class DefaultDocumentChangedType extends DocumentChangedType { + constructor(contentChanges: ReadonlyArray) { + super(contentChanges) + } + + checkChangeSource(): DocumentChangedSource { + if (this.contentChanges.length === 0) { + return DocumentChangedSource.Unknown + } + + // event.contentChanges.length will be 2 when user press Enter key multiple times + if (this.contentChanges.length > 2) { + return DocumentChangedSource.Reformatting + } + + // Case when event.contentChanges.length === 1 + const changedText = this.contentChanges[0].text + + if (this.isSingleLine(changedText)) { + if (changedText === '') { + return DocumentChangedSource.Deletion + } else if (this.isEnterKey(changedText)) { + return DocumentChangedSource.EnterKey + } else if (this.isTabKey(changedText)) { + return DocumentChangedSource.TabKey + } else if (this.isUserTypingSpecialChar(changedText)) { + return DocumentChangedSource.SpecialCharsKey + } else if (changedText.length === 1) { + return DocumentChangedSource.RegularKey + } else if (new RegExp('^[ ]+$').test(changedText)) { + // single line && single place reformat should consist of space chars only + return DocumentChangedSource.Reformatting + } else { + return DocumentChangedSource.Unknown + } + } + + // Won't trigger cwspr on multi-line changes + return DocumentChangedSource.Unknown + } +} + +export enum DocumentChangedSource { + SpecialCharsKey = 'SpecialCharsKey', + RegularKey = 'RegularKey', + TabKey = 'TabKey', + EnterKey = 'EnterKey', + Reformatting = 'Reformatting', + Deletion = 'Deletion', + Unknown = 'Unknown', +} diff --git a/packages/core/src/codewhisperer/service/recommendationHandler.ts b/packages/core/src/codewhisperer/service/recommendationHandler.ts new file mode 100644 index 00000000000..5abeab17d9b --- /dev/null +++ b/packages/core/src/codewhisperer/service/recommendationHandler.ts @@ -0,0 +1,731 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { extensionVersion } from '../../shared/vscode/env' +import { RecommendationsList, DefaultCodeWhispererClient, CognitoCredentialsError } from '../client/codewhisperer' +import * as EditorContext from '../util/editorContext' +import * as CodeWhispererConstants from '../models/constants' +import { ConfigurationEntry, GetRecommendationsResponse, vsCodeState } from '../models/model' +import { runtimeLanguageContext } from '../util/runtimeLanguageContext' +import { AWSError } from 'aws-sdk' +import { isAwsError } from '../../shared/errors' +import { TelemetryHelper } from '../util/telemetryHelper' +import { getLogger } from '../../shared/logger/logger' +import { hasVendedIamCredentials } from '../../auth/auth' +import { + asyncCallWithTimeout, + isInlineCompletionEnabled, + isVscHavingRegressionInlineCompletionApi, +} from '../util/commonUtil' +import { showTimedMessage } from '../../shared/utilities/messages' +import { + CodewhispererAutomatedTriggerType, + CodewhispererCompletionType, + CodewhispererGettingStartedTask, + CodewhispererTriggerType, + telemetry, +} from '../../shared/telemetry/telemetry' +import { CodeWhispererCodeCoverageTracker } from '../tracker/codewhispererCodeCoverageTracker' +import { invalidCustomizationMessage } from '../models/constants' +import { getSelectedCustomization, switchToBaseCustomizationAndNotify } from '../util/customizationUtil' +import { session } from '../util/codeWhispererSession' +import { Commands } from '../../shared/vscode/commands2' +import globals from '../../shared/extensionGlobals' +import { noSuggestions, updateInlineLockKey } from '../models/constants' +import AsyncLock from 'async-lock' +import { AuthUtil } from '../util/authUtil' +import { CWInlineCompletionItemProvider } from './inlineCompletionItemProvider' +import { application } from '../util/codeWhispererApplication' +import { openUrl } from '../../shared/utilities/vsCodeUtils' +import { indent } from '../../shared/utilities/textUtilities' +import path from 'path' +import { isIamConnection } from '../../auth/connection' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' +import { LanguageClient } from 'vscode-languageclient' + +/** + * This class is for getRecommendation/listRecommendation API calls and its states + * It does not contain UI/UX related logic + */ + +/** + * Commands as a level of indirection so that declare doesn't intercept any registrations for the + * language server implementation. + * + * Otherwise you'll get: + * "Unable to launch amazonq language server: Command "aws.amazonq.rejectCodeSuggestion" has already been declared by the Toolkit" + */ +function createCommands() { + // below commands override VS Code inline completion commands + const prevCommand = Commands.declare('editor.action.inlineSuggest.showPrevious', () => async () => { + await RecommendationHandler.instance.showRecommendation(-1) + }) + const nextCommand = Commands.declare('editor.action.inlineSuggest.showNext', () => async () => { + await RecommendationHandler.instance.showRecommendation(1) + }) + + const rejectCommand = Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => { + telemetry.record({ + traceId: TelemetryHelper.instance.traceId, + }) + + await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') + RecommendationHandler.instance.reportUserDecisions(-1) + await Commands.tryExecute('aws.amazonq.refreshAnnotation') + }) + + return { + prevCommand, + nextCommand, + rejectCommand, + } +} + +const lock = new AsyncLock({ maxPending: 1 }) + +export class RecommendationHandler { + public lastInvocationTime: number + // TODO: remove this requestId + public requestId: string + private nextToken: string + private cancellationToken: vscode.CancellationTokenSource + private _onDidReceiveRecommendation: vscode.EventEmitter = new vscode.EventEmitter() + public readonly onDidReceiveRecommendation: vscode.Event = this._onDidReceiveRecommendation.event + private inlineCompletionProvider?: CWInlineCompletionItemProvider + private inlineCompletionProviderDisposable?: vscode.Disposable + private reject: vscode.Disposable + private next: vscode.Disposable + private prev: vscode.Disposable + private _timer?: NodeJS.Timer + private languageClient?: LanguageClient + documentUri: vscode.Uri | undefined = undefined + + constructor() { + this.requestId = '' + this.nextToken = '' + this.lastInvocationTime = performance.now() - CodeWhispererConstants.invocationTimeIntervalThreshold * 1000 + this.cancellationToken = new vscode.CancellationTokenSource() + this.prev = new vscode.Disposable(() => {}) + this.next = new vscode.Disposable(() => {}) + this.reject = new vscode.Disposable(() => {}) + } + + static #instance: RecommendationHandler + + public static get instance() { + return (this.#instance ??= new this()) + } + + isValidResponse(): boolean { + return session.recommendations.some((r) => r.content.trim() !== '') + } + + setLanguageClient(languageClient: LanguageClient) { + this.languageClient = languageClient + } + + async getServerResponse( + triggerType: CodewhispererTriggerType, + isManualTriggerOn: boolean, + promise: Promise + ): Promise { + const timeoutMessage = hasVendedIamCredentials() + ? 'Generate recommendation timeout.' + : 'List recommendation timeout' + if (isManualTriggerOn && triggerType === 'OnDemand' && hasVendedIamCredentials()) { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: CodeWhispererConstants.pendingResponse, + cancellable: false, + }, + async () => { + return await asyncCallWithTimeout( + promise, + timeoutMessage, + CodeWhispererConstants.promiseTimeoutLimit * 1000 + ) + } + ) + } + return await asyncCallWithTimeout(promise, timeoutMessage, CodeWhispererConstants.promiseTimeoutLimit * 1000) + } + + async getTaskTypeFromEditorFileName(filePath: string): Promise { + if (filePath.includes('CodeWhisperer_generate_suggestion')) { + return 'autoTrigger' + } else if (filePath.includes('CodeWhisperer_manual_invoke')) { + return 'manualTrigger' + } else if (filePath.includes('CodeWhisperer_use_comments')) { + return 'commentAsPrompt' + } else if (filePath.includes('CodeWhisperer_navigate_suggestions')) { + return 'navigation' + } else if (filePath.includes('Generate_unit_tests')) { + return 'unitTest' + } else { + return undefined + } + } + + async getRecommendations( + client: DefaultCodeWhispererClient, + editor: vscode.TextEditor, + triggerType: CodewhispererTriggerType, + config: ConfigurationEntry, + autoTriggerType?: CodewhispererAutomatedTriggerType, + pagination: boolean = true, + page: number = 0, + generate: boolean = isIamConnection(AuthUtil.instance.conn) + ): Promise { + let invocationResult: 'Succeeded' | 'Failed' = 'Failed' + let errorMessage: string | undefined = undefined + let errorCode: string | undefined = undefined + + if (!editor) { + return Promise.resolve({ + result: invocationResult, + errorMessage: errorMessage, + recommendationCount: 0, + }) + } + let recommendations: RecommendationsList = [] + let requestId = '' + let sessionId = '' + let reason = '' + let startTime = 0 + let latency = 0 + let nextToken = '' + let shouldRecordServiceInvocation = true + session.language = runtimeLanguageContext.getLanguageContext( + editor.document.languageId, + path.extname(editor.document.fileName) + ).language + session.taskType = await this.getTaskTypeFromEditorFileName(editor.document.fileName) + + if (pagination && !generate) { + if (page === 0) { + session.requestContext = await EditorContext.buildListRecommendationRequest( + editor as vscode.TextEditor, + this.nextToken, + config.isSuggestionsWithCodeReferencesEnabled, + this.languageClient + ) + } else { + session.requestContext = { + request: { + ...session.requestContext.request, + // Putting nextToken assignment in the end so it overwrites the existing nextToken + nextToken: this.nextToken, + }, + supplementalMetadata: session.requestContext.supplementalMetadata, + } + } + } else { + session.requestContext = await EditorContext.buildGenerateRecommendationRequest(editor as vscode.TextEditor) + } + const request = session.requestContext.request + // record preprocessing end time + TelemetryHelper.instance.setPreprocessEndTime() + + // set start pos for non pagination call or first pagination call + if (!pagination || (pagination && page === 0)) { + session.startPos = editor.selection.active + session.startCursorOffset = editor.document.offsetAt(session.startPos) + session.leftContextOfCurrentLine = EditorContext.getLeftContext(editor, session.startPos.line) + session.triggerType = triggerType + session.autoTriggerType = autoTriggerType + + /** + * Validate request + */ + if (!EditorContext.validateRequest(request)) { + getLogger().verbose('Invalid Request: %O', request) + const languageName = request.fileContext.programmingLanguage.languageName + if (!runtimeLanguageContext.isLanguageSupported(languageName)) { + errorMessage = `${languageName} is currently not supported by Amazon Q inline suggestions` + } + return Promise.resolve({ + result: invocationResult, + errorMessage: errorMessage, + recommendationCount: 0, + }) + } + } + + try { + startTime = performance.now() + this.lastInvocationTime = startTime + const mappedReq = runtimeLanguageContext.mapToRuntimeLanguage(request) + const codewhispererPromise = + pagination && !generate + ? client.listRecommendations(mappedReq) + : client.generateRecommendations(mappedReq) + const resp = await this.getServerResponse(triggerType, config.isManualTriggerEnabled, codewhispererPromise) + TelemetryHelper.instance.setSdkApiCallEndTime() + latency = startTime !== 0 ? performance.now() - startTime : 0 + if ('recommendations' in resp) { + recommendations = (resp && resp.recommendations) || [] + } else { + recommendations = (resp && resp.completions) || [] + } + invocationResult = 'Succeeded' + requestId = resp?.$response && resp?.$response?.requestId + nextToken = resp?.nextToken ? resp?.nextToken : '' + sessionId = resp?.$response?.httpResponse?.headers['x-amzn-sessionid'] + TelemetryHelper.instance.setFirstResponseRequestId(requestId) + if (page === 0) { + session.setTimeToFirstRecommendation(performance.now()) + } + if (nextToken === '') { + TelemetryHelper.instance.setAllPaginationEndTime() + } + } catch (error) { + if (error instanceof CognitoCredentialsError) { + shouldRecordServiceInvocation = false + } + if (latency === 0) { + latency = startTime !== 0 ? performance.now() - startTime : 0 + } + getLogger().error('amazonq inline-suggest: Invocation Exception : %s', (error as Error).message) + if (isAwsError(error)) { + errorMessage = error.message + requestId = error.requestId || '' + errorCode = error.code + reason = `CodeWhisperer Invocation Exception: ${error?.code ?? error?.name ?? 'unknown'}` + await this.onThrottlingException(error, triggerType) + + if (error?.code === 'AccessDeniedException' && errorMessage?.includes('no identity-based policy')) { + getLogger().error('amazonq inline-suggest: AccessDeniedException : %s', (error as Error).message) + void vscode.window + .showErrorMessage(`CodeWhisperer: ${error?.message}`, CodeWhispererConstants.settingsLearnMore) + .then(async (resp) => { + if (resp === CodeWhispererConstants.settingsLearnMore) { + void openUrl(vscode.Uri.parse(CodeWhispererConstants.learnMoreUri)) + } + }) + await vscode.commands.executeCommand('aws.amazonq.enableCodeSuggestions', false) + } + } else { + errorMessage = error instanceof Error ? error.message : String(error) + reason = error ? String(error) : 'unknown' + } + } finally { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + + let msg = indent( + `codewhisperer: request-id: ${requestId}, + timestamp(epoch): ${Date.now()}, + timezone: ${timezone}, + datetime: ${new Date().toLocaleString([], { timeZone: timezone })}, + vscode version: '${vscode.version}', + extension version: '${extensionVersion}', + filename: '${EditorContext.getFileName(editor)}', + left context of line: '${session.leftContextOfCurrentLine}', + line number: ${session.startPos.line}, + character location: ${session.startPos.character}, + latency: ${latency} ms. + Recommendations:`, + 4, + true + ).trimStart() + for (const [index, item] of recommendations.entries()) { + msg += `\n ${index.toString().padStart(2, '0')}: ${indent(item.content, 8, true).trim()}` + session.requestIdList.push(requestId) + } + getLogger().debug(msg) + if (invocationResult === 'Succeeded') { + CodeWhispererCodeCoverageTracker.getTracker(session.language)?.incrementServiceInvocationCount() + UserWrittenCodeTracker.instance.onQFeatureInvoked() + } else { + if ( + (errorMessage?.includes(invalidCustomizationMessage) && errorCode === 'AccessDeniedException') || + errorCode === 'ResourceNotFoundException' + ) { + getLogger() + .debug(`The selected customization is no longer available. Retrying with the default model. + Failed request id: ${requestId}`) + await switchToBaseCustomizationAndNotify() + await this.getRecommendations( + client, + editor, + triggerType, + config, + autoTriggerType, + pagination, + page, + true + ) + } + } + + if (shouldRecordServiceInvocation) { + TelemetryHelper.instance.recordServiceInvocationTelemetry( + requestId, + sessionId, + session.recommendations.length + recommendations.length - 1, + invocationResult, + latency, + session.language, + session.taskType, + reason, + session.requestContext.supplementalMetadata + ) + } + } + + if (this.isCancellationRequested()) { + return Promise.resolve({ + result: invocationResult, + errorMessage: errorMessage, + recommendationCount: session.recommendations.length, + }) + } + + const typedPrefix = editor.document + .getText(new vscode.Range(session.startPos, editor.selection.active)) + .replace('\r\n', '\n') + if (recommendations.length > 0) { + TelemetryHelper.instance.setTypeAheadLength(typedPrefix.length) + // mark suggestions that does not match typeahead when arrival as Discard + // these suggestions can be marked as Showed if typeahead can be removed with new inline API + for (const [i, r] of recommendations.entries()) { + const recommendationIndex = i + session.recommendations.length + if ( + !r.content.startsWith(typedPrefix) && + session.getSuggestionState(recommendationIndex) === undefined + ) { + session.setSuggestionState(recommendationIndex, 'Discard') + } + session.setCompletionType(recommendationIndex, r) + } + session.recommendations = pagination ? session.recommendations.concat(recommendations) : recommendations + if (isInlineCompletionEnabled() && this.hasAtLeastOneValidSuggestion(typedPrefix)) { + this._onDidReceiveRecommendation.fire() + } + } + + this.requestId = requestId + session.sessionId = sessionId + this.nextToken = nextToken + + // send Empty userDecision event if user receives no recommendations in this session at all. + if (invocationResult === 'Succeeded' && nextToken === '') { + // case 1: empty list of suggestion [] + if (session.recommendations.length === 0) { + session.requestIdList.push(requestId) + // Received an empty list of recommendations + TelemetryHelper.instance.recordUserDecisionTelemetryForEmptyList( + session.requestIdList, + sessionId, + page, + runtimeLanguageContext.getLanguageContext( + editor.document.languageId, + path.extname(editor.document.fileName) + ).language, + session.requestContext.supplementalMetadata + ) + } + // case 2: non empty list of suggestion but with (a) empty content or (b) non-matching typeahead + else if (!this.hasAtLeastOneValidSuggestion(typedPrefix)) { + this.reportUserDecisions(-1) + } + } + return Promise.resolve({ + result: invocationResult, + errorMessage: errorMessage, + recommendationCount: session.recommendations.length, + }) + } + + hasAtLeastOneValidSuggestion(typedPrefix: string): boolean { + return session.recommendations.some((r) => r.content.trim() !== '' && r.content.startsWith(typedPrefix)) + } + + cancelPaginatedRequest() { + this.nextToken = '' + this.cancellationToken.cancel() + } + + isCancellationRequested() { + return this.cancellationToken.token.isCancellationRequested + } + + checkAndResetCancellationTokens() { + if (this.isCancellationRequested()) { + this.cancellationToken.dispose() + this.cancellationToken = new vscode.CancellationTokenSource() + this.nextToken = '' + return true + } + return false + } + /** + * Clear recommendation state + */ + clearRecommendations() { + session.requestIdList = [] + session.recommendations = [] + session.suggestionStates = new Map() + session.completionTypes = new Map() + this.requestId = '' + session.sessionId = '' + this.nextToken = '' + session.requestContext.supplementalMetadata = undefined + } + + async clearInlineCompletionStates() { + try { + vsCodeState.isCodeWhispererEditing = false + application()._clearCodeWhispererUIListener.fire() + this.cancelPaginatedRequest() + this.clearRecommendations() + this.disposeInlineCompletion() + await vscode.commands.executeCommand('aws.amazonq.refreshStatusBar') + // fix a regression that requires user to hit Esc twice to clear inline ghost text + // because disposing a provider does not clear the UX + if (isVscHavingRegressionInlineCompletionApi()) { + await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') + } + } finally { + this.clearRejectionTimer() + } + } + + reportDiscardedUserDecisions() { + for (const [i, _] of session.recommendations.entries()) { + session.setSuggestionState(i, 'Discard') + } + this.reportUserDecisions(-1) + } + + /** + * Emits telemetry reflecting user decision for current recommendation. + */ + reportUserDecisions(acceptIndex: number) { + if (session.sessionId === '' || this.requestId === '') { + return + } + TelemetryHelper.instance.recordUserDecisionTelemetry( + session.requestIdList, + session.sessionId, + session.recommendations, + acceptIndex, + session.recommendations.length, + session.completionTypes, + session.suggestionStates, + session.requestContext.supplementalMetadata + ) + if (isInlineCompletionEnabled()) { + this.clearInlineCompletionStates().catch((e) => { + getLogger().error('clearInlineCompletionStates failed: %s', (e as Error).message) + }) + } + } + + hasNextToken(): boolean { + return this.nextToken !== '' + } + + canShowRecommendationInIntelliSense( + editor: vscode.TextEditor, + showPrompt: boolean = false, + response: GetRecommendationsResponse + ): boolean { + const reject = () => { + this.reportUserDecisions(-1) + } + if (!this.isValidResponse()) { + if (showPrompt) { + void showTimedMessage(response.errorMessage ? response.errorMessage : noSuggestions, 3000) + } + reject() + return false + } + // do not show recommendation if cursor is before invocation position + // also mark as Discard + if (editor.selection.active.isBefore(session.startPos)) { + for (const [i, _] of session.recommendations.entries()) { + session.setSuggestionState(i, 'Discard') + } + reject() + return false + } + + // do not show recommendation if typeahead does not match + // also mark as Discard + const typedPrefix = editor.document.getText( + new vscode.Range( + session.startPos.line, + session.startPos.character, + editor.selection.active.line, + editor.selection.active.character + ) + ) + if (!session.recommendations[0].content.startsWith(typedPrefix.trimStart())) { + for (const [i, _] of session.recommendations.entries()) { + session.setSuggestionState(i, 'Discard') + } + reject() + return false + } + return true + } + + async onThrottlingException(awsError: AWSError, triggerType: CodewhispererTriggerType) { + if ( + awsError.code === 'ThrottlingException' && + awsError.message.includes(CodeWhispererConstants.throttlingMessage) + ) { + if (triggerType === 'OnDemand') { + void vscode.window.showErrorMessage(CodeWhispererConstants.freeTierLimitReached) + } + vsCodeState.isFreeTierLimitReached = true + } + } + + public disposeInlineCompletion() { + this.inlineCompletionProviderDisposable?.dispose() + this.inlineCompletionProvider = undefined + } + + private disposeCommandOverrides() { + this.prev.dispose() + this.reject.dispose() + this.next.dispose() + } + + // These commands override the vs code inline completion commands + // They are subscribed when suggestion starts and disposed when suggestion is accepted/rejected + // to avoid impacting other plugins or user who uses this API + private registerCommandOverrides() { + const { prevCommand, nextCommand, rejectCommand } = createCommands() + this.prev = prevCommand.register() + this.next = nextCommand.register() + this.reject = rejectCommand.register() + } + + subscribeSuggestionCommands() { + this.disposeCommandOverrides() + this.registerCommandOverrides() + globals.context.subscriptions.push(this.prev) + globals.context.subscriptions.push(this.next) + globals.context.subscriptions.push(this.reject) + } + + async showRecommendation(indexShift: number, noSuggestionVisible: boolean = false) { + await lock.acquire(updateInlineLockKey, async () => { + if (!vscode.window.state.focused) { + this.reportDiscardedUserDecisions() + return + } + const inlineCompletionProvider = new CWInlineCompletionItemProvider( + this.inlineCompletionProvider?.getActiveItemIndex, + indexShift, + session.recommendations, + this.requestId, + session.startPos, + this.nextToken + ) + this.inlineCompletionProviderDisposable?.dispose() + // when suggestion is active, registering a new provider will let VS Code invoke inline API automatically + this.inlineCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider( + Object.assign([], CodeWhispererConstants.platformLanguageIds), + inlineCompletionProvider + ) + this.inlineCompletionProvider = inlineCompletionProvider + + if (isVscHavingRegressionInlineCompletionApi() && !noSuggestionVisible) { + // fix a regression in new VS Code when disposing and re-registering + // a new provider does not auto refresh the inline suggestion widget + // by manually refresh it + await vscode.commands.executeCommand('editor.action.inlineSuggest.hide') + await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger') + } + if (noSuggestionVisible) { + await vscode.commands.executeCommand(`editor.action.inlineSuggest.trigger`) + this.sendPerceivedLatencyTelemetry() + } + }) + } + + async onEditorChange() { + this.reportUserDecisions(-1) + } + + async onFocusChange() { + this.reportUserDecisions(-1) + } + + async onCursorChange(e: vscode.TextEditorSelectionChangeEvent) { + // we do not want to reset the states for keyboard events because they can be typeahead + if ( + e.kind !== vscode.TextEditorSelectionChangeKind.Keyboard && + vscode.window.activeTextEditor === e.textEditor + ) { + application()._clearCodeWhispererUIListener.fire() + // when cursor change due to mouse movement we need to reset the active item index for inline + if (e.kind === vscode.TextEditorSelectionChangeKind.Mouse) { + this.inlineCompletionProvider?.clearActiveItemIndex() + } + } + } + + isSuggestionVisible(): boolean { + return this.inlineCompletionProvider?.getActiveItemIndex !== undefined + } + + async tryShowRecommendation() { + const editor = vscode.window.activeTextEditor + if (editor === undefined) { + return + } + if (this.isSuggestionVisible()) { + // do not force refresh the tooltip to avoid suggestion "flashing" + return + } + if ( + editor.selection.active.isBefore(session.startPos) || + editor.document.uri.fsPath !== this.documentUri?.fsPath + ) { + for (const [i, _] of session.recommendations.entries()) { + session.setSuggestionState(i, 'Discard') + } + this.reportUserDecisions(-1) + } else if (session.recommendations.length > 0) { + await this.showRecommendation(0, true) + } + } + + private clearRejectionTimer() { + if (this._timer !== undefined) { + clearInterval(this._timer) + this._timer = undefined + } + } + + private sendPerceivedLatencyTelemetry() { + if (vscode.window.activeTextEditor) { + const languageContext = runtimeLanguageContext.getLanguageContext( + vscode.window.activeTextEditor.document.languageId, + vscode.window.activeTextEditor.document.fileName.substring( + vscode.window.activeTextEditor.document.fileName.lastIndexOf('.') + 1 + ) + ) + telemetry.codewhisperer_perceivedLatency.emit({ + codewhispererRequestId: this.requestId, + codewhispererSessionId: session.sessionId, + codewhispererTriggerType: session.triggerType, + codewhispererCompletionType: session.getCompletionType(0), + codewhispererCustomizationArn: getSelectedCustomization().arn, + codewhispererLanguage: languageContext.language, + duration: performance.now() - this.lastInvocationTime, + passive: true, + credentialStartUrl: AuthUtil.instance.startUrl, + result: 'Succeeded', + }) + } + } +} diff --git a/packages/core/src/codewhisperer/service/recommendationService.ts b/packages/core/src/codewhisperer/service/recommendationService.ts new file mode 100644 index 00000000000..de78b435913 --- /dev/null +++ b/packages/core/src/codewhisperer/service/recommendationService.ts @@ -0,0 +1,122 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { ConfigurationEntry, GetRecommendationsResponse } from '../models/model' +import { isInlineCompletionEnabled } from '../util/commonUtil' +import { + CodewhispererAutomatedTriggerType, + CodewhispererTriggerType, + telemetry, +} from '../../shared/telemetry/telemetry' +import { InlineCompletionService } from '../service/inlineCompletionService' +import { ClassifierTrigger } from './classifierTrigger' +import { DefaultCodeWhispererClient } from '../client/codewhisperer' +import { randomUUID } from '../../shared/crypto' +import { TelemetryHelper } from '../util/telemetryHelper' +import { AuthUtil } from '../util/authUtil' + +export interface SuggestionActionEvent { + readonly editor: vscode.TextEditor | undefined + readonly isRunning: boolean + readonly triggerType: CodewhispererTriggerType + readonly response: GetRecommendationsResponse | undefined +} + +export class RecommendationService { + static #instance: RecommendationService + + private _isRunning: boolean = false + get isRunning() { + return this._isRunning + } + + private _onSuggestionActionEvent = new vscode.EventEmitter() + get suggestionActionEvent(): vscode.Event { + return this._onSuggestionActionEvent.event + } + + private _acceptedSuggestionCount: number = 0 + get acceptedSuggestionCount() { + return this._acceptedSuggestionCount + } + + private _totalValidTriggerCount: number = 0 + get totalValidTriggerCount() { + return this._totalValidTriggerCount + } + + public static get instance() { + return (this.#instance ??= new RecommendationService()) + } + + incrementAcceptedCount() { + this._acceptedSuggestionCount++ + } + + incrementValidTriggerCount() { + this._totalValidTriggerCount++ + } + + async generateRecommendation( + client: DefaultCodeWhispererClient, + editor: vscode.TextEditor, + triggerType: CodewhispererTriggerType, + config: ConfigurationEntry, + autoTriggerType?: CodewhispererAutomatedTriggerType, + event?: vscode.TextDocumentChangeEvent + ) { + // TODO: should move all downstream auth check(inlineCompletionService, recommendationHandler etc) to here(upstream) instead of spreading everywhere + if (AuthUtil.instance.isConnected() && AuthUtil.instance.requireProfileSelection()) { + return + } + + if (this._isRunning) { + return + } + + /** + * Use an existing trace ID if invoked through a command (e.g., manual invocation), + * otherwise generate a new trace ID + */ + const traceId = telemetry.attributes?.traceId ?? randomUUID() + TelemetryHelper.instance.setTraceId(traceId) + await telemetry.withTraceId(async () => { + if (isInlineCompletionEnabled()) { + if (triggerType === 'OnDemand') { + ClassifierTrigger.instance.recordClassifierResultForManualTrigger(editor) + } + + this._isRunning = true + let response: GetRecommendationsResponse | undefined = undefined + + try { + this._onSuggestionActionEvent.fire({ + editor: editor, + isRunning: true, + triggerType: triggerType, + response: undefined, + }) + + response = await InlineCompletionService.instance.getPaginatedRecommendation( + client, + editor, + triggerType, + config, + autoTriggerType, + event + ) + } finally { + this._isRunning = false + this._onSuggestionActionEvent.fire({ + editor: editor, + isRunning: false, + triggerType: triggerType, + response: response, + }) + } + } + }, traceId) + } +} diff --git a/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts b/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts new file mode 100644 index 00000000000..0989f022245 --- /dev/null +++ b/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts @@ -0,0 +1,319 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { getLogger } from '../../shared/logger/logger' +import * as CodeWhispererConstants from '../models/constants' +import globals from '../../shared/extensionGlobals' +import { vsCodeState } from '../models/model' +import { CodewhispererLanguage, telemetry } from '../../shared/telemetry/telemetry' +import { runtimeLanguageContext } from '../util/runtimeLanguageContext' +import { TelemetryHelper } from '../util/telemetryHelper' +import { AuthUtil } from '../util/authUtil' +import { getSelectedCustomization } from '../util/customizationUtil' +import { codeWhispererClient as client } from '../client/codewhisperer' +import { isAwsError } from '../../shared/errors' +import { getUnmodifiedAcceptedTokens } from '../util/commonUtil' + +interface CodeWhispererToken { + range: vscode.Range + text: string + accepted: number +} + +const autoClosingKeystrokeInputs = ['[]', '{}', '()', '""', "''"] + +/** + * This singleton class is mainly used for calculating the code written by codeWhisperer + * TODO: Remove this tracker, uses user written code tracker instead. + * This is kept in codebase for server side backward compatibility until service fully switch to user written code + */ +export class CodeWhispererCodeCoverageTracker { + private _acceptedTokens: { [key: string]: CodeWhispererToken[] } + private _totalTokens: { [key: string]: number } + private _timer?: NodeJS.Timer + private _startTime: number + private _language: CodewhispererLanguage + private _serviceInvocationCount: number + + private constructor(language: CodewhispererLanguage) { + this._acceptedTokens = {} + this._totalTokens = {} + this._startTime = 0 + this._language = language + this._serviceInvocationCount = 0 + } + + public get serviceInvocationCount(): number { + return this._serviceInvocationCount + } + + public get acceptedTokens(): { [key: string]: CodeWhispererToken[] } { + return this._acceptedTokens + } + + public get totalTokens(): { [key: string]: number } { + return this._totalTokens + } + + public isActive(): boolean { + return TelemetryHelper.instance.isTelemetryEnabled() && AuthUtil.instance.isConnected() + } + + public incrementServiceInvocationCount() { + this._serviceInvocationCount += 1 + } + + public flush() { + if (!this.isActive()) { + this._totalTokens = {} + this._acceptedTokens = {} + this.closeTimer() + return + } + try { + this.emitCodeWhispererCodeContribution() + } catch (error) { + getLogger().error(`Encountered ${error} when emitting code contribution metric`) + } + } + + // TODO: Improve the range tracking of the accepted recommendation + // TODO: use the editor of the filename, not the current editor + public updateAcceptedTokensCount(editor: vscode.TextEditor) { + const filename = editor.document.fileName + if (filename in this._acceptedTokens) { + for (let i = 0; i < this._acceptedTokens[filename].length; i++) { + const oldText = this._acceptedTokens[filename][i].text + const newText = editor.document.getText(this._acceptedTokens[filename][i].range) + this._acceptedTokens[filename][i].accepted = getUnmodifiedAcceptedTokens(oldText, newText) + } + } + } + + public emitCodeWhispererCodeContribution() { + let totalTokens = 0 + for (const filename in this._totalTokens) { + totalTokens += this._totalTokens[filename] + } + if (vscode.window.activeTextEditor) { + this.updateAcceptedTokensCount(vscode.window.activeTextEditor) + } + // the accepted characters without counting user modification + let acceptedTokens = 0 + // the accepted characters after calculating user modification + let unmodifiedAcceptedTokens = 0 + for (const filename in this._acceptedTokens) { + for (const v of this._acceptedTokens[filename]) { + if (filename in this._totalTokens && this._totalTokens[filename] >= v.accepted) { + unmodifiedAcceptedTokens += v.accepted + acceptedTokens += v.text.length + } + } + } + const percentCount = ((acceptedTokens / totalTokens) * 100).toFixed(2) + const percentage = Math.round(parseInt(percentCount)) + const selectedCustomization = getSelectedCustomization() + if (this._serviceInvocationCount <= 0) { + getLogger().debug(`Skip emiting code contribution metric`) + return + } + telemetry.codewhisperer_codePercentage.emit({ + codewhispererTotalTokens: totalTokens, + codewhispererLanguage: this._language, + codewhispererAcceptedTokens: unmodifiedAcceptedTokens, + codewhispererSuggestedTokens: acceptedTokens, + codewhispererPercentage: percentage ? percentage : 0, + successCount: this._serviceInvocationCount, + codewhispererCustomizationArn: selectedCustomization.arn === '' ? undefined : selectedCustomization.arn, + credentialStartUrl: AuthUtil.instance.startUrl, + }) + + client + .sendTelemetryEvent({ + telemetryEvent: { + codeCoverageEvent: { + customizationArn: selectedCustomization.arn === '' ? undefined : selectedCustomization.arn, + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(this._language), + }, + acceptedCharacterCount: acceptedTokens, + unmodifiedAcceptedCharacterCount: unmodifiedAcceptedTokens, + totalCharacterCount: totalTokens, + timestamp: new Date(Date.now()), + }, + }, + profileArn: AuthUtil.instance.regionProfileManager.activeRegionProfile?.arn, + }) + .then() + .catch((error) => { + let requestId: string | undefined + if (isAwsError(error)) { + requestId = error.requestId + } + + getLogger().debug( + `Failed to sendTelemetryEvent to CodeWhisperer, requestId: ${requestId ?? ''}, message: ${ + error.message + }` + ) + }) + } + + private tryStartTimer() { + if (this._timer !== undefined) { + return + } + const currentDate = new globals.clock.Date() + this._startTime = currentDate.getTime() + this._timer = setTimeout(() => { + try { + const currentTime = new globals.clock.Date().getTime() + const delay: number = CodeWhispererConstants.defaultCheckPeriodMillis + const diffTime: number = this._startTime + delay + if (diffTime <= currentTime) { + let totalTokens = 0 + for (const filename in this._totalTokens) { + totalTokens += this._totalTokens[filename] + } + if (totalTokens > 0) { + this.flush() + } else { + getLogger().debug( + `CodeWhispererCodeCoverageTracker: skipped telemetry due to empty tokens array` + ) + } + } + } catch (e) { + getLogger().verbose(`Exception Thrown from CodeWhispererCodeCoverageTracker: ${e}`) + } finally { + this.resetTracker() + this.closeTimer() + } + }, CodeWhispererConstants.defaultCheckPeriodMillis) + } + + private resetTracker() { + this._totalTokens = {} + this._acceptedTokens = {} + this._startTime = 0 + this._serviceInvocationCount = 0 + } + + private closeTimer() { + if (this._timer !== undefined) { + clearTimeout(this._timer) + this._timer = undefined + } + } + + public addAcceptedTokens(filename: string, token: CodeWhispererToken) { + if (!(filename in this._acceptedTokens)) { + this._acceptedTokens[filename] = [] + } + this._acceptedTokens[filename].push(token) + } + + public addTotalTokens(filename: string, count: number) { + if (!(filename in this._totalTokens)) { + this._totalTokens[filename] = 0 + } + this._totalTokens[filename] += count + if (this._totalTokens[filename] < 0) { + this._totalTokens[filename] = 0 + } + } + + public countAcceptedTokens(range: vscode.Range, text: string, filename: string) { + if (!this.isActive()) { + return + } + // generate accepted recommendation token and stored in collection + this.addAcceptedTokens(filename, { range: range, text: text, accepted: text.length }) + this.addTotalTokens(filename, text.length) + } + + // For below 2 edge cases + // 1. newline character with indentation + // 2. 2 character insertion of closing brackets + public getCharacterCountFromComplexEvent(e: vscode.TextDocumentChangeEvent) { + function countChanges(cond: boolean, text: string): number { + if (!cond) { + return 0 + } + if ((text.startsWith('\n') || text.startsWith('\r\n')) && text.trim().length === 0) { + return 1 + } + if (autoClosingKeystrokeInputs.includes(text)) { + return 2 + } + return 0 + } + if (e.contentChanges.length === 2) { + const text1 = e.contentChanges[0].text + const text2 = e.contentChanges[1].text + const text2Count = countChanges(text1.length === 0, text2) + const text1Count = countChanges(text2.length === 0, text1) + return text2Count > 0 ? text2Count : text1Count + } else if (e.contentChanges.length === 1) { + return countChanges(true, e.contentChanges[0].text) + } + return 0 + } + + public isFromUserKeystroke(e: vscode.TextDocumentChangeEvent) { + return e.contentChanges.length === 1 && e.contentChanges[0].text.length === 1 + } + + public countTotalTokens(e: vscode.TextDocumentChangeEvent) { + // ignore no contentChanges. ignore contentChanges from other plugins (formatters) + // only include contentChanges from user keystroke input(one character input). + // Also ignore deletion events due to a known issue of tracking deleted CodeWhiperer tokens. + if (!runtimeLanguageContext.isLanguageSupported(e.document.languageId) || vsCodeState.isCodeWhispererEditing) { + return + } + // a user keystroke input can be + // 1. content change with 1 character insertion + // 2. newline character with indentation + // 3. 2 character insertion of closing brackets + if (this.isFromUserKeystroke(e)) { + this.tryStartTimer() + this.addTotalTokens(e.document.fileName, 1) + } else if (this.getCharacterCountFromComplexEvent(e) !== 0) { + this.tryStartTimer() + const characterIncrease = this.getCharacterCountFromComplexEvent(e) + this.addTotalTokens(e.document.fileName, characterIncrease) + } + // also include multi character input within 50 characters (not from CWSPR) + else if ( + e.contentChanges.length === 1 && + e.contentChanges[0].text.length > 1 && + TelemetryHelper.instance.lastSuggestionInDisplay !== e.contentChanges[0].text + ) { + const multiCharInputSize = e.contentChanges[0].text.length + + // select 50 as the cut-off threshold for counting user input. + // ignore all white space multi char input, this usually comes from reformat. + if (multiCharInputSize < 50 && e.contentChanges[0].text.trim().length > 0) { + this.addTotalTokens(e.document.fileName, multiCharInputSize) + } + } + } + + public static readonly instances = new Map() + + public static getTracker(language: string): CodeWhispererCodeCoverageTracker | undefined { + if (!runtimeLanguageContext.isLanguageSupported(language)) { + return undefined + } + const cwsprLanguage = runtimeLanguageContext.normalizeLanguage(language) + if (!cwsprLanguage) { + return undefined + } + const instance = this.instances.get(cwsprLanguage) ?? new this(cwsprLanguage) + this.instances.set(cwsprLanguage, instance) + return instance + } +} diff --git a/packages/core/src/codewhisperer/util/commonUtil.ts b/packages/core/src/codewhisperer/util/commonUtil.ts index 729d3b7ed12..d2df78f1369 100644 --- a/packages/core/src/codewhisperer/util/commonUtil.ts +++ b/packages/core/src/codewhisperer/util/commonUtil.ts @@ -3,18 +3,80 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as vscode from 'vscode' +import * as semver from 'semver' import { distance } from 'fastest-levenshtein' import { getInlineSuggestEnabled } from '../../shared/utilities/editorUtilities' +import { + AWSTemplateCaseInsensitiveKeyWords, + AWSTemplateKeyWords, + JsonConfigFileNamingConvention, +} from '../models/constants' export function getLocalDatetime() { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone return new Date().toLocaleString([], { timeZone: timezone }) } +export function asyncCallWithTimeout(asyncPromise: Promise, message: string, timeLimit: number): Promise { + let timeoutHandle: NodeJS.Timeout + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(message)), timeLimit) + }) + return Promise.race([asyncPromise, timeoutPromise]).then((result) => { + clearTimeout(timeoutHandle) + return result as T + }) +} + export function isInlineCompletionEnabled() { return getInlineSuggestEnabled() } +// This is the VS Code version that started to have regressions in inline completion API +export function isVscHavingRegressionInlineCompletionApi() { + return semver.gte(vscode.version, '1.78.0') && getInlineSuggestEnabled() +} + +export function getFileExt(languageId: string) { + switch (languageId) { + case 'java': + return '.java' + case 'python': + return '.py' + default: + break + } + return undefined +} + +/** + * Returns the longest overlap between the Suffix of firstString and Prefix of second string + * getPrefixSuffixOverlap("adwg31", "31ggrs") = "31" + */ +export function getPrefixSuffixOverlap(firstString: string, secondString: string) { + let i = Math.min(firstString.length, secondString.length) + while (i > 0) { + if (secondString.slice(0, i) === firstString.slice(-i)) { + break + } + i-- + } + return secondString.slice(0, i) +} + +export function checkLeftContextKeywordsForJson(fileName: string, leftFileContent: string, language: string): boolean { + if ( + language === 'json' && + !AWSTemplateKeyWords.some((substring) => leftFileContent.includes(substring)) && + !AWSTemplateCaseInsensitiveKeyWords.some((substring) => leftFileContent.toLowerCase().includes(substring)) && + !JsonConfigFileNamingConvention.has(fileName.toLowerCase()) + ) { + return true + } + return false +} + // With edit distance, complicate usermodification can be considered as simple edit(add, delete, replace), // and thus the unmodified part of recommendation length can be deducted/approximated // ex. (modified > original): originalRecom: foo -> modifiedRecom: fobarbarbaro, distance = 9, delta = 12 - 9 = 3 diff --git a/packages/core/src/codewhisperer/util/editorContext.ts b/packages/core/src/codewhisperer/util/editorContext.ts new file mode 100644 index 00000000000..dacf3b326a1 --- /dev/null +++ b/packages/core/src/codewhisperer/util/editorContext.ts @@ -0,0 +1,427 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as codewhispererClient from '../client/codewhisperer' +import * as path from 'path' +import * as CodeWhispererConstants from '../models/constants' +import { getTabSizeSetting } from '../../shared/utilities/editorUtilities' +import { truncate } from '../../shared/utilities/textUtilities' +import { getLogger } from '../../shared/logger/logger' +import { runtimeLanguageContext } from './runtimeLanguageContext' +import { fetchSupplementalContext } from './supplementalContext/supplementalContextUtil' +import { editorStateMaxLength, supplementalContextTimeoutInMs } from '../models/constants' +import { getSelectedCustomization } from './customizationUtil' +import { selectFrom } from '../../shared/utilities/tsUtils' +import { checkLeftContextKeywordsForJson } from './commonUtil' +import { CodeWhispererSupplementalContext } from '../models/model' +import { getOptOutPreference } from '../../shared/telemetry/util' +import { indent } from '../../shared/utilities/textUtilities' +import { isInDirectory } from '../../shared/filesystemUtilities' +import { AuthUtil } from './authUtil' +import { predictionTracker } from '../nextEditPrediction/activation' +import { LanguageClient } from 'vscode-languageclient' + +let tabSize: number = getTabSizeSetting() + +function getEnclosingNotebook(editor: vscode.TextEditor): vscode.NotebookDocument | undefined { + // For notebook cells, find the existing notebook with a cell that matches the current editor. + return vscode.workspace.notebookDocuments.find( + (nb) => + nb.notebookType === 'jupyter-notebook' && nb.getCells().some((cell) => cell.document === editor.document) + ) +} + +export function getNotebookContext( + notebook: vscode.NotebookDocument, + editor: vscode.TextEditor, + languageName: string, + caretLeftFileContext: string, + caretRightFileContext: string +) { + // Expand the context for a cell inside of a noteboo with whatever text fits from the preceding and subsequent cells + const allCells = notebook.getCells() + const cellIndex = allCells.findIndex((cell) => cell.document === editor.document) + // Extract text from prior cells if there is enough room in left file context + if (caretLeftFileContext.length < CodeWhispererConstants.charactersLimit - 1) { + const leftCellsText = getNotebookCellsSliceContext( + allCells.slice(0, cellIndex), + CodeWhispererConstants.charactersLimit - (caretLeftFileContext.length + 1), + languageName, + true + ) + if (leftCellsText.length > 0) { + caretLeftFileContext = addNewlineIfMissing(leftCellsText) + caretLeftFileContext + } + } + // Extract text from subsequent cells if there is enough room in right file context + if (caretRightFileContext.length < CodeWhispererConstants.charactersLimit - 1) { + const rightCellsText = getNotebookCellsSliceContext( + allCells.slice(cellIndex + 1), + CodeWhispererConstants.charactersLimit - (caretRightFileContext.length + 1), + languageName, + false + ) + if (rightCellsText.length > 0) { + caretRightFileContext = addNewlineIfMissing(caretRightFileContext) + rightCellsText + } + } + return { caretLeftFileContext, caretRightFileContext } +} + +export function getNotebookCellContext(cell: vscode.NotebookCell, referenceLanguage?: string): string { + // Extract the text verbatim if the cell is code and the cell has the same language. + // Otherwise, add the correct comment string for the reference language + const cellText = cell.document.getText() + if ( + cell.kind === vscode.NotebookCellKind.Markup || + (runtimeLanguageContext.normalizeLanguage(cell.document.languageId) ?? cell.document.languageId) !== + referenceLanguage + ) { + const commentPrefix = runtimeLanguageContext.getSingleLineCommentPrefix(referenceLanguage) + if (commentPrefix === '') { + return cellText + } + return cell.document + .getText() + .split('\n') + .map((line) => `${commentPrefix}${line}`) + .join('\n') + } + return cellText +} + +export function getNotebookCellsSliceContext( + cells: vscode.NotebookCell[], + maxLength: number, + referenceLanguage: string, + fromStart: boolean +): string { + // Extract context from array of notebook cells that fits inside `maxLength` characters, + // from either the start or the end of the array. + let output: string[] = [] + if (!fromStart) { + cells = cells.reverse() + } + cells.some((cell) => { + const cellText = addNewlineIfMissing(getNotebookCellContext(cell, referenceLanguage)) + if (cellText.length > 0) { + if (cellText.length >= maxLength) { + if (fromStart) { + output.push(cellText.substring(0, maxLength)) + } else { + output.push(cellText.substring(cellText.length - maxLength)) + } + return true + } + output.push(cellText) + maxLength -= cellText.length + } + }) + if (!fromStart) { + output = output.reverse() + } + return output.join('') +} + +export function addNewlineIfMissing(text: string): string { + if (text.length > 0 && !text.endsWith('\n')) { + text += '\n' + } + return text +} + +export function extractContextForCodeWhisperer(editor: vscode.TextEditor): codewhispererClient.FileContext { + const document = editor.document + const curPos = editor.selection.active + const offset = document.offsetAt(curPos) + + let caretLeftFileContext = editor.document.getText( + new vscode.Range( + document.positionAt(offset - CodeWhispererConstants.charactersLimit), + document.positionAt(offset) + ) + ) + let caretRightFileContext = editor.document.getText( + new vscode.Range( + document.positionAt(offset), + document.positionAt(offset + CodeWhispererConstants.charactersLimit) + ) + ) + let languageName = 'plaintext' + if (!checkLeftContextKeywordsForJson(document.fileName, caretLeftFileContext, editor.document.languageId)) { + languageName = runtimeLanguageContext.resolveLang(editor.document) + } + if (editor.document.uri.scheme === 'vscode-notebook-cell') { + const notebook = getEnclosingNotebook(editor) + if (notebook) { + ;({ caretLeftFileContext, caretRightFileContext } = getNotebookContext( + notebook, + editor, + languageName, + caretLeftFileContext, + caretRightFileContext + )) + } + } + + return { + fileUri: editor.document.uri.toString().substring(0, CodeWhispererConstants.filenameCharsLimit), + filename: getFileRelativePath(editor), + programmingLanguage: { + languageName: languageName, + }, + leftFileContent: caretLeftFileContext, + rightFileContent: caretRightFileContext, + } as codewhispererClient.FileContext +} + +export function getFileName(editor: vscode.TextEditor): string { + const fileName = path.basename(editor.document.fileName) + return fileName.substring(0, CodeWhispererConstants.filenameCharsLimit) +} + +export function getFileRelativePath(editor: vscode.TextEditor): string { + const fileName = path.basename(editor.document.fileName) + let relativePath = '' + const workspaceFolder = vscode.workspace.getWorkspaceFolder(editor.document.uri) + if (!workspaceFolder) { + relativePath = fileName + } else { + const workspacePath = workspaceFolder.uri.fsPath + const filePath = editor.document.uri.fsPath + relativePath = path.relative(workspacePath, filePath) + } + // For notebook files, we want to use the programming language for each cell for the code suggestions, so change + // the filename sent in the request to reflect that language + if (relativePath.endsWith('.ipynb')) { + const fileExtension = runtimeLanguageContext.getLanguageExtensionForNotebook(editor.document.languageId) + if (fileExtension !== undefined) { + const filenameWithNewExtension = relativePath.substring(0, relativePath.length - 5) + fileExtension + return filenameWithNewExtension.substring(0, CodeWhispererConstants.filenameCharsLimit) + } + } + return relativePath.substring(0, CodeWhispererConstants.filenameCharsLimit) +} + +async function getWorkspaceId(editor: vscode.TextEditor): Promise { + try { + const workspaceIds: { workspaces: { workspaceRoot: string; workspaceId: string }[] } = + await vscode.commands.executeCommand('aws.amazonq.getWorkspaceId') + for (const item of workspaceIds.workspaces) { + const path = vscode.Uri.parse(item.workspaceRoot).fsPath + if (isInDirectory(path, editor.document.uri.fsPath)) { + return item.workspaceId + } + } + } catch (err) { + getLogger().warn(`No workspace id found ${err}`) + } + return undefined +} + +export async function buildListRecommendationRequest( + editor: vscode.TextEditor, + nextToken: string, + allowCodeWithReference: boolean, + languageClient?: LanguageClient +): Promise<{ + request: codewhispererClient.ListRecommendationsRequest + supplementalMetadata: CodeWhispererSupplementalContext | undefined +}> { + const fileContext = extractContextForCodeWhisperer(editor) + + const tokenSource = new vscode.CancellationTokenSource() + setTimeout(() => { + tokenSource.cancel() + }, supplementalContextTimeoutInMs) + + const supplementalContexts = await fetchSupplementalContext(editor, tokenSource.token, languageClient) + + logSupplementalContext(supplementalContexts) + + // Get predictionSupplementalContext from PredictionTracker + let predictionSupplementalContext: codewhispererClient.SupplementalContext[] = [] + if (predictionTracker) { + predictionSupplementalContext = await predictionTracker.generatePredictionSupplementalContext() + } + + const selectedCustomization = getSelectedCustomization() + const completionSupplementalContext: codewhispererClient.SupplementalContext[] = supplementalContexts + ? supplementalContexts.supplementalContextItems.map((v) => { + return selectFrom(v, 'content', 'filePath') + }) + : [] + + const profile = AuthUtil.instance.regionProfileManager.activeRegionProfile + + const editorState = getEditorState(editor, fileContext) + + // Combine inline and prediction supplemental contexts + const finalSupplementalContext = completionSupplementalContext.concat(predictionSupplementalContext) + return { + request: { + fileContext: fileContext, + nextToken: nextToken, + referenceTrackerConfiguration: { + recommendationsWithReferences: allowCodeWithReference ? 'ALLOW' : 'BLOCK', + }, + supplementalContexts: finalSupplementalContext, + editorState: editorState, + maxResults: CodeWhispererConstants.maxRecommendations, + customizationArn: selectedCustomization.arn === '' ? undefined : selectedCustomization.arn, + optOutPreference: getOptOutPreference(), + workspaceId: await getWorkspaceId(editor), + profileArn: profile?.arn, + }, + supplementalMetadata: supplementalContexts, + } +} + +export async function buildGenerateRecommendationRequest(editor: vscode.TextEditor): Promise<{ + request: codewhispererClient.GenerateRecommendationsRequest + supplementalMetadata: CodeWhispererSupplementalContext | undefined +}> { + const fileContext = extractContextForCodeWhisperer(editor) + + const tokenSource = new vscode.CancellationTokenSource() + // the supplement context fetch mechanisms each has a timeout of supplementalContextTimeoutInMs + // adding 10 ms for overall timeout as buffer + setTimeout(() => { + tokenSource.cancel() + }, supplementalContextTimeoutInMs + 10) + const supplementalContexts = await fetchSupplementalContext(editor, tokenSource.token) + + logSupplementalContext(supplementalContexts) + + return { + request: { + fileContext: fileContext, + maxResults: CodeWhispererConstants.maxRecommendations, + supplementalContexts: supplementalContexts?.supplementalContextItems ?? [], + }, + supplementalMetadata: supplementalContexts, + } +} + +export function validateRequest( + req: codewhispererClient.ListRecommendationsRequest | codewhispererClient.GenerateRecommendationsRequest +): boolean { + const isLanguageNameValid = + req.fileContext.programmingLanguage.languageName !== undefined && + req.fileContext.programmingLanguage.languageName.length >= 1 && + req.fileContext.programmingLanguage.languageName.length <= 128 && + (runtimeLanguageContext.isLanguageSupported(req.fileContext.programmingLanguage.languageName) || + runtimeLanguageContext.isFileFormatSupported( + req.fileContext.filename.substring(req.fileContext.filename.lastIndexOf('.') + 1) + )) + const isFileNameValid = !(req.fileContext.filename === undefined || req.fileContext.filename.length < 1) + const isFileContextValid = !( + req.fileContext.leftFileContent.length > CodeWhispererConstants.charactersLimit || + req.fileContext.rightFileContent.length > CodeWhispererConstants.charactersLimit + ) + if (isFileNameValid && isLanguageNameValid && isFileContextValid) { + return true + } + return false +} + +export function updateTabSize(val: number): void { + tabSize = val +} + +export function getTabSize(): number { + return tabSize +} + +export function getEditorState(editor: vscode.TextEditor, fileContext: codewhispererClient.FileContext): any { + try { + const cursorPosition = editor.selection.active + const cursorOffset = editor.document.offsetAt(cursorPosition) + const documentText = editor.document.getText() + + // Truncate if document content is too large (defined in constants.ts) + let fileText = documentText + if (documentText.length > editorStateMaxLength) { + const halfLength = Math.floor(editorStateMaxLength / 2) + + // Use truncate function to get the text around the cursor position + const leftPart = truncate(documentText.substring(0, cursorOffset), -halfLength, '') + const rightPart = truncate(documentText.substring(cursorOffset), halfLength, '') + + fileText = leftPart + rightPart + } + + return { + document: { + programmingLanguage: { + languageName: fileContext.programmingLanguage.languageName, + }, + relativeFilePath: fileContext.filename, + text: fileText, + }, + cursorState: { + position: { + line: editor.selection.active.line, + character: editor.selection.active.character, + }, + }, + } + } catch (error) { + getLogger().error(`Error generating editor state: ${error}`) + return undefined + } +} + +export function getLeftContext(editor: vscode.TextEditor, line: number): string { + let lineText = '' + try { + if (editor && editor.document.lineAt(line)) { + lineText = editor.document.lineAt(line).text + if (lineText.length > CodeWhispererConstants.contextPreviewLen) { + lineText = + '...' + + lineText.substring( + lineText.length - CodeWhispererConstants.contextPreviewLen - 1, + lineText.length - 1 + ) + } + } + } catch (error) { + getLogger().error(`Error when getting left context ${error}`) + } + + return lineText +} + +function logSupplementalContext(supplementalContext: CodeWhispererSupplementalContext | undefined) { + if (!supplementalContext) { + return + } + + let logString = indent( + `CodeWhispererSupplementalContext: + isUtg: ${supplementalContext.isUtg}, + isProcessTimeout: ${supplementalContext.isProcessTimeout}, + contentsLength: ${supplementalContext.contentsLength}, + latency: ${supplementalContext.latency} + strategy: ${supplementalContext.strategy}`, + 4, + true + ).trimStart() + + for (const [index, context] of supplementalContext.supplementalContextItems.entries()) { + logString += indent(`\nChunk ${index}:\n`, 4, true) + logString += indent( + `Path: ${context.filePath} + Length: ${context.content.length} + Score: ${context.score}`, + 8, + true + ) + } + + getLogger().debug(logString) +} diff --git a/packages/core/src/codewhisperer/util/globalStateUtil.ts b/packages/core/src/codewhisperer/util/globalStateUtil.ts new file mode 100644 index 00000000000..55376a83546 --- /dev/null +++ b/packages/core/src/codewhisperer/util/globalStateUtil.ts @@ -0,0 +1,23 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vsCodeState } from '../models/model' + +export function resetIntelliSenseState( + isManualTriggerEnabled: boolean, + isAutomatedTriggerEnabled: boolean, + hasResponse: boolean +) { + /** + * Skip when CodeWhisperer service is turned off + */ + if (!isManualTriggerEnabled && !isAutomatedTriggerEnabled) { + return + } + + if (vsCodeState.isIntelliSenseActive && hasResponse) { + vsCodeState.isIntelliSenseActive = false + } +} diff --git a/packages/core/src/codewhisperer/util/supplementalContext/codeParsingUtil.ts b/packages/core/src/codewhisperer/util/supplementalContext/codeParsingUtil.ts new file mode 100644 index 00000000000..c73a2eebaa4 --- /dev/null +++ b/packages/core/src/codewhisperer/util/supplementalContext/codeParsingUtil.ts @@ -0,0 +1,130 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import path = require('path') +import { normalize } from '../../../shared/utilities/pathUtils' + +// TODO: functionExtractionPattern, classExtractionPattern, imposrtStatementRegex are not scalable and we will deprecate and remove the usage in the near future +export interface utgLanguageConfig { + extension: string + testFilenamePattern: RegExp[] + functionExtractionPattern?: RegExp + classExtractionPattern?: RegExp + importStatementRegExp?: RegExp +} + +export const utgLanguageConfigs: Record = { + // Java regexes are not working efficiently for class or function extraction + java: { + extension: '.java', + testFilenamePattern: [/^(.+)Test(\.java)$/, /(.+)Tests(\.java)$/, /Test(.+)(\.java)$/], + functionExtractionPattern: + /(?:(?:public|private|protected)\s+)(?:static\s+)?(?:[\w<>]+\s+)?(\w+)\s*\([^)]*\)\s*(?:(?:throws\s+\w+)?\s*)[{;]/gm, // TODO: Doesn't work for generice T functions. + classExtractionPattern: /(?<=^|\n)\s*public\s+class\s+(\w+)/gm, // TODO: Verify these. + importStatementRegExp: /import .*\.([a-zA-Z0-9]+);/, + }, + python: { + extension: '.py', + testFilenamePattern: [/^test_(.+)(\.py)$/, /^(.+)_test(\.py)$/], + functionExtractionPattern: /def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g, // Worked fine + classExtractionPattern: /^class\s+(\w+)\s*:/gm, + importStatementRegExp: /from (.*) import.*/, + }, + typescript: { + extension: '.ts', + testFilenamePattern: [/^(.+)\.test(\.ts|\.tsx)$/, /^(.+)\.spec(\.ts|\.tsx)$/], + }, + javascript: { + extension: '.js', + testFilenamePattern: [/^(.+)\.test(\.js|\.jsx)$/, /^(.+)\.spec(\.js|\.jsx)$/], + }, + typescriptreact: { + extension: '.tsx', + testFilenamePattern: [/^(.+)\.test(\.ts|\.tsx)$/, /^(.+)\.spec(\.ts|\.tsx)$/], + }, + javascriptreact: { + extension: '.jsx', + testFilenamePattern: [/^(.+)\.test(\.js|\.jsx)$/, /^(.+)\.spec(\.js|\.jsx)$/], + }, +} + +export function extractFunctions(fileContent: string, regex?: RegExp) { + if (!regex) { + return [] + } + const functionNames: string[] = [] + let match: RegExpExecArray | null + + while ((match = regex.exec(fileContent)) !== null) { + functionNames.push(match[1]) + } + return functionNames +} + +export function extractClasses(fileContent: string, regex?: RegExp) { + if (!regex) { + return [] + } + const classNames: string[] = [] + let match: RegExpExecArray | null + + while ((match = regex.exec(fileContent)) !== null) { + classNames.push(match[1]) + } + return classNames +} + +export function countSubstringMatches(arr1: string[], arr2: string[]): number { + let count = 0 + for (const str1 of arr1) { + for (const str2 of arr2) { + if (str2.toLowerCase().includes(str1.toLowerCase())) { + count++ + } + } + } + return count +} + +export async function isTestFile( + filePath: string, + languageConfig: { + languageId: vscode.TextDocument['languageId'] + fileContent?: string + } +): Promise { + const normalizedFilePath = normalize(filePath) + const pathContainsTest = + normalizedFilePath.includes('tests/') || + normalizedFilePath.includes('test/') || + normalizedFilePath.includes('tst/') + const fileNameMatchTestPatterns = isTestFileByName(normalizedFilePath, languageConfig.languageId) + + if (pathContainsTest || fileNameMatchTestPatterns) { + return true + } + + return false +} + +function isTestFileByName(filePath: string, language: vscode.TextDocument['languageId']): boolean { + const languageConfig = utgLanguageConfigs[language] + if (!languageConfig) { + // We have enabled the support only for python and Java for this check + // as we depend on Regex for this validation. + return false + } + const testFilenamePattern = languageConfig.testFilenamePattern + + const filename = path.basename(filePath) + for (const pattern of testFilenamePattern) { + if (pattern.test(filename)) { + return true + } + } + + return false +} diff --git a/packages/core/src/codewhisperer/util/supplementalContext/crossFileContextUtil.ts b/packages/core/src/codewhisperer/util/supplementalContext/crossFileContextUtil.ts new file mode 100644 index 00000000000..17dc594cde9 --- /dev/null +++ b/packages/core/src/codewhisperer/util/supplementalContext/crossFileContextUtil.ts @@ -0,0 +1,407 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import path = require('path') +import { BM25Document, BM25Okapi } from './rankBm25' +import { + crossFileContextConfig, + supplementalContextTimeoutInMs, + supplementalContextMaxTotalLength, +} from '../../models/constants' +import { isTestFile } from './codeParsingUtil' +import { getFileDistance } from '../../../shared/filesystemUtilities' +import { getOpenFilesInWindow } from '../../../shared/utilities/editorUtilities' +import { getLogger } from '../../../shared/logger/logger' +import { + CodeWhispererSupplementalContext, + CodeWhispererSupplementalContextItem, + SupplementalContextStrategy, +} from '../../models/model' +import { waitUntil } from '../../../shared/utilities/timeoutUtils' +import { FeatureConfigProvider } from '../../../shared/featureConfig' +import fs from '../../../shared/fs/fs' +import { LanguageClient } from 'vscode-languageclient' + +import { + GetSupplementalContextParams, + getSupplementalContextRequestType, + SupplementalContextItem, +} from '@aws/language-server-runtimes/protocol' +type CrossFileSupportedLanguage = + | 'java' + | 'python' + | 'javascript' + | 'typescript' + | 'javascriptreact' + | 'typescriptreact' + +// TODO: ugly, can we make it prettier? like we have to manually type 'java', 'javascriptreact' which is error prone +// TODO: Move to another config file or constants file +// Supported language to its corresponding file ext +const supportedLanguageToDialects: Readonly>> = { + java: new Set(['.java']), + python: new Set(['.py']), + javascript: new Set(['.js', '.jsx']), + javascriptreact: new Set(['.js', '.jsx']), + typescript: new Set(['.ts', '.tsx']), + typescriptreact: new Set(['.ts', '.tsx']), +} + +function isCrossFileSupported(languageId: string): languageId is CrossFileSupportedLanguage { + return Object.keys(supportedLanguageToDialects).includes(languageId) +} + +interface Chunk { + fileName: string + content: string + nextContent: string + score?: number +} + +/** + * `none`: supplementalContext is not supported + * `opentabs`: opentabs_BM25 + * `codemap`: repomap + opentabs BM25 + * `bm25`: global_BM25 + * `default`: repomap + global_BM25 + */ +type SupplementalContextConfig = 'none' | 'opentabs' | 'codemap' | 'bm25' | 'default' + +export async function fetchSupplementalContextForSrc( + editor: vscode.TextEditor, + cancellationToken: vscode.CancellationToken, + languageClient?: LanguageClient +): Promise | undefined> { + const supplementalContextConfig = getSupplementalContextConfig(editor.document.languageId) + + // not supported case + if (supplementalContextConfig === 'none') { + return undefined + } + + // fallback to opentabs if projectContext timeout + const opentabsContextPromise = waitUntil( + async function () { + return await fetchOpentabsContext(editor, cancellationToken) + }, + { timeout: supplementalContextTimeoutInMs, interval: 5, truthy: false } + ) + + // opentabs context will use bm25 and users' open tabs to fetch supplemental context + if (supplementalContextConfig === 'opentabs') { + const supContext = (await opentabsContextPromise) ?? [] + return { + supplementalContextItems: supContext, + strategy: supContext.length === 0 ? 'empty' : 'opentabs', + } + } + + // codemap will use opentabs context plus repomap if it's present + if (supplementalContextConfig === 'codemap') { + let strategy: SupplementalContextStrategy = 'empty' + let hasCodemap: boolean = false + let hasOpentabs: boolean = false + const opentabsContextAndCodemap = await waitUntil( + async function () { + const result: CodeWhispererSupplementalContextItem[] = [] + const opentabsContext = await fetchOpentabsContext(editor, cancellationToken) + const codemap = await fetchProjectContext(editor, 'codemap', languageClient) + + function addToResult(items: CodeWhispererSupplementalContextItem[]) { + for (const item of items) { + const curLen = result.reduce((acc, i) => acc + i.content.length, 0) + if (curLen + item.content.length < supplementalContextMaxTotalLength) { + result.push(item) + } + } + } + + if (codemap && codemap.length > 0) { + addToResult(codemap) + hasCodemap = true + } + + if (opentabsContext && opentabsContext.length > 0) { + addToResult(opentabsContext) + hasOpentabs = true + } + + return result + }, + { timeout: supplementalContextTimeoutInMs, interval: 5, truthy: false } + ) + + if (hasCodemap) { + strategy = 'codemap' + } else if (hasOpentabs) { + strategy = 'opentabs' + } else { + strategy = 'empty' + } + + return { + supplementalContextItems: opentabsContextAndCodemap ?? [], + strategy: strategy, + } + } + + // global bm25 without repomap + if (supplementalContextConfig === 'bm25') { + const projectBM25Promise = waitUntil( + async function () { + return await fetchProjectContext(editor, 'bm25', languageClient) + }, + { timeout: supplementalContextTimeoutInMs, interval: 5, truthy: false } + ) + + const [projectContext, opentabsContext] = await Promise.all([projectBM25Promise, opentabsContextPromise]) + if (projectContext && projectContext.length > 0) { + return { + supplementalContextItems: projectContext, + strategy: 'bm25', + } + } + + const supContext = opentabsContext ?? [] + return { + supplementalContextItems: supContext, + strategy: supContext.length === 0 ? 'empty' : 'opentabs', + } + } + + // global bm25 with repomap + const projectContextAndCodemapPromise = waitUntil( + async function () { + return await fetchProjectContext(editor, 'default', languageClient) + }, + { timeout: supplementalContextTimeoutInMs, interval: 5, truthy: false } + ) + + const [projectContext, opentabsContext] = await Promise.all([ + projectContextAndCodemapPromise, + opentabsContextPromise, + ]) + if (projectContext && projectContext.length > 0) { + return { + supplementalContextItems: projectContext, + strategy: 'default', + } + } + + return { + supplementalContextItems: opentabsContext ?? [], + strategy: 'opentabs', + } +} + +export async function fetchProjectContext( + editor: vscode.TextEditor, + target: 'default' | 'codemap' | 'bm25', + languageclient?: LanguageClient +): Promise { + try { + if (languageclient) { + const request: GetSupplementalContextParams = { + filePath: editor.document.uri.fsPath, + } + const response = await languageclient.sendRequest( + getSupplementalContextRequestType.method, + request + ) + return response as CodeWhispererSupplementalContextItem[] + } + } catch (error) { + return [] + } + return [] +} + +export async function fetchOpentabsContext( + editor: vscode.TextEditor, + cancellationToken: vscode.CancellationToken +): Promise { + const codeChunksCalculated = crossFileContextConfig.numberOfChunkToFetch + + // Step 1: Get relevant cross files to refer + const relevantCrossFilePaths = await getCrossFileCandidates(editor) + + // Step 2: Split files to chunks with upper bound on chunkCount + // We restrict the total number of chunks to improve on latency. + // Chunk linking is required as we want to pass the next chunk value for matched chunk. + let chunkList: Chunk[] = [] + for (const relevantFile of relevantCrossFilePaths) { + const chunks: Chunk[] = await splitFileToChunks(relevantFile, crossFileContextConfig.numberOfLinesEachChunk) + const linkedChunks = linkChunks(chunks) + chunkList.push(...linkedChunks) + if (chunkList.length >= codeChunksCalculated) { + break + } + } + + // it's required since chunkList.push(...) is likely giving us a list of size > 60 + chunkList = chunkList.slice(0, codeChunksCalculated) + + // Step 3: Generate Input chunk (10 lines left of cursor position) + // and Find Best K chunks w.r.t input chunk using BM25 + const inputChunk: Chunk = getInputChunk(editor) + const bestChunks: Chunk[] = findBestKChunkMatches(inputChunk, chunkList, crossFileContextConfig.topK) + + // Step 4: Transform best chunks to supplemental contexts + const supplementalContexts: CodeWhispererSupplementalContextItem[] = [] + let totalLength = 0 + for (const chunk of bestChunks) { + totalLength += chunk.nextContent.length + + if (totalLength > crossFileContextConfig.maximumTotalLength) { + break + } + + supplementalContexts.push({ + filePath: chunk.fileName, + content: chunk.nextContent, + score: chunk.score, + }) + } + + // DO NOT send code chunk with empty content + getLogger().debug(`CodeWhisperer finished fetching crossfile context out of ${relevantCrossFilePaths.length} files`) + return supplementalContexts +} + +function findBestKChunkMatches(chunkInput: Chunk, chunkReferences: Chunk[], k: number): Chunk[] { + const chunkContentList = chunkReferences.map((chunk) => chunk.content) + + // performBM25Scoring returns the output in a sorted order (descending of scores) + const top3: BM25Document[] = new BM25Okapi(chunkContentList).topN(chunkInput.content, crossFileContextConfig.topK) + + return top3.map((doc) => { + // reference to the original metadata since BM25.top3 will sort the result + const chunkIndex = doc.index + const chunkReference = chunkReferences[chunkIndex] + return { + content: chunkReference.content, + fileName: chunkReference.fileName, + nextContent: chunkReference.nextContent, + score: doc.score, + } + }) +} + +/* This extract 10 lines to the left of the cursor from trigger file. + * This will be the inputquery to bm25 matching against list of cross-file chunks + */ +function getInputChunk(editor: vscode.TextEditor) { + const chunkSize = crossFileContextConfig.numberOfLinesEachChunk + const cursorPosition = editor.selection.active + const startLine = Math.max(cursorPosition.line - chunkSize, 0) + const endLine = Math.max(cursorPosition.line - 1, 0) + const inputChunkContent = editor.document.getText( + new vscode.Range(startLine, 0, endLine, editor.document.lineAt(endLine).text.length) + ) + const inputChunk: Chunk = { fileName: editor.document.fileName, content: inputChunkContent, nextContent: '' } + return inputChunk +} + +/** + * Util to decide if we need to fetch crossfile context since CodeWhisperer CrossFile Context feature is gated by userGroup and language level + * @param languageId: VSCode language Identifier + * @returns specifically returning undefined if the langueage is not supported, + * otherwise true/false depending on if the language is fully supported or not belonging to the user group + */ +function getSupplementalContextConfig(languageId: vscode.TextDocument['languageId']): SupplementalContextConfig { + if (!isCrossFileSupported(languageId)) { + return 'none' + } + + const group = FeatureConfigProvider.instance.getProjectContextGroup() + switch (group) { + default: + return 'codemap' + } +} + +/** + * This linking is required from science experimentations to pass the next contnet chunk + * when a given chunk context passes the match in BM25. + * Special handling is needed for last(its next points to its own) and first chunk + */ +export function linkChunks(chunks: Chunk[]) { + const updatedChunks: Chunk[] = [] + + // This additional chunk is needed to create a next pointer to chunk 0. + const firstChunk = chunks[0] + const firstChunkSubContent = firstChunk.content.split('\n').slice(0, 3).join('\n').trimEnd() + const newFirstChunk = { + fileName: firstChunk.fileName, + content: firstChunkSubContent, + nextContent: firstChunk.content, + } + updatedChunks.push(newFirstChunk) + + const n = chunks.length + for (let i = 0; i < n; i++) { + const chunk = chunks[i] + const nextChunk = i < n - 1 ? chunks[i + 1] : chunk + + chunk.nextContent = nextChunk.content + updatedChunks.push(chunk) + } + + return updatedChunks +} + +export async function splitFileToChunks(filePath: string, chunkSize: number): Promise { + const chunks: Chunk[] = [] + + const fileContent = (await fs.readFileText(filePath)).trimEnd() + const lines = fileContent.split('\n') + + for (let i = 0; i < lines.length; i += chunkSize) { + const chunkContent = lines.slice(i, Math.min(i + chunkSize, lines.length)).join('\n') + const chunk = { fileName: filePath, content: chunkContent.trimEnd(), nextContent: '' } + chunks.push(chunk) + } + return chunks +} + +/** + * This function will return relevant cross files sorted by file distance for the given editor file + * by referencing open files, imported files and same package files. + */ +export async function getCrossFileCandidates(editor: vscode.TextEditor): Promise { + const targetFile = editor.document.uri.fsPath + const language = editor.document.languageId as CrossFileSupportedLanguage + const dialects = supportedLanguageToDialects[language] + + /** + * Consider a file which + * 1. is different from the target + * 2. has the same file extension or it's one of the dialect of target file (e.g .js vs. .jsx) + * 3. is not a test file + */ + const unsortedCandidates = await getOpenFilesInWindow(async (candidateFile) => { + return ( + targetFile !== candidateFile && + (path.extname(targetFile) === path.extname(candidateFile) || + (dialects && dialects.has(path.extname(candidateFile)))) && + !(await isTestFile(candidateFile, { languageId: language })) + ) + }) + + return unsortedCandidates + .map((candidate) => { + return { + file: candidate, + fileDistance: getFileDistance(targetFile, candidate), + } + }) + .sort((file1, file2) => { + return file1.fileDistance - file2.fileDistance + }) + .map((fileToDistance) => { + return fileToDistance.file + }) +} diff --git a/packages/core/src/codewhisperer/util/supplementalContext/rankBm25.ts b/packages/core/src/codewhisperer/util/supplementalContext/rankBm25.ts new file mode 100644 index 00000000000..a2c77e0b10f --- /dev/null +++ b/packages/core/src/codewhisperer/util/supplementalContext/rankBm25.ts @@ -0,0 +1,137 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Implementation inspired by https://github.com/dorianbrown/rank_bm25/blob/990470ebbe6b28c18216fd1a8b18fe7446237dd6/rank_bm25.py#L52 + +export interface BM25Document { + content: string + /** The score that the document receives. */ + score: number + + index: number +} + +export abstract class BM25 { + protected readonly corpusSize: number + protected readonly avgdl: number + protected readonly idf: Map = new Map() + protected readonly docLen: number[] = [] + protected readonly docFreqs: Map[] = [] + protected readonly nd: Map = new Map() + + constructor( + protected readonly corpus: string[], + protected readonly tokenizer: (str: string) => string[] = defaultTokenizer, + protected readonly k1: number, + protected readonly b: number, + protected readonly epsilon: number + ) { + this.corpusSize = corpus.length + + let numDoc = 0 + for (const document of corpus.map((document) => { + return tokenizer(document) + })) { + this.docLen.push(document.length) + numDoc += document.length + + const frequencies = new Map() + for (const word of document) { + frequencies.set(word, (frequencies.get(word) || 0) + 1) + } + this.docFreqs.push(frequencies) + + for (const [word, _] of frequencies.entries()) { + this.nd.set(word, (this.nd.get(word) || 0) + 1) + } + } + + this.avgdl = numDoc / this.corpusSize + + this.calIdf(this.nd) + } + + abstract calIdf(nd: Map): void + + abstract score(query: string): BM25Document[] + + topN(query: string, n: number): BM25Document[] { + const notSorted = this.score(query) + const sorted = notSorted.sort((a, b) => b.score - a.score) + return sorted.slice(0, Math.min(n, sorted.length)) + } +} + +export class BM25Okapi extends BM25 { + constructor(corpus: string[], tokenizer: (str: string) => string[] = defaultTokenizer) { + super(corpus, tokenizer, 1.5, 0.75, 0.25) + } + + calIdf(nd: Map): void { + let idfSum = 0 + + const negativeIdfs: string[] = [] + for (const [word, freq] of nd) { + const idf = Math.log(this.corpusSize - freq + 0.5) - Math.log(freq + 0.5) + this.idf.set(word, idf) + idfSum += idf + + if (idf < 0) { + negativeIdfs.push(word) + } + } + + const averageIdf = idfSum / this.idf.size + const eps = this.epsilon * averageIdf + for (const word of negativeIdfs) { + this.idf.set(word, eps) + } + } + + score(query: string): BM25Document[] { + const queryWords = defaultTokenizer(query) + return this.docFreqs.map((docFreq, index) => { + let score = 0 + for (const [_, queryWord] of queryWords.entries()) { + const queryWordFreqForDocument = docFreq.get(queryWord) || 0 + const numerator = (this.idf.get(queryWord) || 0.0) * queryWordFreqForDocument * (this.k1 + 1) + const denominator = + queryWordFreqForDocument + this.k1 * (1 - this.b + (this.b * this.docLen[index]) / this.avgdl) + + score += numerator / denominator + } + + return { + content: this.corpus[index], + score: score, + index: index, + } + }) + } +} + +// TODO: This is a very simple tokenizer, we want to replace this by more sophisticated one. +function defaultTokenizer(content: string): string[] { + const regex = /\w+/g + const words = content.split(' ') + const result = [] + for (const word of words) { + const wordList = findAll(word, regex) + result.push(...wordList) + } + + return result +} + +function findAll(str: string, re: RegExp): string[] { + let match: RegExpExecArray | null + const matches: string[] = [] + + while ((match = re.exec(str)) !== null) { + matches.push(match[0]) + } + + return matches +} diff --git a/packages/core/src/codewhisperer/util/supplementalContext/supplementalContextUtil.ts b/packages/core/src/codewhisperer/util/supplementalContext/supplementalContextUtil.ts new file mode 100644 index 00000000000..a21bf113b82 --- /dev/null +++ b/packages/core/src/codewhisperer/util/supplementalContext/supplementalContextUtil.ts @@ -0,0 +1,139 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fetchSupplementalContextForTest } from './utgUtils' +import { fetchSupplementalContextForSrc } from './crossFileContextUtil' +import { isTestFile } from './codeParsingUtil' +import * as vscode from 'vscode' +import { CancellationError } from '../../../shared/utilities/timeoutUtils' +import { ToolkitError } from '../../../shared/errors' +import { getLogger } from '../../../shared/logger/logger' +import { CodeWhispererSupplementalContext } from '../../models/model' +import * as os from 'os' +import { crossFileContextConfig } from '../../models/constants' +import { LanguageClient } from 'vscode-languageclient' + +export async function fetchSupplementalContext( + editor: vscode.TextEditor, + cancellationToken: vscode.CancellationToken, + languageClient?: LanguageClient +): Promise { + const timesBeforeFetching = performance.now() + + const isUtg = await isTestFile(editor.document.uri.fsPath, { + languageId: editor.document.languageId, + fileContent: editor.document.getText(), + }) + + let supplementalContextPromise: Promise< + Pick | undefined + > + + if (isUtg) { + supplementalContextPromise = fetchSupplementalContextForTest(editor, cancellationToken) + } else { + supplementalContextPromise = fetchSupplementalContextForSrc(editor, cancellationToken, languageClient) + } + + return supplementalContextPromise + .then((value) => { + if (value) { + const resBeforeTruncation = { + isUtg: isUtg, + isProcessTimeout: false, + supplementalContextItems: value.supplementalContextItems.filter( + (item) => item.content.trim().length !== 0 + ), + contentsLength: value.supplementalContextItems.reduce((acc, curr) => acc + curr.content.length, 0), + latency: performance.now() - timesBeforeFetching, + strategy: value.strategy, + } + + return truncateSuppelementalContext(resBeforeTruncation) + } else { + return undefined + } + }) + .catch((err) => { + if (err instanceof ToolkitError && err.cause instanceof CancellationError) { + return { + isUtg: isUtg, + isProcessTimeout: true, + supplementalContextItems: [], + contentsLength: 0, + latency: performance.now() - timesBeforeFetching, + strategy: 'empty', + } + } else { + getLogger().error( + `Fail to fetch supplemental context for target file ${editor.document.fileName}: ${err}` + ) + return undefined + } + }) +} + +/** + * Requirement + * - Maximum 5 supplemental context. + * - Each chunk can't exceed 10240 characters + * - Sum of all chunks can't exceed 20480 characters + */ +export function truncateSuppelementalContext( + context: CodeWhispererSupplementalContext +): CodeWhispererSupplementalContext { + let c = context.supplementalContextItems.map((item) => { + if (item.content.length > crossFileContextConfig.maxLengthEachChunk) { + return { + ...item, + content: truncateLineByLine(item.content, crossFileContextConfig.maxLengthEachChunk), + } + } else { + return item + } + }) + + if (c.length > crossFileContextConfig.maxContextCount) { + c = c.slice(0, crossFileContextConfig.maxContextCount) + } + + let curTotalLength = c.reduce((acc, cur) => { + return acc + cur.content.length + }, 0) + while (curTotalLength >= 20480 && c.length - 1 >= 0) { + const last = c[c.length - 1] + c = c.slice(0, -1) + curTotalLength -= last.content.length + } + + return { + ...context, + supplementalContextItems: c, + contentsLength: curTotalLength, + } +} + +export function truncateLineByLine(input: string, l: number): string { + const maxLength = l > 0 ? l : -1 * l + if (input.length === 0) { + return '' + } + + const shouldAddNewLineBack = input.endsWith(os.EOL) + let lines = input.trim().split(os.EOL) + let curLen = input.length + while (curLen > maxLength && lines.length - 1 >= 0) { + const last = lines[lines.length - 1] + lines = lines.slice(0, -1) + curLen -= last.length + 1 + } + + const r = lines.join(os.EOL) + if (shouldAddNewLineBack) { + return r + os.EOL + } else { + return r + } +} diff --git a/packages/core/src/codewhisperer/util/supplementalContext/utgUtils.ts b/packages/core/src/codewhisperer/util/supplementalContext/utgUtils.ts new file mode 100644 index 00000000000..0d33969773e --- /dev/null +++ b/packages/core/src/codewhisperer/util/supplementalContext/utgUtils.ts @@ -0,0 +1,229 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'path' +import { fs } from '../../../shared/fs/fs' +import * as vscode from 'vscode' +import { + countSubstringMatches, + extractClasses, + extractFunctions, + isTestFile, + utgLanguageConfig, + utgLanguageConfigs, +} from './codeParsingUtil' +import { ToolkitError } from '../../../shared/errors' +import { supplemetalContextFetchingTimeoutMsg } from '../../models/constants' +import { CancellationError } from '../../../shared/utilities/timeoutUtils' +import { utgConfig } from '../../models/constants' +import { getOpenFilesInWindow } from '../../../shared/utilities/editorUtilities' +import { getLogger } from '../../../shared/logger/logger' +import { CodeWhispererSupplementalContext, CodeWhispererSupplementalContextItem, UtgStrategy } from '../../models/model' + +const utgSupportedLanguages: vscode.TextDocument['languageId'][] = ['java', 'python'] + +type UtgSupportedLanguage = (typeof utgSupportedLanguages)[number] + +function isUtgSupportedLanguage(languageId: vscode.TextDocument['languageId']): languageId is UtgSupportedLanguage { + return utgSupportedLanguages.includes(languageId) +} + +export function shouldFetchUtgContext(languageId: vscode.TextDocument['languageId']): boolean | undefined { + if (!isUtgSupportedLanguage(languageId)) { + return undefined + } + + return languageId === 'java' +} + +/** + * This function attempts to find a focal file for the given trigger file. + * Attempt 1: If naming patterns followed correctly, source file can be found by name referencing. + * Attempt 2: Compare the function and class names of trigger file and all other open files in editor + * to find the closest match. + * Once found the focal file, we split it into multiple pieces as supplementalContext. + * @param editor + * @returns + */ +export async function fetchSupplementalContextForTest( + editor: vscode.TextEditor, + cancellationToken: vscode.CancellationToken +): Promise | undefined> { + const shouldProceed = shouldFetchUtgContext(editor.document.languageId) + + if (!shouldProceed) { + return shouldProceed === undefined ? undefined : { supplementalContextItems: [], strategy: 'empty' } + } + + const languageConfig = utgLanguageConfigs[editor.document.languageId] + + // TODO (Metrics): 1. Total number of calls to fetchSupplementalContextForTest + throwIfCancelled(cancellationToken) + + let crossSourceFile = await findSourceFileByName(editor, languageConfig, cancellationToken) + if (crossSourceFile) { + // TODO (Metrics): 2. Success count for fetchSourceFileByName (find source file by name) + getLogger().debug(`CodeWhisperer finished fetching utg context by file name`) + return { + supplementalContextItems: await generateSupplementalContextFromFocalFile( + crossSourceFile, + 'byName', + cancellationToken + ), + strategy: 'byName', + } + } + throwIfCancelled(cancellationToken) + + crossSourceFile = await findSourceFileByContent(editor, languageConfig, cancellationToken) + if (crossSourceFile) { + // TODO (Metrics): 3. Success count for fetchSourceFileByContent (find source file by content) + getLogger().debug(`CodeWhisperer finished fetching utg context by file content`) + return { + supplementalContextItems: await generateSupplementalContextFromFocalFile( + crossSourceFile, + 'byContent', + cancellationToken + ), + strategy: 'byContent', + } + } + + // TODO (Metrics): 4. Failure count - when unable to find focal file (supplemental context empty) + getLogger().debug(`CodeWhisperer failed to fetch utg context`) + return { + supplementalContextItems: [], + strategy: 'empty', + } +} + +async function generateSupplementalContextFromFocalFile( + filePath: string, + strategy: UtgStrategy, + cancellationToken: vscode.CancellationToken +): Promise { + const fileContent = await fs.readFileText(vscode.Uri.parse(filePath!).fsPath) + + // DO NOT send code chunk with empty content + if (fileContent.trim().length === 0) { + return [] + } + + return [ + { + filePath: filePath, + content: 'UTG\n' + fileContent.slice(0, Math.min(fileContent.length, utgConfig.maxSegmentSize)), + }, + ] +} + +async function findSourceFileByContent( + editor: vscode.TextEditor, + languageConfig: utgLanguageConfig, + cancellationToken: vscode.CancellationToken +): Promise { + const testFileContent = await fs.readFileText(editor.document.fileName) + const testElementList = extractFunctions(testFileContent, languageConfig.functionExtractionPattern) + + throwIfCancelled(cancellationToken) + + testElementList.push(...extractClasses(testFileContent, languageConfig.classExtractionPattern)) + + throwIfCancelled(cancellationToken) + + let sourceFilePath: string | undefined = undefined + let maxMatchCount = 0 + + if (testElementList.length === 0) { + // TODO: Add metrics here, as unable to parse test file using Regex. + return sourceFilePath + } + + const relevantFilePaths = await getRelevantUtgFiles(editor) + + throwIfCancelled(cancellationToken) + + // TODO (Metrics):Add metrics for relevantFilePaths length + for (const filePath of relevantFilePaths) { + throwIfCancelled(cancellationToken) + + const fileContent = await fs.readFileText(filePath) + const elementList = extractFunctions(fileContent, languageConfig.functionExtractionPattern) + elementList.push(...extractClasses(fileContent, languageConfig.classExtractionPattern)) + const matchCount = countSubstringMatches(elementList, testElementList) + if (matchCount > maxMatchCount) { + maxMatchCount = matchCount + sourceFilePath = filePath + } + } + return sourceFilePath +} + +async function getRelevantUtgFiles(editor: vscode.TextEditor): Promise { + const targetFile = editor.document.uri.fsPath + const language = editor.document.languageId + + return await getOpenFilesInWindow(async (candidateFile) => { + return ( + targetFile !== candidateFile && + path.extname(targetFile) === path.extname(candidateFile) && + !(await isTestFile(candidateFile, { languageId: language })) + ) + }) +} + +export function guessSrcFileName( + testFileName: string, + languageId: vscode.TextDocument['languageId'] +): string | undefined { + const languageConfig = utgLanguageConfigs[languageId] + if (!languageConfig) { + return undefined + } + + for (const pattern of languageConfig.testFilenamePattern) { + try { + const match = testFileName.match(pattern) + if (match) { + return match[1] + match[2] + } + } catch (err) { + if (err instanceof Error) { + getLogger().error( + `codewhisperer: error while guessing source file name from file ${testFileName} and pattern ${pattern}: ${err.message}` + ) + } + } + } + + return undefined +} + +async function findSourceFileByName( + editor: vscode.TextEditor, + languageConfig: utgLanguageConfig, + cancellationToken: vscode.CancellationToken +): Promise { + const testFileName = path.basename(editor.document.fileName) + const assumedSrcFileName = guessSrcFileName(testFileName, editor.document.languageId) + if (!assumedSrcFileName) { + return undefined + } + + const sourceFiles = await vscode.workspace.findFiles(`**/${assumedSrcFileName}`) + + throwIfCancelled(cancellationToken) + + if (sourceFiles.length > 0) { + return sourceFiles[0].toString() + } + return undefined +} + +function throwIfCancelled(token: vscode.CancellationToken): void | never { + if (token.isCancellationRequested) { + throw new ToolkitError(supplemetalContextFetchingTimeoutMsg, { cause: new CancellationError('timeout') }) + } +} diff --git a/packages/core/src/codewhisperer/util/telemetryHelper.ts b/packages/core/src/codewhisperer/util/telemetryHelper.ts index 076cea19049..89c04afe572 100644 --- a/packages/core/src/codewhisperer/util/telemetryHelper.ts +++ b/packages/core/src/codewhisperer/util/telemetryHelper.ts @@ -5,9 +5,18 @@ import globals from '../../shared/extensionGlobals' import { runtimeLanguageContext } from './runtimeLanguageContext' -import { codeWhispererClient as client } from '../client/codewhisperer' -import { CodewhispererGettingStartedTask, CodewhispererLanguage, telemetry } from '../../shared/telemetry/telemetry' -import { CodewhispererCompletionType } from '../../shared/telemetry/telemetry' +import { codeWhispererClient as client, RecommendationsList } from '../client/codewhisperer' +import { LicenseUtil } from './licenseUtil' +import { + CodewhispererGettingStartedTask, + CodewhispererLanguage, + CodewhispererPreviousSuggestionState, + CodewhispererUserDecision, + CodewhispererUserTriggerDecision, + telemetry, +} from '../../shared/telemetry/telemetry' +import { CodewhispererCompletionType, CodewhispererSuggestionState } from '../../shared/telemetry/telemetry' +import { getImportCount } from './importAdderUtil' import { CodeWhispererSettings } from './codewhispererSettings' import { getSelectedCustomization } from './customizationUtil' import { AuthUtil } from './authUtil' @@ -15,8 +24,12 @@ import { isAwsError } from '../../shared/errors' import { getLogger } from '../../shared/logger/logger' import { session } from './codeWhispererSession' import { CodeWhispererSupplementalContext } from '../models/model' -import { CodeScanRemediationsEventType } from '../client/codewhispereruserclient' +import { FeatureConfigProvider } from '../../shared/featureConfig' +import CodeWhispererUserClient, { CodeScanRemediationsEventType } from '../client/codewhispereruserclient' import { CodeAnalysisScope as CodeAnalysisScopeClientSide } from '../models/constants' +import { sleep } from '../../shared/utilities/timeoutUtils' +import { getDiagnosticsDifferences, getDiagnosticsOfCurrentFile, toIdeDiagnostics } from './diagnosticsUtil' +import { Auth } from '../../auth/auth' export class TelemetryHelper { // Some variables for client component latency @@ -32,6 +45,16 @@ export class TelemetryHelper { get firstResponseRequestId(): string { return this._firstResponseRequestId } + // variables for user trigger decision + // these will be cleared after a invocation session + private sessionDecisions: CodewhispererUserTriggerDecision[] = [] + private triggerChar?: string = undefined + private prevTriggerDecision?: CodewhispererPreviousSuggestionState + private typeAheadLength = 0 + private timeSinceLastModification = 0 + private lastTriggerDecisionTime = 0 + private classifierResult?: number = undefined + private classifierThreshold?: number = undefined // variables for tracking end to end sessions public traceId: string = 'notSet' @@ -82,6 +105,434 @@ export class TelemetryHelper { telemetry.codewhisperer_serviceInvocation.emit(event) } + public recordUserDecisionTelemetryForEmptyList( + requestIdList: string[], + sessionId: string, + paginationIndex: number, + language: CodewhispererLanguage, + supplementalContextMetadata?: CodeWhispererSupplementalContext | undefined + ) { + const selectedCustomization = getSelectedCustomization() + const profile = AuthUtil.instance.regionProfileManager.activeRegionProfile + + telemetry.codewhisperer_userTriggerDecision.emit({ + codewhispererAutomatedTriggerType: session.autoTriggerType, + codewhispererClassifierResult: this.classifierResult, + codewhispererClassifierThreshold: this.classifierThreshold, + codewhispererCompletionType: 'Line', + codewhispererCursorOffset: session.startCursorOffset, + codewhispererCustomizationArn: selectedCustomization.arn === '' ? undefined : selectedCustomization.arn, + codewhispererFeatureEvaluations: FeatureConfigProvider.instance.getFeatureConfigsTelemetry(), + codewhispererFirstRequestId: requestIdList[0], + codewhispererGettingStartedTask: session.taskType, + codewhispererLanguage: language, + codewhispererLineNumber: session.startPos.line, + codewhispererPreviousSuggestionState: this.prevTriggerDecision, + codewhispererSessionId: sessionId, + codewhispererSuggestionCount: 0, + codewhispererSuggestionImportCount: 0, + codewhispererSuggestionState: 'Empty', + codewhispererSupplementalContextIsUtg: supplementalContextMetadata?.isUtg, + codewhispererSupplementalContextLength: supplementalContextMetadata?.contentsLength, + // eslint-disable-next-line id-length + codewhispererSupplementalContextStrategyId: supplementalContextMetadata?.strategy, + codewhispererSupplementalContextTimeout: supplementalContextMetadata?.isProcessTimeout, + codewhispererTimeSinceLastDocumentChange: this.timeSinceLastModification + ? this.timeSinceLastModification + : undefined, + codewhispererTimeSinceLastUserDecision: this.lastTriggerDecisionTime + ? performance.now() - this.lastTriggerDecisionTime + : undefined, + codewhispererTimeToFirstRecommendation: session.timeToFirstRecommendation, + codewhispererTriggerType: session.triggerType, + codewhispererTypeaheadLength: this.typeAheadLength, + credentialStartUrl: AuthUtil.instance.startUrl, + traceId: this.traceId, + }) + + client + .sendTelemetryEvent({ + telemetryEvent: { + userTriggerDecisionEvent: { + sessionId: sessionId, + requestId: requestIdList[0], + customizationArn: selectedCustomization.arn === '' ? undefined : selectedCustomization.arn, + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(language), + }, + completionType: 'LINE', + suggestionState: 'EMPTY', + recommendationLatencyMilliseconds: 0, + triggerToResponseLatencyMilliseconds: session.timeToFirstRecommendation, + perceivedLatencyMilliseconds: session.perceivedLatency, + timestamp: new Date(Date.now()), + suggestionReferenceCount: 0, + generatedLine: 0, + numberOfRecommendations: 0, + acceptedCharacterCount: 0, + }, + }, + profileArn: profile?.arn, + }) + .then() + .catch((error) => { + let requestId: string | undefined + if (isAwsError(error)) { + requestId = error.requestId + } + + getLogger().error(`Failed to invoke sendTelemetryEvent, requestId: ${requestId ?? ''}`) + }) + } + + /** + * This function is to record the user decision on each of the suggestion in the list of CodeWhisperer recommendations. + * @param recommendations the recommendations + * @param acceptIndex the index of the accepted suggestion in the corresponding list of CodeWhisperer response. + * If this function is not called on acceptance, then acceptIndex == -1 + * @param languageId the language ID of the current document in current active editor + * @param paginationIndex the index of pagination calls + * @param recommendationSuggestionState the key-value mapping from index to suggestion state + */ + + public recordUserDecisionTelemetry( + requestIdList: string[], + sessionId: string, + recommendations: RecommendationsList, + acceptIndex: number, + paginationIndex: number, + completionTypes: Map, + recommendationSuggestionState?: Map, + supplementalContextMetadata?: CodeWhispererSupplementalContext | undefined + ) { + const events: CodewhispererUserDecision[] = [] + // emit user decision telemetry + for (const [i, _elem] of recommendations.entries()) { + let uniqueSuggestionReferences: string | undefined = undefined + const uniqueLicenseSet = LicenseUtil.getUniqueLicenseNames(_elem.references) + if (uniqueLicenseSet.size > 0) { + uniqueSuggestionReferences = JSON.stringify(Array.from(uniqueLicenseSet)) + } + if (_elem.content.length === 0) { + recommendationSuggestionState?.set(i, 'Empty') + } + const event: CodewhispererUserDecision = { + // TODO: maintain a list of RecommendationContexts with both recommendation and requestId in it, instead of two separate list items. + codewhispererCompletionType: this.getCompletionType(i, completionTypes), + codewhispererGettingStartedTask: session.taskType, + codewhispererLanguage: session.language, + codewhispererPaginationProgress: paginationIndex, + codewhispererRequestId: requestIdList[i], + codewhispererSessionId: sessionId ? sessionId : undefined, + codewhispererSuggestionImportCount: getImportCount(_elem), + codewhispererSuggestionIndex: i, + codewhispererSuggestionState: this.getSuggestionState(i, acceptIndex, recommendationSuggestionState), + codewhispererSuggestionReferenceCount: _elem.references ? _elem.references.length : 0, + codewhispererSuggestionReferences: uniqueSuggestionReferences, + codewhispererSupplementalContextIsUtg: supplementalContextMetadata?.isUtg, + codewhispererSupplementalContextLength: supplementalContextMetadata?.contentsLength, + codewhispererSupplementalContextTimeout: supplementalContextMetadata?.isProcessTimeout, + codewhispererTriggerType: session.triggerType, + credentialStartUrl: AuthUtil.instance.startUrl, + traceId: this.traceId, + } + events.push(event) + } + + // aggregate suggestion references count + const referenceCount = this.getAggregatedSuggestionReferenceCount(events) + + // aggregate user decision events at requestId level + const aggregatedEvent = this.aggregateUserDecisionByRequest(events, requestIdList[0], sessionId) + if (aggregatedEvent) { + this.sessionDecisions.push(aggregatedEvent) + } + + // TODO: use a ternary for this + let acceptedRecommendationContent + if (acceptIndex !== -1 && recommendations[acceptIndex] !== undefined) { + acceptedRecommendationContent = recommendations[acceptIndex].content + } else { + acceptedRecommendationContent = '' + } + + // after we have all request level user decisions, aggregate them at session level and send + this.sendUserTriggerDecisionTelemetry( + sessionId, + acceptedRecommendationContent, + referenceCount, + supplementalContextMetadata + ) + } + + public aggregateUserDecisionByRequest( + events: CodewhispererUserDecision[], + requestId: string, + sessionId: string, + supplementalContextMetadata?: CodeWhispererSupplementalContext | undefined + ) { + // the request level user decision will contain information from both the service_invocation event + // and the user_decision events for recommendations within that request + if (!events.length) { + return + } + const aggregated: CodewhispererUserTriggerDecision = { + codewhispererAutomatedTriggerType: session.autoTriggerType, + codewhispererCompletionType: events[0].codewhispererCompletionType, + codewhispererCursorOffset: session.startCursorOffset, + codewhispererFirstRequestId: requestId, + codewhispererGettingStartedTask: session.taskType, + codewhispererLanguage: events[0].codewhispererLanguage, + codewhispererLineNumber: session.startPos.line, + codewhispererSessionId: sessionId, + codewhispererSuggestionCount: events.length, + codewhispererSuggestionImportCount: events + .map((e) => e.codewhispererSuggestionImportCount || 0) + .reduce((a, b) => a + b, 0), + codewhispererSuggestionState: this.getAggregatedSuggestionState(events), + codewhispererSupplementalContextIsUtg: supplementalContextMetadata?.isUtg, + codewhispererSupplementalContextLength: supplementalContextMetadata?.contentsLength, + codewhispererSupplementalContextTimeout: supplementalContextMetadata?.isProcessTimeout, + codewhispererTriggerType: events[0].codewhispererTriggerType, + codewhispererTypeaheadLength: 0, + credentialStartUrl: events[0].credentialStartUrl, + traceId: this.traceId, + } + return aggregated + } + + public sendUserTriggerDecisionTelemetry( + sessionId: string, + acceptedRecommendationContent: string, + referenceCount: number, + supplementalContextMetadata?: CodeWhispererSupplementalContext | undefined + ) { + // the user trigger decision will aggregate information from request level user decisions within one session + // and add additional session level insights + if (!this.sessionDecisions.length) { + return + } + + // TODO: add partial acceptance related metrics + const autoTriggerType = this.sessionDecisions[0].codewhispererAutomatedTriggerType + const language = this.sessionDecisions[0].codewhispererLanguage + const aggregatedCompletionType = this.sessionDecisions[0].codewhispererCompletionType + const aggregatedSuggestionState = this.getAggregatedSuggestionState(this.sessionDecisions) + const selectedCustomization = getSelectedCustomization() + const profile = AuthUtil.instance.regionProfileManager.activeRegionProfile + const generatedLines = + acceptedRecommendationContent.trim() === '' ? 0 : acceptedRecommendationContent.split('\n').length + const suggestionCount = this.sessionDecisions + .map((e) => e.codewhispererSuggestionCount) + .reduce((a, b) => a + b, 0) + + const aggregated: CodewhispererUserTriggerDecision = { + codewhispererAutomatedTriggerType: autoTriggerType, + codewhispererCharactersAccepted: acceptedRecommendationContent.length, + codewhispererClassifierResult: this.classifierResult, + codewhispererClassifierThreshold: this.classifierThreshold, + codewhispererCompletionType: aggregatedCompletionType, + codewhispererCursorOffset: this.sessionDecisions[0].codewhispererCursorOffset, + codewhispererCustomizationArn: selectedCustomization.arn === '' ? undefined : selectedCustomization.arn, + codewhispererFeatureEvaluations: FeatureConfigProvider.instance.getFeatureConfigsTelemetry(), + codewhispererFirstRequestId: this.sessionDecisions[0].codewhispererFirstRequestId, + codewhispererGettingStartedTask: session.taskType, + codewhispererLanguage: language, + codewhispererLineNumber: this.sessionDecisions[0].codewhispererLineNumber, + codewhispererPreviousSuggestionState: this.prevTriggerDecision, + codewhispererSessionId: this.sessionDecisions[0].codewhispererSessionId, + codewhispererSuggestionCount: suggestionCount, + codewhispererSuggestionImportCount: this.sessionDecisions + .map((e) => e.codewhispererSuggestionImportCount || 0) + .reduce((a, b) => a + b, 0), + codewhispererSuggestionState: aggregatedSuggestionState, + codewhispererSupplementalContextIsUtg: supplementalContextMetadata?.isUtg, + codewhispererSupplementalContextLength: supplementalContextMetadata?.contentsLength, + // eslint-disable-next-line id-length + codewhispererSupplementalContextStrategyId: supplementalContextMetadata?.strategy, + codewhispererSupplementalContextTimeout: supplementalContextMetadata?.isProcessTimeout, + codewhispererTimeSinceLastDocumentChange: this.timeSinceLastModification + ? this.timeSinceLastModification + : undefined, + codewhispererTimeSinceLastUserDecision: this.lastTriggerDecisionTime + ? performance.now() - this.lastTriggerDecisionTime + : undefined, + codewhispererTimeToFirstRecommendation: session.timeToFirstRecommendation, + codewhispererTriggerCharacter: autoTriggerType === 'SpecialCharacters' ? this.triggerChar : undefined, + codewhispererTriggerType: this.sessionDecisions[0].codewhispererTriggerType, + codewhispererTypeaheadLength: this.typeAheadLength, + credentialStartUrl: this.sessionDecisions[0].credentialStartUrl, + traceId: this.traceId, + } + telemetry.codewhisperer_userTriggerDecision.emit(aggregated) + this.prevTriggerDecision = this.getAggregatedSuggestionState(this.sessionDecisions) + this.lastTriggerDecisionTime = performance.now() + + // When we send a userTriggerDecision for neither Accept nor Reject, service side should not use this value + // and client side will set this value to 0.0. + let e2eLatency = session.firstSuggestionShowTime - session.invokeSuggestionStartTime + if (aggregatedSuggestionState !== 'Reject' && aggregatedSuggestionState !== 'Accept') { + e2eLatency = 0.0 + } + + const userTriggerDecisionEvent: CodeWhispererUserClient.UserTriggerDecisionEvent = { + sessionId: sessionId, + requestId: this.sessionDecisions[0].codewhispererFirstRequestId, + customizationArn: selectedCustomization.arn === '' ? undefined : selectedCustomization.arn, + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(this.sessionDecisions[0].codewhispererLanguage), + }, + completionType: this.getSendTelemetryCompletionType(aggregatedCompletionType), + suggestionState: this.getSendTelemetrySuggestionState(aggregatedSuggestionState), + recommendationLatencyMilliseconds: e2eLatency, + triggerToResponseLatencyMilliseconds: session.timeToFirstRecommendation, + perceivedLatencyMilliseconds: session.perceivedLatency, + timestamp: new Date(Date.now()), + suggestionReferenceCount: referenceCount, + generatedLine: generatedLines, + numberOfRecommendations: suggestionCount, + acceptedCharacterCount: acceptedRecommendationContent.length, + } + this.resetUserTriggerDecisionTelemetry() + + const sendEvent = () => + client + .sendTelemetryEvent({ + telemetryEvent: { userTriggerDecisionEvent: userTriggerDecisionEvent }, + profileArn: profile?.arn, + }) + .catch((error) => { + const requestId = isAwsError(error) ? error.requestId : undefined + getLogger().debug( + `Failed to sendTelemetryEvent to CodeWhisperer, requestId: ${requestId ?? ''}, message: ${error.message}` + ) + }) + + if (userTriggerDecisionEvent.suggestionState === 'ACCEPT' && Auth.instance.isInternalAmazonUser()) { + // wait 1 seconds for the user installed 3rd party LSP + // to update its diagnostics. + void sleep(1000).then(() => { + const diagnosticDiff = getDiagnosticsDifferences( + session.diagnosticsBeforeAccept, + getDiagnosticsOfCurrentFile() + ) + userTriggerDecisionEvent.addedIdeDiagnostics = diagnosticDiff.added.map((it) => toIdeDiagnostics(it)) + userTriggerDecisionEvent.removedIdeDiagnostics = diagnosticDiff.removed.map((it) => + toIdeDiagnostics(it) + ) + void sendEvent() + }) + } else { + void sendEvent() + } + } + + public getLastTriggerDecisionForClassifier() { + if (this.lastTriggerDecisionTime && performance.now() - this.lastTriggerDecisionTime <= 2 * 60 * 1000) { + return this.prevTriggerDecision + } + } + + public setClassifierResult(classifierResult: number) { + this.classifierResult = classifierResult + } + + public setClassifierThreshold(classifierThreshold: number) { + this.classifierThreshold = classifierThreshold + } + + public setTriggerCharForUserTriggerDecision(triggerChar: string) { + this.triggerChar = triggerChar + } + + public setTypeAheadLength(typeAheadLength: number) { + this.typeAheadLength = typeAheadLength + } + + public setTimeSinceLastModification(timeSinceLastModification: number) { + this.timeSinceLastModification = timeSinceLastModification + } + + public setTraceId(traceId: string) { + this.traceId = traceId + } + + private resetUserTriggerDecisionTelemetry() { + this.sessionDecisions = [] + this.triggerChar = '' + this.typeAheadLength = 0 + this.timeSinceLastModification = 0 + session.timeToFirstRecommendation = 0 + session.perceivedLatency = 0 + this.classifierResult = undefined + this.classifierThreshold = undefined + } + + private getSendTelemetryCompletionType(completionType: CodewhispererCompletionType) { + return completionType === 'Block' ? 'BLOCK' : 'LINE' + } + + private getAggregatedSuggestionState( + // if there is any Accept within the session, mark the session as Accept + // if there is any Reject within the session, mark the session as Reject + // if all recommendations within the session are empty, mark the session as Empty + // otherwise mark the session as Discard + events: CodewhispererUserDecision[] | CodewhispererUserTriggerDecision[] + ): CodewhispererPreviousSuggestionState { + let isEmpty = true + for (const event of events) { + if (event.codewhispererSuggestionState === 'Accept') { + return 'Accept' + } else if (event.codewhispererSuggestionState === 'Reject') { + return 'Reject' + } else if (event.codewhispererSuggestionState !== 'Empty') { + isEmpty = false + } + } + return isEmpty ? 'Empty' : 'Discard' + } + + private getSendTelemetrySuggestionState(state: CodewhispererPreviousSuggestionState) { + if (state === 'Accept') { + return 'ACCEPT' + } else if (state === 'Reject') { + return 'REJECT' + } else if (state === 'Discard') { + return 'DISCARD' + } + return 'EMPTY' + } + + private getAggregatedSuggestionReferenceCount( + events: CodewhispererUserDecision[] + // if there is reference for accepted recommendation within the session, mark the reference number + // as 1, otherwise mark the session as 0 + ) { + for (const event of events) { + if (event.codewhispererSuggestionState === 'Accept' && event.codewhispererSuggestionReferenceCount !== 0) { + return 1 + } + } + return 0 + } + + public getSuggestionState( + i: number, + acceptIndex: number, + recommendationSuggestionState?: Map + ): CodewhispererSuggestionState { + const state = recommendationSuggestionState?.get(i) + if (state && ['Empty', 'Filter', 'Discard'].includes(state)) { + return state as CodewhispererSuggestionState + } else if (recommendationSuggestionState !== undefined && recommendationSuggestionState.get(i) !== 'Showed') { + return 'Unseen' + } + if (acceptIndex === -1) { + return 'Reject' + } + return i === acceptIndex ? 'Accept' : 'Ignore' + } + public getCompletionType(i: number, completionTypes: Map) { return completionTypes.get(i) || 'Line' } diff --git a/packages/core/src/shared/featureConfig.ts b/packages/core/src/shared/featureConfig.ts index c7b111b3243..c0ed174045a 100644 --- a/packages/core/src/shared/featureConfig.ts +++ b/packages/core/src/shared/featureConfig.ts @@ -39,6 +39,8 @@ export const Features = { dataCollectionFeature: 'IDEProjectContextDataCollection', projectContextFeature: 'ProjectContextV2', workspaceContextFeature: 'WorkspaceContext', + preFlareRollbackBIDFeature: 'PreflareRollbackExperiment_BID', + preFlareRollbackIDCFeature: 'PreflareRollbackExperiment_IDC', test: 'testFeature', highlightCommand: 'highlightCommand', } as const @@ -106,6 +108,16 @@ export class FeatureConfigProvider { } } + getPreFlareRollbackGroup(): 'control' | 'treatment' | 'default' { + const variationBid = this.featureConfigs.get(Features.preFlareRollbackBIDFeature)?.variation + const variationIdc = this.featureConfigs.get(Features.preFlareRollbackIDCFeature)?.variation + if (variationBid === 'TREATMENT' || variationIdc === 'TREATMENT') { + return 'treatment' + } else { + return 'control' + } + } + public async listFeatureEvaluations(): Promise { const profile = AuthUtil.instance.regionProfileManager.activeRegionProfile const request: ListFeatureEvaluationsRequest = {