packages/amazonq/src/lsp/chat/messages.ts (512 lines of code) (raw):

/*! * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ import { isValidAuthFollowUpType, INSERT_TO_CURSOR_POSITION, AUTH_FOLLOW_UP_CLICKED, CHAT_OPTIONS, COPY_TO_CLIPBOARD, AuthFollowUpType, DISCLAIMER_ACKNOWLEDGED, UiMessageResultParams, CHAT_PROMPT_OPTION_ACKNOWLEDGED, ChatPromptOptionAcknowledgedMessage, STOP_CHAT_RESPONSE, StopChatResponseMessage, } from '@aws/chat-client-ui-types' import { ChatResult, chatRequestType, ChatParams, followUpClickNotificationType, quickActionRequestType, QuickActionResult, QuickActionParams, insertToCursorPositionNotificationType, ErrorCodes, ResponseError, openTabRequestType, getSerializedChatRequestType, listConversationsRequestType, conversationClickRequestType, ShowSaveFileDialogRequestType, ShowSaveFileDialogParams, LSPErrorCodes, tabBarActionRequestType, ShowDocumentParams, ShowDocumentResult, ShowDocumentRequest, contextCommandsNotificationType, ContextCommandParams, openFileDiffNotificationType, OpenFileDiffParams, LINK_CLICK_NOTIFICATION_METHOD, LinkClickParams, INFO_LINK_CLICK_NOTIFICATION_METHOD, buttonClickRequestType, ButtonClickResult, CancellationTokenSource, chatUpdateNotificationType, ChatUpdateParams, } from '@aws/language-server-runtimes/protocol' import { v4 as uuidv4 } from 'uuid' import * as vscode from 'vscode' import { Disposable, LanguageClient, Position, TextDocumentIdentifier } from 'vscode-languageclient' import * as jose from 'jose' import { AmazonQChatViewProvider } from './webviewProvider' import { AuthUtil, ReferenceLogViewProvider } from 'aws-core-vscode/codewhisperer' import { amazonQDiffScheme, AmazonQPromptSettings, messages, openUrl } from 'aws-core-vscode/shared' import { DefaultAmazonQAppInitContext, messageDispatcher, EditorContentController, ViewDiffMessage, referenceLogText, } from 'aws-core-vscode/amazonq' import { telemetry, TelemetryBase } from 'aws-core-vscode/telemetry' import { isValidResponseError } from './error' export function registerLanguageServerEventListener(languageClient: LanguageClient, provider: AmazonQChatViewProvider) { languageClient.info( 'Language client received initializeResult from server:', JSON.stringify(languageClient.initializeResult) ) const chatOptions = languageClient.initializeResult?.awsServerCapabilities?.chatOptions // overide the quick action commands provided by flare server initialization, which doesn't provide the group header if (chatOptions?.quickActions?.quickActionsCommandGroups?.[0]) { chatOptions.quickActions.quickActionsCommandGroups[0].groupName = 'Quick Actions' } provider.onDidResolveWebview(() => { void provider.webview?.postMessage({ command: CHAT_OPTIONS, params: chatOptions, }) }) // This passes through metric data from LSP events to Toolkit telemetry with all fields from the LSP server languageClient.onTelemetry((e) => { const telemetryName: string = e.name if (telemetryName in telemetry) { telemetry[telemetryName as keyof TelemetryBase].emit(e.data) } }) } function getCursorState(selection: readonly vscode.Selection[]) { return selection.map((s) => ({ range: { start: { line: s.start.line, character: s.start.character, }, end: { line: s.end.line, character: s.end.character, }, }, })) } export function registerMessageListeners( languageClient: LanguageClient, provider: AmazonQChatViewProvider, encryptionKey: Buffer ) { const chatStreamTokens = new Map<string, CancellationTokenSource>() // tab id -> token provider.webview?.onDidReceiveMessage(async (message) => { languageClient.info(`[VSCode Client] Received ${JSON.stringify(message)} from chat`) if ((message.tabType && message.tabType !== 'cwc') || messageDispatcher.isLegacyEvent(message.command)) { // handle the mynah ui -> agent legacy flow messageDispatcher.handleWebviewEvent( message, DefaultAmazonQAppInitContext.instance.getWebViewToAppsMessagePublishers() ) return } const webview = provider.webview switch (message.command) { case COPY_TO_CLIPBOARD: languageClient.info('[VSCode Client] Copy to clipboard event received') try { await messages.copyToClipboard(message.params.code) } catch (e) { languageClient.error(`[VSCode Client] Failed to copy to clipboard: ${(e as Error).message}`) } break case INSERT_TO_CURSOR_POSITION: { const editor = vscode.window.activeTextEditor let textDocument: TextDocumentIdentifier | undefined = undefined let cursorPosition: Position | undefined = undefined if (editor) { cursorPosition = editor.selection.active textDocument = { uri: editor.document.uri.toString() } } languageClient.sendNotification(insertToCursorPositionNotificationType.method, { ...message.params, cursorPosition, textDocument, }) break } case AUTH_FOLLOW_UP_CLICKED: { languageClient.info('[VSCode Client] AuthFollowUp clicked') const authType = message.params.authFollowupType const reAuthTypes: AuthFollowUpType[] = ['re-auth', 'missing_scopes'] const fullAuthTypes: AuthFollowUpType[] = ['full-auth', 'use-supported-auth'] if (reAuthTypes.includes(authType)) { try { await AuthUtil.instance.reauthenticate() } catch (e) { languageClient.error( `[VSCode Client] Failed to re-authenticate after AUTH_FOLLOW_UP_CLICKED: ${(e as Error).message}` ) } } if (fullAuthTypes.includes(authType)) { try { await AuthUtil.instance.secondaryAuth.deleteConnection() } catch (e) { languageClient.error( `[VSCode Client] Failed to authenticate after AUTH_FOLLOW_UP_CLICKED: ${(e as Error).message}` ) } } break } case DISCLAIMER_ACKNOWLEDGED: { void AmazonQPromptSettings.instance.update('amazonQChatDisclaimer', true) break } case CHAT_PROMPT_OPTION_ACKNOWLEDGED: { const acknowledgedMessage = message as ChatPromptOptionAcknowledgedMessage switch (acknowledgedMessage.params.messageId) { case 'programmerModeCardId': { void AmazonQPromptSettings.instance.disablePrompt('amazonQChatPairProgramming') } } break } case INFO_LINK_CLICK_NOTIFICATION_METHOD: case LINK_CLICK_NOTIFICATION_METHOD: { const linkParams = message.params as LinkClickParams void openUrl(vscode.Uri.parse(linkParams.link)) break } case STOP_CHAT_RESPONSE: { const tabId = (message as StopChatResponseMessage).params.tabId const token = chatStreamTokens.get(tabId) token?.cancel() token?.dispose() chatStreamTokens.delete(tabId) break } case chatRequestType.method: { const chatParams: ChatParams = { ...message.params } const partialResultToken = uuidv4() let lastPartialResult: ChatResult | undefined const cancellationToken = new CancellationTokenSource() chatStreamTokens.set(chatParams.tabId, cancellationToken) const chatDisposable = languageClient.onProgress( chatRequestType, partialResultToken, (partialResult) => { // Store the latest partial result if (typeof partialResult === 'string' && encryptionKey) { void decodeRequest<ChatResult>(partialResult, encryptionKey).then( (decoded) => (lastPartialResult = decoded) ) } else { lastPartialResult = partialResult as ChatResult } void handlePartialResult<ChatResult>(partialResult, encryptionKey, provider, chatParams.tabId) } ) const editor = vscode.window.activeTextEditor || vscode.window.visibleTextEditors.find((editor) => editor.document.languageId !== 'Log') if (editor) { chatParams.cursorState = getCursorState(editor.selections) chatParams.textDocument = { uri: editor.document.uri.toString() } } const chatRequest = await encryptRequest<ChatParams>(chatParams, encryptionKey) try { const chatResult = await languageClient.sendRequest<string | ChatResult>( chatRequestType.method, { ...chatRequest, partialResultToken, }, cancellationToken.token ) await handleCompleteResult<ChatResult>( chatResult, encryptionKey, provider, chatParams.tabId, chatDisposable ) } catch (e) { const errorMsg = `Error occurred during chat request: ${e}` languageClient.info(errorMsg) languageClient.info( `Last result from langauge server: ${JSON.stringify(lastPartialResult, undefined, 2)}` ) if (!isValidResponseError(e)) { throw e } await handleCompleteResult<ChatResult>( e.data, encryptionKey, provider, chatParams.tabId, chatDisposable ) } finally { chatStreamTokens.delete(chatParams.tabId) } break } case quickActionRequestType.method: { const quickActionPartialResultToken = uuidv4() const quickActionDisposable = languageClient.onProgress( quickActionRequestType, quickActionPartialResultToken, (partialResult) => handlePartialResult<QuickActionResult>( partialResult, encryptionKey, provider, message.params.tabId ) ) const quickActionRequest = await encryptRequest<QuickActionParams>(message.params, encryptionKey) const quickActionResult = (await languageClient.sendRequest(quickActionRequestType.method, { ...quickActionRequest, partialResultToken: quickActionPartialResultToken, })) as string | ChatResult void handleCompleteResult<ChatResult>( quickActionResult, encryptionKey, provider, message.params.tabId, quickActionDisposable ) break } case listConversationsRequestType.method: case conversationClickRequestType.method: case tabBarActionRequestType.method: await resolveChatResponse(message.command, message.params, languageClient, webview) break case followUpClickNotificationType.method: if (!isValidAuthFollowUpType(message.params.followUp.type)) { languageClient.sendNotification(followUpClickNotificationType.method, message.params) } break case buttonClickRequestType.method: { const buttonResult = await languageClient.sendRequest<ButtonClickResult>( buttonClickRequestType.method, message.params ) if (!buttonResult.success) { languageClient.error( `[VSCode Client] Failed to execute action associated with button with reason: ${buttonResult.failureReason}` ) } break } default: if (isServerEvent(message.command)) { languageClient.sendNotification(message.command, message.params) } break } }, undefined) const registerHandlerWithResponseRouter = (command: string) => { const handler = async (params: any, _: any) => { const mapErrorType = (type: string | undefined): number => { switch (type) { case 'InvalidRequest': return ErrorCodes.InvalidRequest case 'InternalError': return ErrorCodes.InternalError case 'UnknownError': default: return ErrorCodes.UnknownErrorCode } } const requestId = uuidv4() void provider.webview?.postMessage({ requestId: requestId, command: command, params: params, }) const responsePromise = new Promise<UiMessageResultParams | undefined>((resolve, reject) => { const timeout = setTimeout(() => { disposable?.dispose() reject(new Error('Request timed out')) }, 30000) const disposable = provider.webview?.onDidReceiveMessage((message: any) => { if (message.requestId === requestId) { clearTimeout(timeout) disposable?.dispose() resolve(message.params) } }) }) const result = await responsePromise if (result?.success) { return result.result } else { return new ResponseError( mapErrorType(result?.error.type), result?.error.message ?? 'No response from client' ) } } languageClient.onRequest(command, handler) } registerHandlerWithResponseRouter(openTabRequestType.method) registerHandlerWithResponseRouter(getSerializedChatRequestType.method) languageClient.onRequest(ShowSaveFileDialogRequestType.method, async (params: ShowSaveFileDialogParams) => { const filters: Record<string, string[]> = {} const formatMappings = [ { format: 'markdown', key: 'Markdown', extensions: ['md'] }, { format: 'html', key: 'HTML', extensions: ['html'] }, ] for (const format of params.supportedFormats ?? []) { const mapping = formatMappings.find((m) => m.format === format) if (mapping) { filters[mapping.key] = mapping.extensions } } const saveAtUri = params.defaultUri ? vscode.Uri.parse(params.defaultUri) : vscode.Uri.file('export-chat.md') const targetUri = await vscode.window.showSaveDialog({ filters, defaultUri: saveAtUri, title: 'Export', }) if (!targetUri) { return new ResponseError(LSPErrorCodes.RequestFailed, 'Export failed') } return { targetUri: targetUri.toString(), } }) languageClient.onRequest<ShowDocumentParams, ShowDocumentResult>( ShowDocumentRequest.method, async (params: ShowDocumentParams): Promise<ShowDocumentParams | ResponseError<ShowDocumentResult>> => { try { const uri = vscode.Uri.parse(params.uri) const doc = await vscode.workspace.openTextDocument(uri) await vscode.window.showTextDocument(doc, { preview: false }) return params } catch (e) { return new ResponseError( LSPErrorCodes.RequestFailed, `Failed to open document: ${(e as Error).message}` ) } } ) languageClient.onNotification(contextCommandsNotificationType.method, (params: ContextCommandParams) => { void provider.webview?.postMessage({ command: contextCommandsNotificationType.method, params: params, }) }) languageClient.onNotification(openFileDiffNotificationType.method, async (params: OpenFileDiffParams) => { const ecc = new EditorContentController() const uri = params.originalFileUri const doc = await vscode.workspace.openTextDocument(uri) const entireDocumentSelection = new vscode.Selection( new vscode.Position(0, 0), new vscode.Position(doc.lineCount - 1, doc.lineAt(doc.lineCount - 1).text.length) ) const viewDiffMessage: ViewDiffMessage = { context: { activeFileContext: { filePath: params.originalFileUri, fileText: params.originalFileContent ?? '', fileLanguage: undefined, matchPolicy: undefined, }, focusAreaContext: { selectionInsideExtendedCodeBlock: entireDocumentSelection, codeBlock: '', extendedCodeBlock: '', names: undefined, }, }, code: params.fileContent ?? '', } await ecc.viewDiff(viewDiffMessage, amazonQDiffScheme) }) languageClient.onNotification(chatUpdateNotificationType.method, (params: ChatUpdateParams) => { void provider.webview?.postMessage({ command: chatUpdateNotificationType.method, params: params, }) }) } function isServerEvent(command: string) { return command.startsWith('aws/chat/') || command === 'telemetry/event' } async function encryptRequest<T>(params: T, encryptionKey: Buffer): Promise<{ message: string } | T> { const payload = new TextEncoder().encode(JSON.stringify(params)) const encryptedMessage = await new jose.CompactEncrypt(payload) .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) .encrypt(encryptionKey) return { message: encryptedMessage } } async function decodeRequest<T>(request: string, key: Buffer): Promise<T> { const result = await jose.jwtDecrypt(request, key, { clockTolerance: 60, // Allow up to 60 seconds to account for clock differences contentEncryptionAlgorithms: ['A256GCM'], keyManagementAlgorithms: ['dir'], }) if (!result.payload) { throw new Error('JWT payload not found') } return result.payload as T } /** * Decodes partial chat responses from the language server before sending them to mynah UI */ async function handlePartialResult<T extends ChatResult>( partialResult: string | T, encryptionKey: Buffer | undefined, provider: AmazonQChatViewProvider, tabId: string ) { const decryptedMessage = typeof partialResult === 'string' && encryptionKey ? await decodeRequest<T>(partialResult, encryptionKey) : (partialResult as T) if (decryptedMessage.body !== undefined) { void provider.webview?.postMessage({ command: chatRequestType.method, params: decryptedMessage, isPartialResult: true, tabId: tabId, }) } } /** * Decodes the final chat responses from the language server before sending it to mynah UI. * Once this is called the answer response is finished */ async function handleCompleteResult<T extends ChatResult>( result: string | T, encryptionKey: Buffer | undefined, provider: AmazonQChatViewProvider, tabId: string, disposable: Disposable ) { const decryptedMessage = typeof result === 'string' && encryptionKey ? await decodeRequest<T>(result, encryptionKey) : (result as T) void provider.webview?.postMessage({ command: chatRequestType.method, params: decryptedMessage, tabId: tabId, }) // only add the reference log once the request is complete, otherwise we will get duplicate log items for (const ref of decryptedMessage.codeReference ?? []) { ReferenceLogViewProvider.instance.addReferenceLog(referenceLogText(ref)) } disposable.dispose() } async function resolveChatResponse( requestMethod: string, params: any, languageClient: LanguageClient, webview: vscode.Webview | undefined ) { const result = await languageClient.sendRequest(requestMethod, params) void webview?.postMessage({ command: requestMethod, params: result, }) }