packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts (940 lines of code) (raw):

/*! * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ import { ChatItemAction, MynahIcons } from '@aws/mynah-ui' import * as path from 'path' import * as vscode from 'vscode' import { EventEmitter } from 'vscode' import { telemetry } from '../../../shared/telemetry/telemetry' import { createSingleFileDialog } from '../../../shared/ui/common/openDialog' import { CodeIterationLimitError, ContentLengthError, createUserFacingErrorMessage, denyListedErrors, FeatureDevServiceError, getMetricResult, MonthlyConversationLimitError, NoChangeRequiredException, PrepareRepoFailedError, PromptRefusalException, SelectedFolderNotInWorkspaceFolderError, TabIdNotFoundError, UploadCodeError, UploadURLExpired, UserMessageNotFoundError, WorkspaceFolderNotFoundError, ZipFileError, } from '../../errors' import { codeGenRetryLimit, defaultRetryLimit } from '../../limits' import { Session } from '../../session/session' import { featureDevScheme, featureName, generateDevFilePrompt } from '../../constants' import { DeletedFileInfo, DevPhase, MetricDataOperationName, MetricDataResult, type NewFileInfo, } from '../../../amazonq/commons/types' import { AuthUtil } from '../../../codewhisperer/util/authUtil' import { AuthController } from '../../../amazonq/auth/controller' import { getLogger } from '../../../shared/logger/logger' import { submitFeedback } from '../../../feedback/vue/submitFeedback' import { Commands, placeholder } from '../../../shared/vscode/commands2' import { EditorContentController } from '../../../amazonq/commons/controllers/contentController' import { openUrl } from '../../../shared/utilities/vsCodeUtils' import { checkForDevFile, getPathsFromZipFilePath } from '../../../amazonq/util/files' import { examples, messageWithConversationId } from '../../userFacingText' import { getWorkspaceFoldersByPrefixes } from '../../../shared/utilities/workspaceUtils' import { openDeletedDiff, openDiff } from '../../../amazonq/commons/diff' import { i18n } from '../../../shared/i18n-helper' import globals from '../../../shared/extensionGlobals' import { CodeWhispererSettings } from '../../../codewhisperer/util/codewhispererSettings' import { randomUUID } from '../../../shared/crypto' import { FollowUpTypes } from '../../../amazonq/commons/types' import { Messenger } from '../../../amazonq/commons/connector/baseMessenger' import { BaseChatSessionStorage } from '../../../amazonq/commons/baseChatStorage' export interface ChatControllerEventEmitters { readonly processHumanChatMessage: EventEmitter<any> readonly followUpClicked: EventEmitter<any> readonly openDiff: EventEmitter<any> readonly stopResponse: EventEmitter<any> readonly tabOpened: EventEmitter<any> readonly tabClosed: EventEmitter<any> readonly processChatItemVotedMessage: EventEmitter<any> readonly processChatItemFeedbackMessage: EventEmitter<any> readonly authClicked: EventEmitter<any> readonly processResponseBodyLinkClick: EventEmitter<any> readonly insertCodeAtPositionClicked: EventEmitter<any> readonly fileClicked: EventEmitter<any> readonly storeCodeResultMessageId: EventEmitter<any> } type OpenDiffMessage = { tabID: string messageId: string // currently the zip file path filePath: string deleted: boolean codeGenerationId: string } type fileClickedMessage = { tabID: string messageId: string filePath: string actionName: string } type StoreMessageIdMessage = { tabID: string messageId: string } export class FeatureDevController { private readonly scheme: string = featureDevScheme private readonly messenger: Messenger private readonly sessionStorage: BaseChatSessionStorage<Session> private isAmazonQVisible: boolean private authController: AuthController private contentController: EditorContentController public constructor( private readonly chatControllerMessageListeners: ChatControllerEventEmitters, messenger: Messenger, sessionStorage: BaseChatSessionStorage<Session>, onDidChangeAmazonQVisibility: vscode.Event<boolean> ) { this.messenger = messenger this.sessionStorage = sessionStorage this.authController = new AuthController() this.contentController = new EditorContentController() /** * defaulted to true because onDidChangeAmazonQVisibility doesn't get fire'd until after * the view is opened */ this.isAmazonQVisible = true onDidChangeAmazonQVisibility((visible) => { this.isAmazonQVisible = visible }) this.chatControllerMessageListeners.processHumanChatMessage.event((data) => { this.processUserChatMessage(data).catch((e) => { getLogger().error('processUserChatMessage failed: %s', (e as Error).message) }) }) this.chatControllerMessageListeners.processChatItemVotedMessage.event((data) => { this.processChatItemVotedMessage(data.tabID, data.vote).catch((e) => { getLogger().error('processChatItemVotedMessage failed: %s', (e as Error).message) }) }) this.chatControllerMessageListeners.processChatItemFeedbackMessage.event((data) => { this.processChatItemFeedbackMessage(data).catch((e) => { getLogger().error('processChatItemFeedbackMessage failed: %s', (e as Error).message) }) }) this.chatControllerMessageListeners.followUpClicked.event((data) => { switch (data.followUp.type) { case FollowUpTypes.InsertCode: return this.insertCode(data) case FollowUpTypes.ProvideFeedbackAndRegenerateCode: return this.provideFeedbackAndRegenerateCode(data) case FollowUpTypes.Retry: return this.retryRequest(data) case FollowUpTypes.ModifyDefaultSourceFolder: return this.modifyDefaultSourceFolder(data) case FollowUpTypes.DevExamples: this.initialExamples(data) break case FollowUpTypes.NewTask: this.messenger.sendAnswer({ type: 'answer', tabID: data?.tabID, message: i18n('AWS.amazonq.featureDev.answer.newTaskChanges'), }) return this.newTask(data) case FollowUpTypes.CloseSession: return this.closeSession(data) case FollowUpTypes.SendFeedback: this.sendFeedback() break case FollowUpTypes.AcceptAutoBuild: return this.processAutoBuildSetting(true, data) case FollowUpTypes.DenyAutoBuild: return this.processAutoBuildSetting(false, data) case FollowUpTypes.GenerateDevFile: this.messenger.sendAnswer({ type: 'system-prompt', tabID: data?.tabID, message: i18n('AWS.amazonq.featureDev.pillText.generateDevFile'), }) return this.newTask(data, generateDevFilePrompt) } }) this.chatControllerMessageListeners.openDiff.event((data) => { return this.openDiff(data) }) this.chatControllerMessageListeners.stopResponse.event((data) => { return this.stopResponse(data) }) this.chatControllerMessageListeners.tabOpened.event((data) => { return this.tabOpened(data) }) this.chatControllerMessageListeners.tabClosed.event((data) => { this.tabClosed(data) }) this.chatControllerMessageListeners.authClicked.event((data) => { this.authClicked(data) }) this.chatControllerMessageListeners.processResponseBodyLinkClick.event((data) => { this.processLink(data) }) this.chatControllerMessageListeners.insertCodeAtPositionClicked.event((data) => { this.insertCodeAtPosition(data) }) this.chatControllerMessageListeners.fileClicked.event(async (data) => { return await this.fileClicked(data) }) this.chatControllerMessageListeners.storeCodeResultMessageId.event(async (data) => { return await this.storeCodeResultMessageId(data) }) AuthUtil.instance.regionProfileManager.onDidChangeRegionProfile(() => { this.sessionStorage.deleteAllSessions() }) } private async processChatItemVotedMessage(tabId: string, vote: string) { const session = await this.sessionStorage.getSession(tabId) if (vote === 'upvote') { telemetry.amazonq_codeGenerationThumbsUp.emit({ amazonqConversationId: session?.conversationId, value: 1, result: 'Succeeded', credentialStartUrl: AuthUtil.instance.startUrl, }) } else if (vote === 'downvote') { telemetry.amazonq_codeGenerationThumbsDown.emit({ amazonqConversationId: session?.conversationId, value: 1, result: 'Succeeded', credentialStartUrl: AuthUtil.instance.startUrl, }) } } private async processChatItemFeedbackMessage(message: any) { const session = await this.sessionStorage.getSession(message.tabId) await globals.telemetry.postFeedback({ comment: `${JSON.stringify({ type: 'featuredev-chat-answer-feedback', conversationId: session?.conversationId ?? '', messageId: message?.messageId, reason: message?.selectedOption, userComment: message?.comment, })}`, sentiment: 'Negative', // The chat UI reports only negative feedback currently. }) } private processErrorChatMessage = (err: any, message: any, session: Session | undefined) => { const errorMessage = createUserFacingErrorMessage( `${featureName} request failed: ${err.cause?.message ?? err.message}` ) let defaultMessage const isDenyListedError = denyListedErrors.some((denyListedError) => err.message.includes(denyListedError)) switch (err.constructor.name) { case ContentLengthError.name: this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: err.message + messageWithConversationId(session?.conversationIdUnsafe), canBeVoted: true, }) this.messenger.sendAnswer({ type: 'system-prompt', tabID: message.tabID, followUps: [ { pillText: i18n('AWS.amazonq.featureDev.pillText.modifyDefaultSourceFolder'), type: 'ModifyDefaultSourceFolder', status: 'info', }, ], }) break case MonthlyConversationLimitError.name: this.messenger.sendMonthlyLimitError(message.tabID) break case FeatureDevServiceError.name: case UploadCodeError.name: case UserMessageNotFoundError.name: case TabIdNotFoundError.name: case PrepareRepoFailedError.name: this.messenger.sendErrorMessage( errorMessage, message.tabID, this.retriesRemaining(session), session?.conversationIdUnsafe ) break case PromptRefusalException.name: case ZipFileError.name: this.messenger.sendErrorMessage(errorMessage, message.tabID, 0, session?.conversationIdUnsafe, true) break case NoChangeRequiredException.name: this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: err.message, canBeVoted: true, }) // Allow users to re-work the task description. return this.newTask(message) case CodeIterationLimitError.name: this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: err.message + messageWithConversationId(session?.conversationIdUnsafe), canBeVoted: true, }) this.messenger.sendAnswer({ type: 'system-prompt', tabID: message.tabID, followUps: [ { pillText: session?.getInsertCodePillText([ ...(session?.state.filePaths ?? []), ...(session?.state.deletedFiles ?? []), ]) ?? i18n('AWS.amazonq.featureDev.pillText.acceptAllChanges'), type: FollowUpTypes.InsertCode, icon: 'ok' as MynahIcons, status: 'success', }, ], }) break case UploadURLExpired.name: this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: err.message, canBeVoted: true, }) break default: if (isDenyListedError || this.retriesRemaining(session) === 0) { defaultMessage = i18n('AWS.amazonq.featureDev.error.codeGen.denyListedError') } else { defaultMessage = i18n('AWS.amazonq.featureDev.error.codeGen.default') } this.messenger.sendErrorMessage( defaultMessage ? defaultMessage : errorMessage, message.tabID, this.retriesRemaining(session), session?.conversationIdUnsafe, !!defaultMessage ) break } } /** * * This function dispose cancellation token to free resources and provide a new token. * Since user can abort a call in the same session, when the processing ends, we need provide a new one * to start with the new prompt and allow the ability to stop again. * * @param session */ private disposeToken(session: Session | undefined) { if (session?.state?.tokenSource?.token.isCancellationRequested) { session?.state.tokenSource?.dispose() if (session?.state?.tokenSource) { session.state.tokenSource = new vscode.CancellationTokenSource() } getLogger().debug('Request cancelled, skipping further processing') } } // TODO add type private async processUserChatMessage(message: any) { if (message.message === undefined) { this.messenger.sendErrorMessage('chatMessage should be set', message.tabID, 0, undefined) return } /** * Don't attempt to process any chat messages when a workspace folder is not set. * When the tab is first opened we will throw an error and lock the chat if the workspace * folder is not found */ const workspaceFolders = vscode.workspace.workspaceFolders if (workspaceFolders === undefined || workspaceFolders.length === 0) { return } let session try { getLogger().debug(`${featureName}: Processing message: ${message.message}`) session = await this.sessionStorage.getSession(message.tabID) // set latestMessage in session as retry would lose context if function returns early session.latestMessage = message.message await session.disableFileList() const authState = await AuthUtil.instance.getChatAuthState() if (authState.amazonQ !== 'connected') { await this.messenger.sendAuthNeededExceptionMessage(authState, message.tabID) session.isAuthenticating = true return } const root = session.getWorkspaceRoot() const autoBuildProjectSetting = CodeWhispererSettings.instance.getAutoBuildSetting() const hasDevfile = await checkForDevFile(root) const isPromptedForAutoBuildFeature = Object.keys(autoBuildProjectSetting).includes(root) if (hasDevfile && !isPromptedForAutoBuildFeature) { await this.promptAllowQCommandsConsent(message.tabID) return } await session.preloader() if (session.state.phase === DevPhase.CODEGEN) { await this.onCodeGeneration(session, message.message, message.tabID) } } catch (err: any) { this.disposeToken(session) await this.processErrorChatMessage(err, message, session) // Lock the chat input until they explicitly click one of the follow ups this.messenger.sendChatInputEnabled(message.tabID, false) } } private async promptAllowQCommandsConsent(tabID: string) { this.messenger.sendAnswer({ tabID: tabID, message: i18n('AWS.amazonq.featureDev.answer.devFileInRepository'), type: 'answer', }) this.messenger.sendAnswer({ message: undefined, type: 'system-prompt', followUps: [ { pillText: i18n('AWS.amazonq.featureDev.pillText.acceptForProject'), type: FollowUpTypes.AcceptAutoBuild, status: 'success', }, { pillText: i18n('AWS.amazonq.featureDev.pillText.declineForProject'), type: FollowUpTypes.DenyAutoBuild, status: 'error', }, ], tabID: tabID, }) } /** * Handle a regular incoming message when a user is in the code generation phase */ private async onCodeGeneration(session: Session, message: string, tabID: string) { // lock the UI/show loading bubbles this.messenger.sendAsyncEventProgress( tabID, true, session.retries === codeGenRetryLimit ? i18n('AWS.amazonq.featureDev.pillText.awaitMessage') : i18n('AWS.amazonq.featureDev.pillText.awaitMessageRetry') ) try { this.messenger.sendAnswer({ message: i18n('AWS.amazonq.featureDev.pillText.requestingChanges'), type: 'answer-stream', tabID, canBeVoted: true, }) this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.generatingCode')) await session.sendMetricDataTelemetry(MetricDataOperationName.StartCodeGeneration, MetricDataResult.Success) await session.send(message) const filePaths = session.state.filePaths ?? [] const deletedFiles = session.state.deletedFiles ?? [] // Only add the follow up accept/deny buttons when the tab hasn't been closed/request hasn't been cancelled if (session?.state?.tokenSource?.token.isCancellationRequested) { return } if (filePaths.length === 0 && deletedFiles.length === 0) { this.messenger.sendAnswer({ message: i18n('AWS.amazonq.featureDev.pillText.unableGenerateChanges'), type: 'answer', tabID: tabID, canBeVoted: true, }) this.messenger.sendAnswer({ type: 'system-prompt', tabID: tabID, followUps: this.retriesRemaining(session) > 0 ? [ { pillText: i18n('AWS.amazonq.featureDev.pillText.retry'), type: FollowUpTypes.Retry, status: 'warning', }, ] : [], }) // Lock the chat input until they explicitly click retry this.messenger.sendChatInputEnabled(tabID, false) return } this.messenger.sendCodeResult( filePaths, deletedFiles, session.state.references ?? [], tabID, session.uploadId, session.state.codeGenerationId ?? '' ) const remainingIterations = session.state.codeGenerationRemainingIterationCount const totalIterations = session.state.codeGenerationTotalIterationCount if (remainingIterations !== undefined && totalIterations !== undefined) { this.messenger.sendAnswer({ type: 'answer' as const, tabID: tabID, message: (() => { if (remainingIterations > 2) { return 'Would you like me to add this code to your project, or provide feedback for new code?' } else if (remainingIterations > 0) { return `Would you like me to add this code to your project, or provide feedback for new code? You have ${remainingIterations} out of ${totalIterations} code generations left.` } else { return 'Would you like me to add this code to your project?' } })(), }) } if (session?.state.phase === DevPhase.CODEGEN) { const messageId = randomUUID() session.updateAcceptCodeMessageId(messageId) session.updateAcceptCodeTelemetrySent(false) // need to add the followUps with an extra update here, or it will double-render them this.messenger.sendAnswer({ message: undefined, type: 'system-prompt', followUps: [], tabID: tabID, messageId, }) await session.updateChatAnswer(tabID, i18n('AWS.amazonq.featureDev.pillText.acceptAllChanges')) await session.sendLinesOfCodeGeneratedTelemetry() } this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.selectOption')) } catch (err: any) { getLogger().error(`${featureName}: Error during code generation: ${err}`) await session.sendMetricDataTelemetry(MetricDataOperationName.EndCodeGeneration, getMetricResult(err)) throw err } finally { // Finish processing the event if (session?.state?.tokenSource?.token.isCancellationRequested) { await this.workOnNewTask( session.tabID, session.state.codeGenerationRemainingIterationCount, session.state.codeGenerationTotalIterationCount, session?.state?.tokenSource?.token.isCancellationRequested ) this.disposeToken(session) } else { this.messenger.sendAsyncEventProgress(tabID, false, undefined) // Lock the chat input until they explicitly click one of the follow ups this.messenger.sendChatInputEnabled(tabID, false) if (!this.isAmazonQVisible) { const open = 'Open chat' const resp = await vscode.window.showInformationMessage( i18n('AWS.amazonq.featureDev.answer.qGeneratedCode'), open ) if (resp === open) { await Commands.tryExecute('aws.amazonq.AmazonQChatView.focus') // TODO add focusing on the specific tab once that's implemented } } } } await session.sendMetricDataTelemetry(MetricDataOperationName.EndCodeGeneration, MetricDataResult.Success) } private sendUpdateCodeMessage(tabID: string) { this.messenger.sendAnswer({ type: 'answer', tabID, message: i18n('AWS.amazonq.featureDev.answer.updateCode'), canBeVoted: true, }) } private async workOnNewTask( tabID: string, remainingIterations: number = 0, totalIterations?: number, isStoppedGeneration: boolean = false ) { const hasDevFile = await checkForDevFile((await this.sessionStorage.getSession(tabID)).getWorkspaceRoot()) if (isStoppedGeneration) { this.messenger.sendAnswer({ message: ((remainingIterations) => { if (totalIterations !== undefined) { if (remainingIterations <= 0) { return "I stopped generating your code. You don't have more iterations left, however, you can start a new session." } else if (remainingIterations <= 2) { return `I stopped generating your code. If you want to continue working on this task, provide another description. You have ${remainingIterations} out of ${totalIterations} code generations left.` } } return 'I stopped generating your code. If you want to continue working on this task, provide another description.' })(remainingIterations), type: 'answer-part', tabID, }) } if ((remainingIterations <= 0 && isStoppedGeneration) || !isStoppedGeneration) { const followUps: Array<ChatItemAction> = [ { pillText: i18n('AWS.amazonq.featureDev.pillText.newTask'), type: FollowUpTypes.NewTask, status: 'info', }, { pillText: i18n('AWS.amazonq.featureDev.pillText.closeSession'), type: FollowUpTypes.CloseSession, status: 'info', }, ] if (!hasDevFile) { followUps.push({ pillText: i18n('AWS.amazonq.featureDev.pillText.generateDevFile'), type: FollowUpTypes.GenerateDevFile, status: 'info', }) this.messenger.sendAnswer({ type: 'answer', tabID, message: i18n('AWS.amazonq.featureDev.answer.devFileSuggestion'), }) } this.messenger.sendAnswer({ type: 'system-prompt', tabID, followUps, }) this.messenger.sendChatInputEnabled(tabID, false) this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.selectOption')) return } // Ensure that chat input is enabled so that they can provide additional iterations if they choose this.messenger.sendChatInputEnabled(tabID, true) this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.placeholder.additionalImprovements')) } private async processAutoBuildSetting(setting: boolean, msg: any) { const root = (await this.sessionStorage.getSession(msg.tabID)).getWorkspaceRoot() await CodeWhispererSettings.instance.updateAutoBuildSetting(root, setting) this.messenger.sendAnswer({ message: i18n('AWS.amazonq.featureDev.answer.settingUpdated'), tabID: msg.tabID, type: 'answer', }) await this.retryRequest(msg) } // TODO add type private async insertCode(message: any) { let session try { session = await this.sessionStorage.getSession(message.tabID) const acceptedFiles = (paths?: { rejected: boolean }[]) => (paths || []).filter((i) => !i.rejected).length const filesAccepted = acceptedFiles(session.state.filePaths) + acceptedFiles(session.state.deletedFiles) this.sendAcceptCodeTelemetry(session, filesAccepted) await session.insertChanges() if (session.acceptCodeMessageId) { this.sendUpdateCodeMessage(message.tabID) await this.workOnNewTask( message.tabID, session.state.codeGenerationRemainingIterationCount, session.state.codeGenerationTotalIterationCount ) await this.clearAcceptCodeMessageId(message.tabID) } } catch (err: any) { this.messenger.sendErrorMessage( createUserFacingErrorMessage(`Failed to insert code changes: ${err.message}`), message.tabID, this.retriesRemaining(session), session?.conversationIdUnsafe ) } } private async provideFeedbackAndRegenerateCode(message: any) { const session = await this.sessionStorage.getSession(message.tabID) telemetry.amazonq_isProvideFeedbackForCodeGen.emit({ amazonqConversationId: session.conversationId, enabled: true, result: 'Succeeded', credentialStartUrl: AuthUtil.instance.startUrl, }) // Unblock the message button this.messenger.sendAsyncEventProgress(message.tabID, false, undefined) this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: i18n('AWS.amazonq.featureDev.answer.howCodeCanBeImproved'), canBeVoted: true, }) this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.placeholder.feedback')) } private async retryRequest(message: any) { let session try { this.messenger.sendAsyncEventProgress(message.tabID, true, undefined) session = await this.sessionStorage.getSession(message.tabID) // Decrease retries before making this request, just in case this one fails as well session.decreaseRetries() // Sending an empty message will re-run the last state with the previous values await this.processUserChatMessage({ message: session.latestMessage, tabID: message.tabID, }) } catch (err: any) { this.messenger.sendErrorMessage( createUserFacingErrorMessage(`Failed to retry request: ${err.message}`), message.tabID, this.retriesRemaining(session), session?.conversationIdUnsafe ) } finally { // Finish processing the event this.messenger.sendAsyncEventProgress(message.tabID, false, undefined) } } private async modifyDefaultSourceFolder(message: any) { const session = await this.sessionStorage.getSession(message.tabID) const uri = await createSingleFileDialog({ canSelectFolders: true, canSelectFiles: false, }).prompt() let metricData: { result: 'Succeeded' } | { result: 'Failed'; reason: string } | undefined if (!(uri instanceof vscode.Uri)) { this.messenger.sendAnswer({ tabID: message.tabID, type: 'system-prompt', followUps: [ { pillText: i18n('AWS.amazonq.featureDev.pillText.selectFiles'), type: 'ModifyDefaultSourceFolder', status: 'info', }, ], }) metricData = { result: 'Failed', reason: 'ClosedBeforeSelection' } } else if (!vscode.workspace.getWorkspaceFolder(uri)) { this.messenger.sendAnswer({ tabID: message.tabID, type: 'answer', message: new SelectedFolderNotInWorkspaceFolderError().message, canBeVoted: true, }) this.messenger.sendAnswer({ tabID: message.tabID, type: 'system-prompt', followUps: [ { pillText: i18n('AWS.amazonq.featureDev.pillText.selectFiles'), type: 'ModifyDefaultSourceFolder', status: 'info', }, ], }) metricData = { result: 'Failed', reason: 'NotInWorkspaceFolder' } } else { session.updateWorkspaceRoot(uri.fsPath) metricData = { result: 'Succeeded' } this.messenger.sendAnswer({ message: `Changed source root to: ${uri.fsPath}`, type: 'answer', tabID: message.tabID, canBeVoted: true, }) this.messenger.sendAnswer({ message: undefined, type: 'system-prompt', followUps: [ { pillText: i18n('AWS.amazonq.featureDev.pillText.retry'), type: FollowUpTypes.Retry, status: 'warning', }, ], tabID: message.tabID, }) this.messenger.sendChatInputEnabled(message.tabID, true) this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.pillText.writeNewPrompt')) } telemetry.amazonq_modifySourceFolder.emit({ credentialStartUrl: AuthUtil.instance.startUrl, amazonqConversationId: session.conversationId, ...metricData, }) } private initialExamples(message: any) { this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: examples, canBeVoted: true, }) } private async fileClicked(message: fileClickedMessage) { // TODO: add Telemetry here const tabId: string = message.tabID const messageId = message.messageId const filePathToUpdate: string = message.filePath const action = message.actionName const session = await this.sessionStorage.getSession(tabId) const filePathIndex = (session.state.filePaths ?? []).findIndex((obj) => obj.relativePath === filePathToUpdate) const deletedFilePathIndex = (session.state.deletedFiles ?? []).findIndex( (obj) => obj.relativePath === filePathToUpdate ) if (filePathIndex !== -1 && session.state.filePaths) { if (action === 'accept-change') { this.sendAcceptCodeTelemetry(session, 1) await session.insertNewFiles([session.state.filePaths[filePathIndex]]) await session.insertCodeReferenceLogs(session.state.references ?? []) await this.openFile(session.state.filePaths[filePathIndex], tabId) } else { session.state.filePaths[filePathIndex].rejected = !session.state.filePaths[filePathIndex].rejected } } if (deletedFilePathIndex !== -1 && session.state.deletedFiles) { if (action === 'accept-change') { this.sendAcceptCodeTelemetry(session, 1) await session.applyDeleteFiles([session.state.deletedFiles[deletedFilePathIndex]]) await session.insertCodeReferenceLogs(session.state.references ?? []) } else { session.state.deletedFiles[deletedFilePathIndex].rejected = !session.state.deletedFiles[deletedFilePathIndex].rejected } } await session.updateFilesPaths({ tabID: tabId, filePaths: session.state.filePaths ?? [], deletedFiles: session.state.deletedFiles ?? [], messageId, }) if (session.acceptCodeMessageId) { const allFilePathsAccepted = session.state.filePaths?.every( (filePath: NewFileInfo) => !filePath.rejected && filePath.changeApplied ) const allDeletedFilePathsAccepted = session.state.deletedFiles?.every( (filePath: DeletedFileInfo) => !filePath.rejected && filePath.changeApplied ) if (allFilePathsAccepted && allDeletedFilePathsAccepted) { this.sendUpdateCodeMessage(tabId) await this.workOnNewTask( tabId, session.state.codeGenerationRemainingIterationCount, session.state.codeGenerationTotalIterationCount ) await this.clearAcceptCodeMessageId(tabId) } } } private async storeCodeResultMessageId(message: StoreMessageIdMessage) { const tabId: string = message.tabID const messageId = message.messageId const session = await this.sessionStorage.getSession(tabId) session.updateCodeResultMessageId(messageId) } private async openDiff(message: OpenDiffMessage) { const tabId: string = message.tabID const codeGenerationId: string = message.messageId const zipFilePath: string = message.filePath const session = await this.sessionStorage.getSession(tabId) telemetry.amazonq_isReviewedChanges.emit({ amazonqConversationId: session.conversationId, enabled: true, result: 'Succeeded', credentialStartUrl: AuthUtil.instance.startUrl, }) const workspacePrefixMapping = getWorkspaceFoldersByPrefixes(session.config.workspaceFolders) const pathInfos = getPathsFromZipFilePath(zipFilePath, workspacePrefixMapping, session.config.workspaceFolders) if (message.deleted) { const name = path.basename(pathInfos.relativePath) await openDeletedDiff(pathInfos.absolutePath, name, tabId, this.scheme) } else { let uploadId = session.uploadId if (session?.state?.uploadHistory && session.state.uploadHistory[codeGenerationId]) { uploadId = session?.state?.uploadHistory[codeGenerationId].uploadId } const rightPath = path.join(uploadId, zipFilePath) await openDiff(pathInfos.absolutePath, rightPath, tabId, this.scheme) } } private async openFile(filePath: NewFileInfo, tabId: string) { const leftPath = path.join(filePath.workspaceFolder.uri.fsPath, filePath.relativePath) const rightPath = filePath.virtualMemoryUri.path await openDiff(leftPath, rightPath, tabId, this.scheme) } private async stopResponse(message: any) { telemetry.ui_click.emit({ elementId: 'amazonq_stopCodeGeneration' }) this.messenger.sendAnswer({ message: i18n('AWS.amazonq.featureDev.pillText.stoppingCodeGeneration'), type: 'answer-part', tabID: message.tabID, }) this.messenger.sendUpdatePlaceholder( message.tabID, i18n('AWS.amazonq.featureDev.pillText.stoppingCodeGeneration') ) this.messenger.sendChatInputEnabled(message.tabID, false) const session = await this.sessionStorage.getSession(message.tabID) if (session.state?.tokenSource) { session.state?.tokenSource?.cancel() } } private async tabOpened(message: any) { let session: Session | undefined try { session = await this.sessionStorage.getSession(message.tabID) getLogger().debug(`${featureName}: Session created with id: ${session.tabID}`) const authState = await AuthUtil.instance.getChatAuthState() if (authState.amazonQ !== 'connected') { void this.messenger.sendAuthNeededExceptionMessage(authState, message.tabID) session.isAuthenticating = true return } } catch (err: any) { if (err instanceof WorkspaceFolderNotFoundError) { this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: err.message, }) this.messenger.sendChatInputEnabled(message.tabID, false) } else { this.messenger.sendErrorMessage( createUserFacingErrorMessage(err.message), message.tabID, this.retriesRemaining(session), session?.conversationIdUnsafe ) } } } private authClicked(message: any) { this.authController.handleAuth(message.authType) this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: i18n('AWS.amazonq.featureDev.pillText.reauthenticate'), }) // Explicitly ensure the user goes through the re-authenticate flow this.messenger.sendChatInputEnabled(message.tabID, false) } private tabClosed(message: any) { this.sessionStorage.deleteSession(message.tabID) } private async newTask(message: any, prefilledPrompt?: string) { // Old session for the tab is ending, delete it so we can create a new one for the message id const session = await this.sessionStorage.getSession(message.tabID) await session.disableFileList() telemetry.amazonq_endChat.emit({ amazonqConversationId: session.conversationId, amazonqEndOfTheConversationLatency: performance.now() - session.telemetry.sessionStartTime, result: 'Succeeded', }) this.sessionStorage.deleteSession(message.tabID) // Re-run the opening flow, where we check auth + create a session await this.tabOpened(message) if (prefilledPrompt) { await this.processUserChatMessage({ ...message, message: prefilledPrompt }) } else { this.messenger.sendChatInputEnabled(message.tabID, true) this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.placeholder.describe')) } } private async closeSession(message: any) { this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: i18n('AWS.amazonq.featureDev.answer.sessionClosed'), }) this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.placeholder.sessionClosed')) this.messenger.sendChatInputEnabled(message.tabID, false) const session = await this.sessionStorage.getSession(message.tabID) await session.disableFileList() telemetry.amazonq_endChat.emit({ amazonqConversationId: session.conversationId, amazonqEndOfTheConversationLatency: performance.now() - session.telemetry.sessionStartTime, result: 'Succeeded', }) } private sendFeedback() { void submitFeedback(placeholder, 'Amazon Q') } private processLink(message: any) { void openUrl(vscode.Uri.parse(message.link)) } private insertCodeAtPosition(message: any) { this.contentController.insertTextAtCursorPosition(message.code, () => {}) } private retriesRemaining(session: Session | undefined) { return session?.retries ?? defaultRetryLimit } private async clearAcceptCodeMessageId(tabID: string) { const session = await this.sessionStorage.getSession(tabID) session.updateAcceptCodeMessageId(undefined) } private sendAcceptCodeTelemetry(session: Session, amazonqNumberOfFilesAccepted: number) { // accepted code telemetry is only to be sent once per iteration of code generation if (amazonqNumberOfFilesAccepted > 0 && !session.acceptCodeTelemetrySent) { session.updateAcceptCodeTelemetrySent(true) telemetry.amazonq_isAcceptedCodeChanges.emit({ credentialStartUrl: AuthUtil.instance.startUrl, amazonqConversationId: session.conversationId, amazonqNumberOfFilesAccepted, enabled: true, result: 'Succeeded', }) } } }