Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/aws-lsp-codewhisperer-runtimes/src/version.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"agenticChat": "1.26.0"
"agenticChat": "1.27.0"
}
16 changes: 4 additions & 12 deletions chat-client/src/client/mynahUi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand All @@ -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
})
Expand Down Expand Up @@ -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)])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ describe('AgenticChatController', () => {

assert.deepStrictEqual(chatResult, {
additionalMessages: [],
body: '\nHello World!',
body: '\n\nHello World!',
messageId: 'mock-message-id',
buttons: [],
codeReference: [],
Expand Down Expand Up @@ -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: [],
Expand All @@ -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: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
* Will be deleted or merged.
*/

import * as crypto from 'crypto'

Check warning on line 6 in server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

View workflow job for this annotation

GitHub Actions / Test

Do not import Node.js builtin module "crypto"

Check warning on line 6 in server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Do not import Node.js builtin module "crypto"
import * as path from 'path'

Check warning on line 7 in server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

View workflow job for this annotation

GitHub Actions / Test

Do not import Node.js builtin module "path"

Check warning on line 7 in server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Do not import Node.js builtin module "path"
import * as os from 'os'

Check warning on line 8 in server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

View workflow job for this annotation

GitHub Actions / Test

Do not import Node.js builtin module "os"

Check warning on line 8 in server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Do not import Node.js builtin module "os"
import {
ChatTriggerType,
Origin,
Expand Down Expand Up @@ -169,7 +169,7 @@
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'
Expand Down Expand Up @@ -1695,7 +1695,8 @@
// 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.
Expand Down Expand Up @@ -1910,19 +1911,10 @@
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
Expand Down Expand Up @@ -2323,6 +2315,7 @@
}

const toolMsgId = toolUse.toolUseId!
const chatMsgId = chatResultStream.getResult().messageId
let headerEmitted = false

const initialHeader: ChatMessage['header'] = {
Expand Down Expand Up @@ -2360,6 +2353,13 @@
header: completedHeader,
})

await chatResultStream.writeResultBlock({
type: 'answer',
messageId: chatMsgId,
body: '',
header: undefined,
})

this.#stoppedToolUses.add(toolMsgId)
},
})
Expand Down Expand Up @@ -2877,135 +2877,70 @@
}
}

async #processFileSearchTool(
toolInput: FileSearchParams,
toolUseId: string,
result: InvokeOutput,
chatResultStream: AgenticChatResultStream
): Promise<void> {
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<ChatMessage | undefined> {
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<string, FileDetails> = {}
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: '',
}
}

Expand All @@ -3021,7 +2956,14 @@
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
Expand Down
Loading
Loading