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/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/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' -}