diff --git a/packages/amazonq/src/app/inline/EditRendering/displayImage.ts b/packages/amazonq/src/app/inline/EditRendering/displayImage.ts index 2262b984e2f..6509d2d744e 100644 --- a/packages/amazonq/src/app/inline/EditRendering/displayImage.ts +++ b/packages/amazonq/src/app/inline/EditRendering/displayImage.ts @@ -12,7 +12,8 @@ import { LogInlineCompletionSessionResultsParams } from '@aws/language-server-ru import { InlineCompletionItemWithReferences } from '@aws/language-server-runtimes/protocol' import path from 'path' import { imageVerticalOffset } from './svgGenerator' -import { AmazonQInlineCompletionItemProvider } from '../completion' +import { EditSuggestionState } from '../editSuggestionState' +import type { AmazonQInlineCompletionItemProvider } from '../completion' import { vsCodeState } from 'aws-core-vscode/codewhisperer' export class EditDecorationManager { @@ -136,6 +137,7 @@ export class EditDecorationManager { await this.clearDecorations(editor) await setContext('aws.amazonq.editSuggestionActive' as any, true) + EditSuggestionState.setEditSuggestionActive(true) this.acceptHandler = onAccept this.rejectHandler = onReject @@ -166,6 +168,7 @@ export class EditDecorationManager { this.acceptHandler = undefined this.rejectHandler = undefined await setContext('aws.amazonq.editSuggestionActive' as any, false) + EditSuggestionState.setEditSuggestionActive(false) } /** @@ -270,6 +273,28 @@ function getEndOfEditPosition(originalCode: string, newCode: string): vscode.Pos return editor ? editor.selection.active : new vscode.Position(0, 0) } +/** + * Helper function to create discard telemetry params + */ +function createDiscardTelemetryParams( + session: CodeWhispererSession, + item: InlineCompletionItemWithReferences +): LogInlineCompletionSessionResultsParams { + return { + sessionId: session.sessionId, + completionSessionResult: { + [item.itemId]: { + seen: false, + accepted: false, + discarded: true, + }, + }, + totalSessionDisplayTime: Date.now() - session.requestStartTime, + firstCompletionDisplayLatency: session.firstCompletionDisplayLatency, + isInlineEdit: true, + } +} + /** * Helper function to display SVG decorations */ @@ -286,21 +311,18 @@ export async function displaySvgDecoration( ) { const originalCode = editor.document.getText() + // Check if a completion suggestion is currently active - if so, discard edit suggestion + if (inlineCompletionProvider && (await inlineCompletionProvider.isCompletionActive())) { + // Emit DISCARD telemetry for edit suggestion that can't be shown due to active completion + const params = createDiscardTelemetryParams(session, item) + languageClient.sendNotification('aws/logInlineCompletionSessionResults', params) + getLogger().info('Edit suggestion discarded due to active completion suggestion') + return + } + const isPatchValid = applyPatch(editor.document.getText(), item.insertText as string) if (!isPatchValid) { - const params: LogInlineCompletionSessionResultsParams = { - sessionId: session.sessionId, - completionSessionResult: { - [item.itemId]: { - seen: false, - accepted: false, - discarded: true, - }, - }, - totalSessionDisplayTime: Date.now() - session.requestStartTime, - firstCompletionDisplayLatency: session.firstCompletionDisplayLatency, - isInlineEdit: true, - } + const params = createDiscardTelemetryParams(session, item) // TODO: this session is closed on flare side hence discarded is not emitted in flare languageClient.sendNotification('aws/logInlineCompletionSessionResults', params) return diff --git a/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts b/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts index 5f204343b5e..6c52dc2d6a0 100644 --- a/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts +++ b/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts @@ -10,7 +10,7 @@ import { getLogger } from 'aws-core-vscode/shared' import { LanguageClient } from 'vscode-languageclient' import { InlineCompletionItemWithReferences } from '@aws/language-server-runtimes/protocol' import { CodeWhispererSession } from '../sessionManager' -import { AmazonQInlineCompletionItemProvider } from '../completion' +import type { AmazonQInlineCompletionItemProvider } from '../completion' export async function showEdits( item: InlineCompletionItemWithReferences, diff --git a/packages/amazonq/src/app/inline/completion.ts b/packages/amazonq/src/app/inline/completion.ts index 3f8725f8579..9a5f5522468 100644 --- a/packages/amazonq/src/app/inline/completion.ts +++ b/packages/amazonq/src/app/inline/completion.ts @@ -237,6 +237,53 @@ export class AmazonQInlineCompletionItemProvider implements InlineCompletionItem await vscode.commands.executeCommand(`aws.amazonq.checkInlineSuggestionVisibility`) } + /** + * Check if a completion suggestion is currently active/displayed + */ + public async isCompletionActive(): Promise { + const session = this.sessionManager.getActiveSession() + if (session === undefined || !session.displayed || session.suggestions.some((item) => item.isInlineEdit)) { + return false + } + + // Use VS Code command to check if inline suggestion is actually visible on screen + // This command only executes when inlineSuggestionVisible context is true + await vscode.commands.executeCommand('aws.amazonq.checkInlineSuggestionVisibility') + const isInlineSuggestionVisible = performance.now() - session.lastVisibleTime < 50 + return isInlineSuggestionVisible + } + + /** + * Batch discard telemetry for completion suggestions when edit suggestion is active + */ + public batchDiscardTelemetryForEditSuggestion(items: any[], session: any): void { + // Emit DISCARD telemetry for completion suggestions that can't be shown due to active edit + const completionSessionResult: { + [key: string]: { seen: boolean; accepted: boolean; discarded: boolean } + } = {} + + for (const item of items) { + if (!item.isInlineEdit && item.itemId) { + completionSessionResult[item.itemId] = { + seen: false, + accepted: false, + discarded: true, + } + } + } + + // Send single telemetry event for all discarded items + if (Object.keys(completionSessionResult).length > 0) { + const params: LogInlineCompletionSessionResultsParams = { + sessionId: session.sessionId, + completionSessionResult, + firstCompletionDisplayLatency: session.firstCompletionDisplayLatency, + totalSessionDisplayTime: performance.now() - session.requestStartTime, + } + this.languageClient.sendNotification(this.logSessionResultMessageName, params) + } + } + // this method is automatically invoked by VS Code as user types async provideInlineCompletionItems( document: TextDocument, diff --git a/packages/amazonq/src/app/inline/editSuggestionState.ts b/packages/amazonq/src/app/inline/editSuggestionState.ts new file mode 100644 index 00000000000..66a9211bdcf --- /dev/null +++ b/packages/amazonq/src/app/inline/editSuggestionState.ts @@ -0,0 +1,19 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Manages the state of edit suggestions to avoid circular dependencies + */ +export class EditSuggestionState { + private static isEditSuggestionCurrentlyActive = false + + static setEditSuggestionActive(active: boolean): void { + this.isEditSuggestionCurrentlyActive = active + } + + static isEditSuggestionActive(): boolean { + return this.isEditSuggestionCurrentlyActive + } +} diff --git a/packages/amazonq/src/app/inline/recommendationService.ts b/packages/amazonq/src/app/inline/recommendationService.ts index 51eb696b119..55c08c820fe 100644 --- a/packages/amazonq/src/app/inline/recommendationService.ts +++ b/packages/amazonq/src/app/inline/recommendationService.ts @@ -24,6 +24,7 @@ import { getLogger } from 'aws-core-vscode/shared' import { DocumentEventListener } from './documentEventListener' import { getOpenFilesInWindow } from 'aws-core-vscode/utils' import { asyncCallWithTimeout } from '../../util/timeoutUtil' +import { EditSuggestionState } from './editSuggestionState' export interface GetAllRecommendationsOptions { emitTelemetry?: boolean @@ -132,7 +133,7 @@ export class RecommendationService { * Completions use PartialResultToken with single 1 call of [getAllRecommendations]. * Edits leverage partialResultToken to achieve EditStreak such that clients can pull all continuous suggestions generated by the model within 1 EOS block. */ - if (!isTriggerByDeletion && !request.partialResultToken) { + if (!isTriggerByDeletion && !request.partialResultToken && !EditSuggestionState.isEditSuggestionActive()) { const completionPromise: Promise = languageClient.sendRequest( inlineCompletionWithReferencesRequestType.method, request, diff --git a/packages/amazonq/src/app/inline/sessionManager.ts b/packages/amazonq/src/app/inline/sessionManager.ts index 7decf035b9a..15d7dbbb8d0 100644 --- a/packages/amazonq/src/app/inline/sessionManager.ts +++ b/packages/amazonq/src/app/inline/sessionManager.ts @@ -26,6 +26,8 @@ export interface CodeWhispererSession { triggerOnAcceptance?: boolean // whether any suggestion in this session was displayed on screen displayed: boolean + // timestamp when the suggestion was last visible + lastVisibleTime: number } export class SessionManager { @@ -52,6 +54,7 @@ export class SessionManager { firstCompletionDisplayLatency, diagnosticsBeforeAccept, displayed: false, + lastVisibleTime: 0, } this._currentSuggestionIndex = 0 } @@ -134,6 +137,7 @@ export class SessionManager { public checkInlineSuggestionVisibility() { if (this.activeSession) { this.activeSession.displayed = true + this.activeSession.lastVisibleTime = performance.now() } } diff --git a/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts b/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts index 6572edffddc..2a6496927d7 100644 --- a/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts +++ b/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts @@ -15,6 +15,7 @@ import { CursorUpdateManager } from '../../../../../src/app/inline/cursorUpdateM import { CodeWhispererStatusBarManager } from 'aws-core-vscode/codewhisperer' import { globals } from 'aws-core-vscode/shared' import { DocumentEventListener } from '../../../../../src/app/inline/documentEventListener' +import { EditSuggestionState } from '../../../../../src/app/inline/editSuggestionState' const completionApi = 'aws/textDocument/inlineCompletionWithReferences' const editApi = 'aws/textDocument/editCompletion' @@ -325,5 +326,77 @@ describe('RecommendationService', () => { console.error = originalConsoleError } }) + + it('should not make completion request when edit suggestion is active', async () => { + // Mock EditSuggestionState to return true (edit suggestion is active) + const isEditSuggestionActiveStub = sandbox.stub(EditSuggestionState, 'isEditSuggestionActive').returns(true) + + const mockResult = { + sessionId: 'test-session', + items: [mockInlineCompletionItemOne], + partialResultToken: undefined, + } + + sendRequestStub.resolves(mockResult) + + await service.getAllRecommendations( + languageClient, + mockDocument, + mockPosition, + mockContext, + mockToken, + true, + mockDocumentEventListener + ) + + // Verify sendRequest was called only for edit API, not completion API + const cs = sendRequestStub.getCalls() + const completionCalls = cs.filter((c) => c.firstArg === completionApi) + const editCalls = cs.filter((c) => c.firstArg === editApi) + + assert.strictEqual(cs.length, 1) // Only edit call + assert.strictEqual(completionCalls.length, 0) // No completion calls + assert.strictEqual(editCalls.length, 1) // One edit call + + // Verify the stub was called + sinon.assert.calledOnce(isEditSuggestionActiveStub) + }) + + it('should make completion request when edit suggestion is not active', async () => { + // Mock EditSuggestionState to return false (no edit suggestion active) + const isEditSuggestionActiveStub = sandbox + .stub(EditSuggestionState, 'isEditSuggestionActive') + .returns(false) + + const mockResult = { + sessionId: 'test-session', + items: [mockInlineCompletionItemOne], + partialResultToken: undefined, + } + + sendRequestStub.resolves(mockResult) + + await service.getAllRecommendations( + languageClient, + mockDocument, + mockPosition, + mockContext, + mockToken, + true, + mockDocumentEventListener + ) + + // Verify sendRequest was called for both APIs + const cs = sendRequestStub.getCalls() + const completionCalls = cs.filter((c) => c.firstArg === completionApi) + const editCalls = cs.filter((c) => c.firstArg === editApi) + + assert.strictEqual(cs.length, 2) // Both calls + assert.strictEqual(completionCalls.length, 1) // One completion call + assert.strictEqual(editCalls.length, 1) // One edit call + + // Verify the stub was called + sinon.assert.calledOnce(isEditSuggestionActiveStub) + }) }) }) diff --git a/packages/amazonq/test/unit/app/inline/completion.test.ts b/packages/amazonq/test/unit/app/inline/completion.test.ts new file mode 100644 index 00000000000..bd38b1c95af --- /dev/null +++ b/packages/amazonq/test/unit/app/inline/completion.test.ts @@ -0,0 +1,210 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { AmazonQInlineCompletionItemProvider } from '../../../../src/app/inline/completion' + +describe('AmazonQInlineCompletionItemProvider', function () { + let provider: AmazonQInlineCompletionItemProvider + let mockLanguageClient: any + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + mockLanguageClient = { + sendNotification: sandbox.stub(), + } + + // Create provider with minimal mocks + provider = new AmazonQInlineCompletionItemProvider( + mockLanguageClient, + {} as any, // recommendationService + {} as any, // sessionManager + {} as any, // inlineTutorialAnnotation + {} as any // documentEventListener + ) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('batchDiscardTelemetryForEditSuggestion', function () { + it('should batch multiple completion items into single telemetry event', function () { + const items = [ + { itemId: 'item1', isInlineEdit: false }, + { itemId: 'item2', isInlineEdit: false }, + { itemId: 'item3', isInlineEdit: false }, + ] + + const session = { + sessionId: 'test-session', + firstCompletionDisplayLatency: 100, + requestStartTime: performance.now() - 1000, + } + + provider.batchDiscardTelemetryForEditSuggestion(items, session) + + // Verify single telemetry notification was sent + assert.strictEqual(mockLanguageClient.sendNotification.callCount, 1) + + // Verify the notification contains all items + const call = mockLanguageClient.sendNotification.getCall(0) + const params = call.args[1] + + assert.strictEqual(params.sessionId, 'test-session') + assert.strictEqual(Object.keys(params.completionSessionResult).length, 3) + assert.deepStrictEqual(params.completionSessionResult.item1, { + seen: false, + accepted: false, + discarded: true, + }) + assert.deepStrictEqual(params.completionSessionResult.item2, { + seen: false, + accepted: false, + discarded: true, + }) + assert.deepStrictEqual(params.completionSessionResult.item3, { + seen: false, + accepted: false, + discarded: true, + }) + }) + + it('should filter out inline edit items', function () { + const items = [ + { itemId: 'item1', isInlineEdit: false }, + { itemId: 'item2', isInlineEdit: true }, // Should be filtered out + { itemId: 'item3', isInlineEdit: false }, + ] + + const session = { + sessionId: 'test-session', + firstCompletionDisplayLatency: 100, + requestStartTime: performance.now() - 1000, + } + + provider.batchDiscardTelemetryForEditSuggestion(items, session) + + const call = mockLanguageClient.sendNotification.getCall(0) + const params = call.args[1] + + // Should only include 2 items (item2 filtered out) + assert.strictEqual(Object.keys(params.completionSessionResult).length, 2) + assert.ok(params.completionSessionResult.item1) + assert.ok(params.completionSessionResult.item3) + assert.ok(!params.completionSessionResult.item2) + }) + + it('should not send notification when no valid items', function () { + const items = [ + { itemId: 'item1', isInlineEdit: true }, // Filtered out + { itemId: undefined, isInlineEdit: false }, // No itemId + ] + + const session = { + sessionId: 'test-session', + firstCompletionDisplayLatency: 100, + requestStartTime: performance.now() - 1000, + } + + provider.batchDiscardTelemetryForEditSuggestion(items, session) + + // No notification should be sent + assert.strictEqual(mockLanguageClient.sendNotification.callCount, 0) + }) + }) + + describe('isCompletionActive', function () { + let mockSessionManager: any + let mockVscodeCommands: any + + beforeEach(function () { + mockSessionManager = { + getActiveSession: sandbox.stub(), + } + + // Mock vscode.commands.executeCommand + mockVscodeCommands = sandbox.stub(require('vscode').commands, 'executeCommand') + + // Create provider with mocked session manager + provider = new AmazonQInlineCompletionItemProvider( + mockLanguageClient, + {} as any, // recommendationService + mockSessionManager, + {} as any, // inlineTutorialAnnotation + {} as any // documentEventListener + ) + }) + + it('should return false when no active session', async function () { + mockSessionManager.getActiveSession.returns(undefined) + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, false) + assert.strictEqual(mockVscodeCommands.callCount, 0) + }) + + it('should return false when session not displayed', async function () { + mockSessionManager.getActiveSession.returns({ + displayed: false, + suggestions: [{ isInlineEdit: false }], + lastVisibleTime: 0, + }) + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, false) + assert.strictEqual(mockVscodeCommands.callCount, 0) + }) + + it('should return false when session has inline edit suggestions', async function () { + mockSessionManager.getActiveSession.returns({ + displayed: true, + suggestions: [{ isInlineEdit: true }], + lastVisibleTime: performance.now(), + }) + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, false) + assert.strictEqual(mockVscodeCommands.callCount, 0) + }) + + it('should return true when VS Code command executes successfully', async function () { + const currentTime = performance.now() + mockSessionManager.getActiveSession.returns({ + displayed: true, + suggestions: [{ isInlineEdit: false }], + lastVisibleTime: currentTime, // Recent timestamp + }) + mockVscodeCommands.resolves() + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, true) + assert.strictEqual(mockVscodeCommands.callCount, 1) + assert.strictEqual(mockVscodeCommands.getCall(0).args[0], 'aws.amazonq.checkInlineSuggestionVisibility') + }) + + it('should return false when VS Code command fails', async function () { + const oldTime = performance.now() - 100 // Old timestamp (>50ms ago) + mockSessionManager.getActiveSession.returns({ + displayed: true, + suggestions: [{ isInlineEdit: false }], + lastVisibleTime: oldTime, + }) + mockVscodeCommands.resolves() // Command doesn't fail, but timestamp is old + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, false) + assert.strictEqual(mockVscodeCommands.callCount, 1) + assert.strictEqual(mockVscodeCommands.getCall(0).args[0], 'aws.amazonq.checkInlineSuggestionVisibility') + }) + }) +})