diff --git a/.github/workflows/create-release-candidate-branch.yml b/.github/workflows/create-release-candidate-branch.yml index aa37873d2a..2071f69ed4 100644 --- a/.github/workflows/create-release-candidate-branch.yml +++ b/.github/workflows/create-release-candidate-branch.yml @@ -44,6 +44,10 @@ jobs: node-version: '20' cache: 'npm' + # Needed to format the json file being checked in + - name: Install dependencies + run: npm ci + - name: Calculate Release Version id: release-version env: @@ -87,6 +91,9 @@ jobs: git add "$VERSION_FILE" + # Ensure the file does not cause issues when merged to main + npm run format-staged + - name: Create Release Candidate Branch id: release-branch env: @@ -112,5 +119,5 @@ jobs: git config --global user.name "aws-toolkit-automation" # Configure git to use the PAT token for authentication git remote set-url origin "https://x-access-token:${REPO_PAT}@github.com/${{ github.repository }}.git" - git commit -m "Bump agentic version: $RELEASE_VERSION" + git commit -m "chore: bump agentic version: $RELEASE_VERSION" git push --set-upstream origin "$BRANCH_NAME" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c3df1b76e7..e6abdf7be2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,8 +1,8 @@ { - "chat-client": "0.1.31", + "chat-client": "0.1.32", "core/aws-lsp-core": "0.0.13", "server/aws-lsp-antlr4": "0.1.17", - "server/aws-lsp-codewhisperer": "0.0.72", + "server/aws-lsp-codewhisperer": "0.0.73", "server/aws-lsp-json": "0.1.17", "server/aws-lsp-partiql": "0.0.16", "server/aws-lsp-yaml": "0.1.17" diff --git a/app/aws-lsp-codewhisperer-runtimes/src/version.json b/app/aws-lsp-codewhisperer-runtimes/src/version.json index 2381334b11..4c93c3f549 100644 --- a/app/aws-lsp-codewhisperer-runtimes/src/version.json +++ b/app/aws-lsp-codewhisperer-runtimes/src/version.json @@ -1,3 +1,3 @@ { - "agenticChat": "1.26.0" + "agenticChat": "1.27.0" } diff --git a/chat-client/CHANGELOG.md b/chat-client/CHANGELOG.md index aa178a18e6..1cd9acfbe0 100644 --- a/chat-client/CHANGELOG.md +++ b/chat-client/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.1.32](https://github.com/aws/language-servers/compare/chat-client/v0.1.31...chat-client/v0.1.32) (2025-08-11) + + +### Features + +* **amazonq:** read tool ui revamp ([c65428b](https://github.com/aws/language-servers/commit/c65428bab2cf5e47badf1e3a9453babcf881e60c)) + ## [0.1.31](https://github.com/aws/language-servers/compare/chat-client/v0.1.30...chat-client/v0.1.31) (2025-08-06) diff --git a/chat-client/package.json b/chat-client/package.json index 161cb50a0b..df0378f3e4 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -1,6 +1,6 @@ { "name": "@aws/chat-client", - "version": "0.1.31", + "version": "0.1.32", "description": "AWS Chat Client", "main": "out/index.js", "repository": { diff --git a/chat-client/src/client/mynahUi.ts b/chat-client/src/client/mynahUi.ts index 527dea06f3..3b42249331 100644 --- a/chat-client/src/client/mynahUi.ts +++ b/chat-client/src/client/mynahUi.ts @@ -1353,15 +1353,10 @@ export const createMynahUi = ( fileTreeTitle: '', hideFileCount: true, details: toDetailsWithoutIcon(header.fileList.details), - renderAsPills: - !header.fileList.details || - (Object.values(header.fileList.details).every(detail => !detail.changes) && - (!header.buttons || !header.buttons.some(button => button.id === 'undo-changes')) && - !header.status?.icon), } } if (!isPartialResult) { - if (processedHeader && !message.header?.status) { + if (processedHeader) { processedHeader.status = undefined } } @@ -1374,8 +1369,7 @@ export const createMynahUi = ( processedHeader.buttons !== null && processedHeader.buttons.length > 0) || processedHeader.status !== undefined || - processedHeader.icon !== undefined || - processedHeader.fileList !== undefined) + processedHeader.icon !== undefined) const padding = message.type === 'tool' ? (fileList ? true : message.messageId?.endsWith('_permission')) : undefined @@ -1386,10 +1380,8 @@ export const createMynahUi = ( // Adding this conditional check to show the stop message in the center. const contentHorizontalAlignment: ChatItem['contentHorizontalAlignment'] = undefined - // If message.header?.status?.text is Stopped or Rejected or Ignored etc.. card should be in disabled state. - const shouldMute = - message.header?.status?.text !== undefined && - ['Stopped', 'Rejected', 'Ignored', 'Failed', 'Error'].includes(message.header.status.text) + // If message.header?.status?.text is Stopped or Rejected or Ignored or Completed etc.. card should be in disabled state. + const shouldMute = message.header?.status?.text !== undefined && message.header?.status?.text !== 'Completed' return { body: message.body, diff --git a/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts b/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts index efc0467d2f..897ee02071 100644 --- a/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts +++ b/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts @@ -169,11 +169,11 @@ describe('Q Agentic Chat Server Integration Tests', async () => { expect(decryptedResult.additionalMessages).to.be.an('array') const fsReadMessage = decryptedResult.additionalMessages?.find( - msg => msg.type === 'tool' && msg.header?.body === '1 file read' + msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 file read' ) expect(fsReadMessage).to.exist const expectedPath = path.join(rootPath, 'test.py') - const actualPaths = fsReadMessage?.header?.fileList?.filePaths?.map(normalizePath) || [] + const actualPaths = fsReadMessage?.fileList?.filePaths?.map(normalizePath) || [] expect(actualPaths).to.include.members([normalizePath(expectedPath)]) expect(fsReadMessage?.messageId?.startsWith('tooluse_')).to.be.true }) @@ -191,10 +191,10 @@ describe('Q Agentic Chat Server Integration Tests', async () => { expect(decryptedResult.additionalMessages).to.be.an('array') const listDirectoryMessage = decryptedResult.additionalMessages?.find( - msg => msg.type === 'tool' && msg.header?.body === '1 directory listed' + msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory listed' ) expect(listDirectoryMessage).to.exist - const actualPaths = listDirectoryMessage?.header?.fileList?.filePaths?.map(normalizePath) || [] + const actualPaths = listDirectoryMessage?.fileList?.filePaths?.map(normalizePath) || [] expect(actualPaths).to.include.members([normalizePath(rootPath)]) expect(listDirectoryMessage?.messageId?.startsWith('tooluse_')).to.be.true }) @@ -371,12 +371,11 @@ describe('Q Agentic Chat Server Integration Tests', async () => { expect(decryptedResult.additionalMessages).to.be.an('array') const fileSearchMessage = decryptedResult.additionalMessages?.find( - msg => msg.type === 'tool' && msg.header?.body === 'Searched for `test` in ' + msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory searched' ) expect(fileSearchMessage).to.exist expect(fileSearchMessage?.messageId?.startsWith('tooluse_')).to.be.true - expect(fileSearchMessage?.header?.status?.text).to.equal('3 results found') - const actualPaths = fileSearchMessage?.header?.fileList?.filePaths?.map(normalizePath) || [] + const actualPaths = fileSearchMessage?.fileList?.filePaths?.map(normalizePath) || [] expect(actualPaths).to.include.members([normalizePath(rootPath)]) }) }) diff --git a/package-lock.json b/package-lock.json index 6b55b375a3..b42f206c44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -251,7 +251,7 @@ }, "chat-client": { "name": "@aws/chat-client", - "version": "0.1.31", + "version": "0.1.32", "license": "Apache-2.0", "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", @@ -28670,7 +28670,7 @@ }, "server/aws-lsp-codewhisperer": { "name": "@aws/lsp-codewhisperer", - "version": "0.0.72", + "version": "0.0.73", "bundleDependencies": [ "@amzn/codewhisperer-streaming", "@amzn/amazon-q-developer-streaming-client" diff --git a/server/aws-lsp-codewhisperer/CHANGELOG.md b/server/aws-lsp-codewhisperer/CHANGELOG.md index 8497df84be..1b2f1011df 100644 --- a/server/aws-lsp-codewhisperer/CHANGELOG.md +++ b/server/aws-lsp-codewhisperer/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [0.0.73](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.72...lsp-codewhisperer/v0.0.73) (2025-08-11) + + +### Features + +* **amazonq:** read tool ui revamp ([c65428b](https://github.com/aws/language-servers/commit/c65428bab2cf5e47badf1e3a9453babcf881e60c)) + + +### Bug Fixes + +* **amazonq:** add fallback classpath generation ([#2077](https://github.com/aws/language-servers/issues/2077)) ([3a6ef14](https://github.com/aws/language-servers/commit/3a6ef14e78fa2e75b837bba6524751d65038f416)) +* **amazonq:** emit failed status for amazonq_invokeLLM ([#2071](https://github.com/aws/language-servers/issues/2071)) ([ee52a41](https://github.com/aws/language-servers/commit/ee52a41bc869b275fff708d7955b59f43b93bbd4)) +* **amazonq:** fix fallout of [#2051](https://github.com/aws/language-servers/issues/2051) ([#2057](https://github.com/aws/language-servers/issues/2057)) ([565066b](https://github.com/aws/language-servers/commit/565066bb61adda60333c9646db958d4208bcc8af)) +* **amazonq:** leverage lcs to find the chars added and removed ([#2092](https://github.com/aws/language-servers/issues/2092)) ([40379a8](https://github.com/aws/language-servers/commit/40379a887f8d42cc184239ca3175b4e673cc5286)) +* **amazonq:** skips continuous monitoring when WCS sees workspace as idle ([#2066](https://github.com/aws/language-servers/issues/2066)) ([9cb959d](https://github.com/aws/language-servers/commit/9cb959d4cc450d0907f8bf5265ba01d2aa68bcd0)) +* creating a new sesion for Edits trigger with next token ([#2094](https://github.com/aws/language-servers/issues/2094)) ([1da8730](https://github.com/aws/language-servers/commit/1da8730b6ed6ad53b6561368bf722e56d59596a4)) +* remove edit cache logic ([#2079](https://github.com/aws/language-servers/issues/2079)) ([9bc5b9c](https://github.com/aws/language-servers/commit/9bc5b9c1d77e5fee6f518f7f5016d3a0043a5a77)) +* sessionManager misused because there are 2 types of manager now ([#2090](https://github.com/aws/language-servers/issues/2090)) ([8db059a](https://github.com/aws/language-servers/commit/8db059ab83d94fd7c3ba3eb265044add31c80aea)) +* update client name to support Sagemaker AI origin for agentic chat ([#2093](https://github.com/aws/language-servers/issues/2093)) ([a746fe8](https://github.com/aws/language-servers/commit/a746fe845d5e09563b475f01ce44059dca9fd10f)) + ## [0.0.72](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.71...lsp-codewhisperer/v0.0.72) (2025-08-06) diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index cb1bd377fb..fdd34884b1 100644 --- a/server/aws-lsp-codewhisperer/package.json +++ b/server/aws-lsp-codewhisperer/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-codewhisperer", - "version": "0.0.72", + "version": "0.0.73", "description": "CodeWhisperer Language Server", "main": "out/index.js", "repository": { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts index 616848d0b5..6c8af7a272 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts @@ -451,7 +451,7 @@ describe('AgenticChatController', () => { assert.deepStrictEqual(chatResult, { additionalMessages: [], - body: '\nHello World!', + body: '\n\nHello World!', messageId: 'mock-message-id', buttons: [], codeReference: [], @@ -1150,7 +1150,7 @@ describe('AgenticChatController', () => { sinon.assert.callCount(testFeatures.lsp.sendProgress, mockChatResponseList.length + 1) // response length + 1 loading messages assert.deepStrictEqual(chatResult, { additionalMessages: [], - body: '\nHello World!', + body: '\n\nHello World!', messageId: 'mock-message-id', codeReference: [], buttons: [], @@ -1169,7 +1169,7 @@ describe('AgenticChatController', () => { sinon.assert.callCount(testFeatures.lsp.sendProgress, mockChatResponseList.length + 1) // response length + 1 loading message assert.deepStrictEqual(chatResult, { additionalMessages: [], - body: '\nHello World!', + body: '\n\nHello World!', messageId: 'mock-message-id', buttons: [], codeReference: [], diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts index 09bf965819..72217fae07 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -169,7 +169,7 @@ import { ExecuteBash, ExecuteBashParams } from './tools/executeBash' import { ExplanatoryParams, InvokeOutput, ToolApprovalException } from './tools/toolShared' import { validatePathBasic, validatePathExists, validatePaths as validatePathsSync } from './utils/pathValidation' import { GrepSearch, SanitizedRipgrepOutput } from './tools/grepSearch' -import { FileSearch, FileSearchParams, isFileSearchParams } from './tools/fileSearch' +import { FileSearch, FileSearchParams } from './tools/fileSearch' import { FsReplace, FsReplaceParams } from './tools/fsReplace' import { loggingUtils, timeoutUtils } from '@aws/lsp-core' import { diffLines } from 'diff' @@ -1695,7 +1695,8 @@ export class AgenticChatController implements ChatHandlers { // remove progress UI await chatResultStream.removeResultBlockAndUpdateUI(progressPrefix + toolUse.toolUseId) - if (![FS_WRITE, FS_REPLACE].includes(toolUse.name)) { + // fsRead and listDirectory write to an existing card and could show nothing in the current position + if (![FS_WRITE, FS_REPLACE, FS_READ, LIST_DIRECTORY].includes(toolUse.name)) { await this.#showUndoAllIfRequired(chatResultStream, session) } // fsWrite can take a long time, so we render fsWrite Explanatory upon partial streaming responses. @@ -1910,19 +1911,10 @@ export class AgenticChatController implements ChatHandlers { switch (toolUse.name) { case FS_READ: case LIST_DIRECTORY: - const readToolResult = await this.#processReadTool(toolUse, chatResultStream) - if (readToolResult) { - await chatResultStream.writeResultBlock(readToolResult) - } - break case FILE_SEARCH: - if (isFileSearchParams(toolUse.input)) { - await this.#processFileSearchTool( - toolUse.input, - toolUse.toolUseId, - result, - chatResultStream - ) + const initialListDirResult = this.#processReadOrListOrSearch(toolUse, chatResultStream) + if (initialListDirResult) { + await chatResultStream.writeResultBlock(initialListDirResult) } break // no need to write tool result for listDir,fsRead,fileSearch into chat stream @@ -2323,6 +2315,7 @@ export class AgenticChatController implements ChatHandlers { } const toolMsgId = toolUse.toolUseId! + const chatMsgId = chatResultStream.getResult().messageId let headerEmitted = false const initialHeader: ChatMessage['header'] = { @@ -2360,6 +2353,13 @@ export class AgenticChatController implements ChatHandlers { header: completedHeader, }) + await chatResultStream.writeResultBlock({ + type: 'answer', + messageId: chatMsgId, + body: '', + header: undefined, + }) + this.#stoppedToolUses.add(toolMsgId) }, }) @@ -2877,135 +2877,70 @@ export class AgenticChatController implements ChatHandlers { } } - async #processFileSearchTool( - toolInput: FileSearchParams, - toolUseId: string, - result: InvokeOutput, - chatResultStream: AgenticChatResultStream - ): Promise { - if (typeof result.output.content !== 'string') return + #processReadOrListOrSearch(toolUse: ToolUse, chatResultStream: AgenticChatResultStream): ChatMessage | undefined { + let messageIdToUpdate = toolUse.toolUseId! + const currentId = chatResultStream.getMessageIdToUpdateForTool(toolUse.name!) - const { queryName, path: inputPath } = toolInput - const resultCount = result.output.content - .split('\n') - .filter(line => line.trim().startsWith('[F]') || line.trim().startsWith('[D]')).length - - const chatMessage: ChatMessage = { - type: 'tool', - messageId: toolUseId, - header: { - body: `Searched for "${queryName}" in `, - icon: 'search', - status: { - text: `${resultCount} result${resultCount !== 1 ? 's' : ''} found`, - }, - fileList: { - filePaths: [inputPath], - details: { - [inputPath]: { - description: inputPath, - visibleName: path.basename(inputPath), - clickable: false, - }, - }, - }, - }, + if (currentId) { + messageIdToUpdate = currentId + } else { + chatResultStream.setMessageIdToUpdateForTool(toolUse.name!, messageIdToUpdate) } - await chatResultStream.writeResultBlock(chatMessage) - } - - async #processReadTool( - toolUse: ToolUse, - chatResultStream: AgenticChatResultStream - ): Promise { - let currentPaths: string[] = [] + let currentPaths = [] if (toolUse.name === FS_READ) { - currentPaths = (toolUse.input as unknown as FsReadParams)?.paths || [] - } else if (toolUse.name === LIST_DIRECTORY) { - const singlePath = (toolUse.input as unknown as ListDirectoryParams)?.path - if (singlePath) { - currentPaths = [singlePath] - } - } else if (toolUse.name === FILE_SEARCH) { - const queryName = (toolUse.input as unknown as FileSearchParams)?.queryName - if (queryName) { - currentPaths = [queryName] - } + currentPaths = (toolUse.input as unknown as FsReadParams)?.paths } else { - return + currentPaths.push((toolUse.input as unknown as ListDirectoryParams | FileSearchParams)?.path) } - if (currentPaths.length === 0) return - - // Check if the last message is the same tool type - const lastMessage = chatResultStream.getLastMessage() - const isSameToolType = - lastMessage?.type === 'tool' && lastMessage.header?.icon === this.#toolToIcon(toolUse.name) - - let allPaths = currentPaths - - if (isSameToolType && lastMessage.messageId) { - // Combine with existing paths and overwrite the last message - const existingPaths = lastMessage.header?.fileList?.filePaths || [] - allPaths = [...existingPaths, ...currentPaths] + if (!currentPaths) return - const blockId = chatResultStream.getMessageBlockId(lastMessage.messageId) - if (blockId !== undefined) { - // Create the updated message with combined paths - const updatedMessage = this.#createFileListToolMessage(toolUse, allPaths, lastMessage.messageId) - // Overwrite the existing block - await chatResultStream.overwriteResultBlock(updatedMessage, blockId) - return undefined // Don't return a message since we already wrote it + for (const currentPath of currentPaths) { + const existingPaths = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths || [] + // Check if path already exists in the list + const isPathAlreadyProcessed = existingPaths.some(path => path.relativeFilePath === currentPath) + if (!isPathAlreadyProcessed) { + const currentFileDetail = { + relativeFilePath: currentPath, + lineRanges: [{ first: -1, second: -1 }], + } + chatResultStream.addMessageOperation(messageIdToUpdate, toolUse.name!, [ + ...existingPaths, + currentFileDetail, + ]) } } - - // Create new message with current paths - return this.#createFileListToolMessage(toolUse, allPaths, toolUse.toolUseId!) - } - - #createFileListToolMessage(toolUse: ToolUse, filePaths: string[], messageId: string): ChatMessage { - const itemCount = filePaths.length let title: string - if (itemCount === 0) { + const itemCount = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths.length + const filePathsPushed = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths ?? [] + if (!itemCount) { title = 'Gathering context' } else { title = toolUse.name === FS_READ ? `${itemCount} file${itemCount > 1 ? 's' : ''} read` - : toolUse.name === LIST_DIRECTORY - ? `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed` - : '' + : toolUse.name === FILE_SEARCH + ? `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} searched` + : `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed` } const details: Record = {} - for (const filePath of filePaths) { - details[filePath] = { - description: filePath, - visibleName: path.basename(filePath), - clickable: toolUse.name === FS_READ, + for (const item of filePathsPushed) { + details[item.relativeFilePath] = { + lineRanges: item.lineRanges, + description: item.relativeFilePath, } } + + const fileList: FileList = { + rootFolderTitle: title, + filePaths: filePathsPushed.map(item => item.relativeFilePath), + details, + } return { type: 'tool', - header: { - body: title, - icon: this.#toolToIcon(toolUse.name), - fileList: { - filePaths, - details, - }, - }, - messageId, - } - } - - #toolToIcon(toolName: string | undefined): string | undefined { - switch (toolName) { - case FS_READ: - return 'eye' - case LIST_DIRECTORY: - return 'check-list' - default: - return undefined + fileList, + messageId: messageIdToUpdate, + body: '', } } @@ -3021,7 +2956,14 @@ export class AgenticChatController implements ChatHandlers { return undefined } - const messageIdToUpdate = toolUse.toolUseId! + let messageIdToUpdate = toolUse.toolUseId! + const currentId = chatResultStream.getMessageIdToUpdateForTool(toolUse.name!) + + if (currentId) { + messageIdToUpdate = currentId + } else { + chatResultStream.setMessageIdToUpdateForTool(toolUse.name!, messageIdToUpdate) + } // Extract search results from the tool output const output = result.output.content as SanitizedRipgrepOutput diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts index 5fb5c39bab..70b3452361 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts @@ -1,4 +1,4 @@ -import { ChatResult, ChatMessage } from '@aws/language-server-runtimes/protocol' +import { ChatResult, FileDetails, ChatMessage } from '@aws/language-server-runtimes/protocol' import { randomUUID } from 'crypto' export interface ResultStreamWriter { @@ -32,20 +32,33 @@ export interface ResultStreamWriter { close(): Promise } -export const progressPrefix = 'progress_' - /** * This class wraps around lsp.sendProgress to provide a more helpful interface for streaming a ChatResult to the client. * ChatResults are grouped into blocks that can be written directly, or streamed in. * In the final message, blocks are seperated by resultDelimiter defined below. */ + +interface FileDetailsWithPath extends FileDetails { + relativeFilePath: string +} + +type OperationType = 'read' | 'write' | 'listDir' + +export const progressPrefix = 'progress_' + +interface FileOperation { + type: OperationType + filePaths: FileDetailsWithPath[] +} export class AgenticChatResultStream { - static readonly resultDelimiter = '\n' + static readonly resultDelimiter = '\n\n' #state = { chatResultBlocks: [] as ChatMessage[], isLocked: false, uuid: randomUUID(), messageId: undefined as string | undefined, + messageIdToUpdateForTool: new Map(), + messageOperations: new Map(), } readonly #sendProgress: (newChatResult: ChatResult | string) => Promise @@ -57,6 +70,33 @@ export class AgenticChatResultStream { return this.#joinResults(this.#state.chatResultBlocks, only) } + setMessageIdToUpdateForTool(toolName: string, messageId: string) { + this.#state.messageIdToUpdateForTool.set(toolName as OperationType, messageId) + } + + getMessageIdToUpdateForTool(toolName: string): string | undefined { + return this.#state.messageIdToUpdateForTool.get(toolName as OperationType) + } + + /** + * Adds a file operation for a specific message + * @param messageId The ID of the message + * @param type The type of operation ('fsRead' or 'listDirectory' or 'fsWrite') + * @param filePaths Array of FileDetailsWithPath involved in the operation + */ + addMessageOperation(messageId: string, type: string, filePaths: FileDetailsWithPath[]) { + this.#state.messageOperations.set(messageId, { type: type as OperationType, filePaths }) + } + + /** + * Gets the file operation details for a specific message + * @param messageId The ID of the message + * @returns The file operation details or undefined if not found + */ + getMessageOperation(messageId: string): FileOperation | undefined { + return this.#state.messageOperations.get(messageId) + } + #joinResults(chatResults: ChatMessage[], only?: string): ChatResult { const result: ChatResult = { body: '', @@ -71,9 +111,9 @@ export class AgenticChatResultStream { return { ...acc, buttons: [...(acc.buttons ?? []), ...(c.buttons ?? [])], - body: acc.body + (c.body ? AgenticChatResultStream.resultDelimiter + c.body : ''), - ...(c.contextList && c.type !== 'tool' && { contextList: c.contextList }), - header: c.header !== undefined ? c.header : acc.header, + body: acc.body + AgenticChatResultStream.resultDelimiter + c.body, + ...(c.contextList && { contextList: c.contextList }), + header: Object.prototype.hasOwnProperty.call(c, 'header') ? c.header : acc.header, codeReference: [...(acc.codeReference ?? []), ...(c.codeReference ?? [])], } } else if (acc.additionalMessages!.some(am => am.messageId === c.messageId)) { @@ -87,7 +127,7 @@ export class AgenticChatResultStream { : am.buttons, body: am.messageId === c.messageId - ? am.body + (c.body ? AgenticChatResultStream.resultDelimiter + c.body : '') + ? am.body + AgenticChatResultStream.resultDelimiter + c.body : am.body, ...(am.messageId === c.messageId && (c.contextList || acc.contextList) && { @@ -121,7 +161,7 @@ export class AgenticChatResultStream { }, }, }), - ...(am.messageId === c.messageId && c.header !== undefined && { header: c.header }), + header: Object.prototype.hasOwnProperty.call(c, 'header') ? c.header : am.header, })), } } else { @@ -206,10 +246,6 @@ export class AgenticChatResultStream { return undefined } - getLastMessage(): ChatMessage { - return this.#state.chatResultBlocks[this.#state.chatResultBlocks.length - 1] - } - getResultStreamWriter(): ResultStreamWriter { // Note: if write calls are not awaited, stream can be out-of-order. if (this.#state.isLocked) { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts index 37d11afe4f..fb6486996e 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts @@ -158,7 +158,3 @@ export class FileSearch { } as const } } - -export function isFileSearchParams(input: any): input is FileSearchParams { - return input && typeof input.path === 'string' && typeof input.queryName === 'string' -} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts index 45ab34170c..7b212bad49 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts @@ -920,8 +920,8 @@ async function migrateConfigToAgent( ...existingAgentConfig, // Merge MCP servers, keeping existing ones if they exist mcpServers: { - ...existingAgentConfig.mcpServers, ...newAgentConfig.mcpServers, + ...existingAgentConfig.mcpServers, }, // Merge tools lists without duplicates tools: [...new Set([...existingAgentConfig.tools, ...newAgentConfig.tools])], diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codePercentage.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codePercentage.ts index 04c7dc5ab1..0513443a35 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codePercentage.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codePercentage.ts @@ -137,10 +137,20 @@ export class CodePercentageTracker { } } + addTotalTokensForEdits(languageId: string, count: number): void { + const languageBucket = this.getLanguageBucket(languageId) + if (count >= INSERT_CUTOFF_THRESHOLD) { + languageBucket.totalTokens += count + } + } + countAcceptedTokens(languageId: string, tokens: string): void { + this.countAcceptedTokensUsingCount(languageId, tokens.length) + } + + countAcceptedTokensUsingCount(languageId: string, count: number): void { const languageBucket = this.getLanguageBucket(languageId) - const tokenCount = tokens.length - languageBucket.acceptedTokens += tokenCount + languageBucket.acceptedTokens += count } countInvocation(languageId: string): void { diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts index 56cda299f8..23a72e3528 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts @@ -46,7 +46,7 @@ import { UserWrittenCodeTracker } from '../../shared/userWrittenCodeTracker' import { RecentEditTracker, RecentEditTrackerDefaultConfig } from './tracker/codeEditTracker' import { CursorTracker } from './tracker/cursorTracker' import { RejectedEditTracker, DEFAULT_REJECTED_EDIT_TRACKER_CONFIG } from './tracker/rejectedEditTracker' -import { getAddedAndDeletedChars } from './diffUtils' +import { getAddedAndDeletedLines, getCharacterDifferences } from './diffUtils' import { emitPerceivedLatencyTelemetry, emitServiceInvocationFailure, @@ -622,36 +622,29 @@ export const CodewhispererServerFactory = ) const isAccepted = acceptedItemId ? true : false const acceptedSuggestion = session.suggestions.find(s => s.itemId === acceptedItemId) - let addedCharactersForEditSuggestion = '' - let deletedCharactersForEditSuggestion = '' - if (acceptedSuggestion !== undefined) { - if (acceptedSuggestion) { - codePercentageTracker.countSuccess(session.language) - if (session.suggestionType === SuggestionType.EDIT && acceptedSuggestion.content) { - // [acceptedSuggestion.insertText] will be undefined for NEP suggestion. Use [acceptedSuggestion.content] instead. - // Since [acceptedSuggestion.content] is in the form of a diff, transform the content into addedCharacters and deletedCharacters. - const addedAndDeletedChars = getAddedAndDeletedChars(acceptedSuggestion.content) - if (addedAndDeletedChars) { - addedCharactersForEditSuggestion = addedAndDeletedChars.addedCharacters - deletedCharactersForEditSuggestion = addedAndDeletedChars.deletedCharacters - - codePercentageTracker.countAcceptedTokens( - session.language, - addedCharactersForEditSuggestion - ) - codePercentageTracker.countTotalTokens( - session.language, - addedCharactersForEditSuggestion, - true - ) - enqueueCodeDiffEntry(session, acceptedSuggestion, addedCharactersForEditSuggestion) - } - } else if (acceptedSuggestion.insertText) { - codePercentageTracker.countAcceptedTokens(session.language, acceptedSuggestion.insertText) - codePercentageTracker.countTotalTokens(session.language, acceptedSuggestion.insertText, true) + let addedLengthForEdits = 0 + let deletedLengthForEdits = 0 + if (acceptedSuggestion) { + codePercentageTracker.countSuccess(session.language) + if (session.suggestionType === SuggestionType.EDIT && acceptedSuggestion.content) { + // [acceptedSuggestion.insertText] will be undefined for NEP suggestion. Use [acceptedSuggestion.content] instead. + // Since [acceptedSuggestion.content] is in the form of a diff, transform the content into addedCharacters and deletedCharacters. + const { addedLines, deletedLines } = getAddedAndDeletedLines(acceptedSuggestion.content) + const charDiffResult = getCharacterDifferences(addedLines, deletedLines) + addedLengthForEdits = charDiffResult.charactersAdded + deletedLengthForEdits = charDiffResult.charactersRemoved + + codePercentageTracker.countAcceptedTokensUsingCount( + session.language, + charDiffResult.charactersAdded + ) + codePercentageTracker.addTotalTokensForEdits(session.language, charDiffResult.charactersAdded) + enqueueCodeDiffEntry(session, acceptedSuggestion, addedLines.join('\n')) + } else if (acceptedSuggestion.insertText) { + codePercentageTracker.countAcceptedTokens(session.language, acceptedSuggestion.insertText) + codePercentageTracker.countTotalTokens(session.language, acceptedSuggestion.insertText, true) - enqueueCodeDiffEntry(session, acceptedSuggestion) - } + enqueueCodeDiffEntry(session, acceptedSuggestion) } } @@ -697,8 +690,8 @@ export const CodewhispererServerFactory = telemetryService, session, timeSinceLastUserModification, - addedCharactersForEditSuggestion.length, - deletedCharactersForEditSuggestion.length, + addedLengthForEdits, + deletedLengthForEdits, addedDiagnostics, removedDiagnostics, streakLength, diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/diffUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/diffUtils.ts index 66cbeb3f23..0899dd5e46 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/diffUtils.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/diffUtils.ts @@ -213,6 +213,18 @@ export function applyUnifiedDiff(docText: string, unifiedDiff: string): string { } } +export function getAddedAndDeletedLines(unifiedDiff: string): { addedLines: string[]; deletedLines: string[] } { + const lines = unifiedDiff.split('\n') + const addedLines = lines.filter(line => line.startsWith('+') && !line.startsWith('+++')).map(line => line.slice(1)) + const deletedLines = lines + .filter(line => line.startsWith('-') && !line.startsWith('---')) + .map(line => line.slice(1)) + return { + addedLines, + deletedLines, + } +} + // src https://github.com/aws/aws-toolkit-vscode/blob/3921457b0a2094b831beea0d66cc2cbd2a833890/packages/amazonq/src/app/inline/EditRendering/diffUtils.ts#L147 export function getAddedAndDeletedChars(unifiedDiff: string): { addedCharacters: string @@ -254,3 +266,58 @@ export function getAddedAndDeletedChars(unifiedDiff: string): { deletedCharacters, } } + +/** + * Calculate character differences between added and deleted text blocks using LCS + */ +export interface CharDiffResult { + charactersAdded: number + charactersRemoved: number +} + +/** + * Find longest common subsequence length between two strings + */ +function lcsLength(str1: string, str2: string): number[][] { + const m = str1.length + const n = str2.length + const dp = Array(m + 1) + .fill(null) + .map(() => Array(n + 1).fill(0)) + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (str1[i - 1] === str2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1 + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]) + } + } + } + + return dp +} + +/** + * Calculate character differences between added and deleted blocks + */ +export function getCharacterDifferences(addedLines: string[], deletedLines: string[]): CharDiffResult { + const addedText = addedLines.join('\n') + const deletedText = deletedLines.join('\n') + + if (addedText.length === 0) { + return { charactersAdded: 0, charactersRemoved: deletedText.length } + } + + if (deletedText.length === 0) { + return { charactersAdded: addedText.length, charactersRemoved: 0 } + } + + const lcsTable = lcsLength(deletedText, addedText) + const lcsLen = lcsTable[deletedText.length][addedText.length] + + return { + charactersAdded: addedText.length - lcsLen, + charactersRemoved: deletedText.length - lcsLen, + } +} diff --git a/server/aws-lsp-codewhisperer/src/shared/utils.test.ts b/server/aws-lsp-codewhisperer/src/shared/utils.test.ts index e83d04bbb3..85cd8817a7 100644 --- a/server/aws-lsp-codewhisperer/src/shared/utils.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/utils.test.ts @@ -134,26 +134,36 @@ describe('getClientName', () => { }) describe('getOriginFromClientInfo', () => { - it('returns MD_IDE for SMUS-IDE client name', () => { + it('returns MD_IDE for client names starting with SMUS-IDE prefix', () => { const result = getOriginFromClientInfo('AmazonQ-For-SMUS-IDE-1.0.0') assert.strictEqual(result, 'MD_IDE') }) - it('returns MD_IDE for SMUS-CE client name', () => { + it('returns MD_IDE for client names starting with SMUS-CE prefix', () => { const result = getOriginFromClientInfo('AmazonQ-For-SMUS-CE-1.0.0') assert.strictEqual(result, 'MD_IDE') }) - it('returns MD_IDE for client names starting with SMUS-IDE prefix', () => { + it('returns MD_IDE for client names starting with SMAI-CE prefix', () => { + const result = getOriginFromClientInfo('AmazonQ-For-SMAI-CE-1.0.0') + assert.strictEqual(result, 'MD_IDE') + }) + + it('returns MD_IDE for SMUS-IDE client name', () => { const result = getOriginFromClientInfo('AmazonQ-For-SMUS-IDE') assert.strictEqual(result, 'MD_IDE') }) - it('returns MD_IDE for client names starting with SMUS-CE prefix', () => { + it('returns MD_IDE for SMUS-CE client name', () => { const result = getOriginFromClientInfo('AmazonQ-For-SMUS-CE') assert.strictEqual(result, 'MD_IDE') }) + it('returns MD_IDE for SMAI-CE client name', () => { + const result = getOriginFromClientInfo('AmazonQ-For-SMAI-CE') + assert.strictEqual(result, 'MD_IDE') + }) + it('returns IDE for non-SMUS client name', () => { const result = getOriginFromClientInfo('VSCode-Extension') assert.strictEqual(result, 'IDE') diff --git a/server/aws-lsp-codewhisperer/src/shared/utils.ts b/server/aws-lsp-codewhisperer/src/shared/utils.ts index e178de4a51..a7a95d8801 100644 --- a/server/aws-lsp-codewhisperer/src/shared/utils.ts +++ b/server/aws-lsp-codewhisperer/src/shared/utils.ts @@ -381,7 +381,12 @@ export function getClientName(lspParams: InitializeParams | undefined): string | } export function getOriginFromClientInfo(clientName: string | undefined): Origin { - if (clientName?.startsWith('AmazonQ-For-SMUS-IDE') || clientName?.startsWith('AmazonQ-For-SMUS-CE')) { + // TODO: Update with a new origin for SMAI case, as a short-term solution Sagemaker AI CE is using same origin as that of Sagemaker Unified Studio's IDE and CE + if ( + clientName?.startsWith('AmazonQ-For-SMUS-IDE') || + clientName?.startsWith('AmazonQ-For-SMUS-CE') || + clientName?.startsWith('AmazonQ-For-SMAI-CE') + ) { return 'MD_IDE' } return 'IDE'