Skip to content

Commit 6568811

Browse files
authored
fix: allowing reading multiple files with fsRead, minor tool validation fix (#1297)
1 parent 06c752e commit 6568811

File tree

7 files changed

+135
-176
lines changed

7 files changed

+135
-176
lines changed

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ export class AgenticChatController implements ChatHandlers {
668668
}))
669669
if (result.error.startsWith('ToolUse input is invalid JSON:')) {
670670
content =
671-
'Your toolUse input is incomplete because it is too large. Break this task down into multiple tool uses with smaller input. Do not apologize.'
671+
'Your toolUse input is incomplete, try again. If the error happens consistently, break this task down into multiple tool uses with smaller input. Do not apologize.'
672672
shouldDisplayMessage = false
673673
}
674674
}
@@ -1370,8 +1370,15 @@ export class AgenticChatController implements ChatHandlers {
13701370
buttons,
13711371
}
13721372
// ⚠️ Warning: This accesses files outside the workspace
1373-
const readFilePath = (toolUse.input as unknown as FsReadParams | ListDirectoryParams).path
1374-
body = `I need permission to read files and list directories outside the workspace.\n\`${readFilePath}\``
1373+
if (toolUse.name === 'fsRead') {
1374+
const paths = (toolUse.input as unknown as FsReadParams).paths
1375+
const formattedPaths: string[] = []
1376+
paths.forEach(element => formattedPaths.push(`\`${element}\``))
1377+
body = `I need permission to read files outside the workspace.\n${formattedPaths.join('\n')}`
1378+
} else {
1379+
const readFilePath = (toolUse.input as unknown as ListDirectoryParams).path
1380+
body = `I need permission to list directories outside the workspace.\n\`${readFilePath}\``
1381+
}
13751382
break
13761383
}
13771384

@@ -1431,21 +1438,29 @@ export class AgenticChatController implements ChatHandlers {
14311438
} else {
14321439
chatResultStream.setMessageIdToUpdateForTool(toolUse.name!, messageIdToUpdate)
14331440
}
1441+
let currentPaths = []
1442+
if (toolUse.name === 'fsRead') {
1443+
currentPaths = (toolUse.input as unknown as FsReadParams)?.paths
1444+
} else {
1445+
currentPaths.push((toolUse.input as unknown as ListDirectoryParams | FileSearchParams)?.path)
1446+
}
14341447

1435-
const currentPath = (toolUse.input as unknown as FsReadParams | ListDirectoryParams | FileSearchParams)?.path
1436-
if (!currentPath) return
1437-
const existingPaths = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths || []
1438-
// Check if path already exists in the list
1439-
const isPathAlreadyProcessed = existingPaths.some(path => path.relativeFilePath === currentPath)
1440-
if (!isPathAlreadyProcessed) {
1441-
const currentFileDetail = {
1442-
relativeFilePath: currentPath,
1443-
lineRanges: [{ first: -1, second: -1 }],
1448+
if (!currentPaths) return
1449+
1450+
for (const currentPath of currentPaths) {
1451+
const existingPaths = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths || []
1452+
// Check if path already exists in the list
1453+
const isPathAlreadyProcessed = existingPaths.some(path => path.relativeFilePath === currentPath)
1454+
if (!isPathAlreadyProcessed) {
1455+
const currentFileDetail = {
1456+
relativeFilePath: currentPath,
1457+
lineRanges: [{ first: -1, second: -1 }],
1458+
}
1459+
chatResultStream.addMessageOperation(messageIdToUpdate, toolUse.name!, [
1460+
...existingPaths,
1461+
currentFileDetail,
1462+
])
14441463
}
1445-
chatResultStream.addMessageOperation(messageIdToUpdate, toolUse.name!, [
1446-
...existingPaths,
1447-
currentFileDetail,
1448-
])
14491464
}
14501465
let title: string
14511466
const itemCount = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths.length

server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ describe('ExecuteBash Tool', () => {
2929

3030
it('pass validation for a safe command (read-only)', async () => {
3131
const execBash = new ExecuteBash(features)
32-
await execBash.validate('ls')
32+
await execBash.validate({ command: 'ls' })
3333
})
3434

3535
it('fail validation if the command is empty', async () => {
3636
const execBash = new ExecuteBash(features)
3737
await assert.rejects(
38-
execBash.validate(' '),
38+
execBash.validate({ command: ' ' }),
3939
/command cannot be empty/i,
4040
'Expected an error for empty command'
4141
)
@@ -56,7 +56,7 @@ describe('ExecuteBash Tool', () => {
5656
it('whichCommand cannot find the first arg', async () => {
5757
const execBash = new ExecuteBash(features)
5858
await assert.rejects(
59-
execBash.validate('noSuchCmd'),
59+
execBash.validate({ command: 'noSuchCmd' }),
6060
/not found on PATH/i,
6161
'Expected not found error from whichCommand'
6262
)

server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ export class ExecuteBash {
150150
this.lsp = features.lsp
151151
}
152152

153-
public async validate(command: string): Promise<void> {
153+
public async validate(input: ExecuteBashParams): Promise<void> {
154+
const command = input.command
154155
if (!command.trim()) {
155156
throw new Error('Command cannot be empty.')
156157
}

server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsRead.test.ts

Lines changed: 36 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as assert from 'assert'
2-
import { FsRead } from './fsRead'
2+
import { FileReadResult, FsRead } from './fsRead'
33
import * as path from 'path'
44
import * as fs from 'fs/promises'
55
import { TestFeatures } from '@aws/language-server-runtimes/testing'
@@ -40,7 +40,7 @@ describe('FsRead Tool', () => {
4040
it('invalidates empty path', async () => {
4141
const fsRead = new FsRead(features)
4242
await assert.rejects(
43-
fsRead.validate({ path: '' }),
43+
fsRead.validate({ paths: [''] }),
4444
/Path cannot be empty/i,
4545
'Expected an error about empty path'
4646
)
@@ -51,7 +51,7 @@ describe('FsRead Tool', () => {
5151
const fsRead = new FsRead(features)
5252

5353
await assert.rejects(
54-
fsRead.validate({ path: filePath }),
54+
fsRead.validate({ paths: [filePath] }),
5555
/does not exist or cannot be accessed/i,
5656
'Expected an error indicating the path does not exist'
5757
)
@@ -61,50 +61,35 @@ describe('FsRead Tool', () => {
6161
const fileContent = 'A'.repeat(FsRead.maxResponseSize + 10)
6262
const filePath = await tempFolder.write('largeFile.txt', fileContent)
6363
const fsRead = new FsRead(features)
64-
await fsRead.validate({ path: filePath })
65-
const result = await fsRead.invoke({ path: filePath })
64+
await fsRead.validate({ paths: [filePath] })
65+
const result = await fsRead.invoke({ paths: [filePath] })
6666

67-
verifyResult(result, { truncated: true }, ({ content }) => content.length === FsRead.maxResponseSize)
67+
verifyResult(result, [
68+
{ path: filePath, content: 'A'.repeat(FsRead.maxResponseSize - 3) + '...', truncated: true },
69+
])
6870
})
6971

7072
it('reads entire file', async () => {
7173
const fileContent = 'Line 1\nLine 2\nLine 3'
7274
const filePath = await tempFolder.write('fullFile.txt', fileContent)
7375

7476
const fsRead = new FsRead(features)
75-
const result = await fsRead.invoke({ path: filePath })
76-
verifyResult(result, { content: fileContent, truncated: false })
77+
const result = await fsRead.invoke({ paths: [filePath] })
78+
verifyResult(result, [{ path: filePath, content: fileContent, truncated: false }])
7779
})
7880

79-
it('reads partial lines of a file', async () => {
80-
const fileContent = 'A\nB\nC\nD\nE\nF'
81-
const filePath = await tempFolder.write('partialFile.txt', fileContent)
82-
83-
const fsRead = new FsRead(features)
84-
const result = await fsRead.invoke({ path: filePath, readRange: [2, 4] })
85-
verifyResult(result, { content: 'B\nC\nD', truncated: false })
86-
})
87-
88-
it('invalid line range', async () => {
89-
const filePath = await tempFolder.write('rangeTest.txt', '1\n2\n3')
90-
const fsRead = new FsRead(features)
91-
92-
await fsRead.invoke({ path: filePath, readRange: [3, 2] })
93-
const result = await fsRead.invoke({ path: filePath, readRange: [3, 2] })
94-
verifyResult(result, { content: '', truncated: false })
95-
})
81+
it('reads multiple files', async () => {
82+
const fileContent = 'Line 1\nLine 2\nLine 3'
83+
const fileContent1 = 'Line 1\n'
84+
const filePath = await tempFolder.write('fullFile.txt', fileContent)
85+
const filePath1 = await tempFolder.write('fullFile1.txt', fileContent1)
9686

97-
it('updates the stream', async () => {
9887
const fsRead = new FsRead(features)
99-
const chunks = []
100-
const stream = new WritableStream({
101-
write: c => {
102-
chunks.push(c)
103-
},
104-
})
105-
await fsRead.queueDescription({ path: 'this/is/my/path' }, stream, true)
106-
assert.ok(chunks.length > 0)
107-
assert.ok(!stream.locked)
88+
const result = await fsRead.invoke({ paths: [filePath, filePath1] })
89+
verifyResult(result, [
90+
{ path: filePath, content: fileContent, truncated: false },
91+
{ path: filePath1, content: fileContent1, truncated: false },
92+
])
10893
})
10994

11095
it('should require acceptance if fsPath is outside the workspace', async () => {
@@ -115,7 +100,7 @@ describe('FsRead Tool', () => {
115100
getTextDocument: async s => undefined,
116101
},
117102
})
118-
const result = await fsRead.requiresAcceptance({ path: '/not/in/workspace/file.txt' })
103+
const result = await fsRead.requiresAcceptance({ paths: ['/not/in/workspace/file.txt'] })
119104
assert.equal(
120105
result.requiresAcceptance,
121106
true,
@@ -140,7 +125,7 @@ describe('FsRead Tool', () => {
140125
getTextDocument: async s => ({}) as TextDocument,
141126
},
142127
})
143-
const result = await fsRead.requiresAcceptance({ path: '/workspace/folder/file.txt' })
128+
const result = await fsRead.requiresAcceptance({ paths: ['/workspace/folder/file.txt'] })
144129
assert.equal(
145130
result.requiresAcceptance,
146131
false,
@@ -149,20 +134,20 @@ describe('FsRead Tool', () => {
149134
})
150135
})
151136

152-
function verifyResult(
153-
result: any,
154-
expected: { content?: string; truncated: boolean },
155-
customChecks?: (r: { content: string; truncated: boolean }) => boolean
156-
) {
137+
function verifyResult(result: any, expected: FileReadResult[]) {
157138
assert.strictEqual(result.output.kind, 'json', 'Output kind should be "json"')
158-
const resultContent = result.output.content as { content: string; truncated: boolean }
159-
if (expected.content) {
160-
assert.strictEqual(resultContent.content, expected.content, 'File content should match exactly')
161-
}
162-
if (expected.truncated !== undefined) {
163-
assert.strictEqual(resultContent.truncated, expected.truncated, 'Truncated flag should match')
164-
}
165-
if (customChecks) {
166-
assert.ok(customChecks(resultContent), 'Custom checks failed in verifyResult')
139+
const resultContent = result.output.content as FileReadResult[]
140+
// Compare array length
141+
assert.strictEqual(resultContent.length, expected.length, 'Arrays should have the same length')
142+
143+
// Compare each element in the arrays
144+
for (let i = 0; i < resultContent.length; i++) {
145+
assert.strictEqual(resultContent[i].path, expected[i].path, `Path at index ${i} should match`)
146+
assert.strictEqual(resultContent[i].content, expected[i].content, `Content at index ${i} should match`)
147+
assert.strictEqual(
148+
resultContent[i].truncated,
149+
expected[i].truncated,
150+
`Truncated flag at index ${i} should match`
151+
)
167152
}
168153
}

0 commit comments

Comments
 (0)