Skip to content

Commit becfee0

Browse files
authored
fix: update listDirectory tool to output in tree-like format to reduce toolSize (#1260)
1 parent ea589c5 commit becfee0

File tree

5 files changed

+314
-27
lines changed

5 files changed

+314
-27
lines changed

core/aws-lsp-core/src/util/workspaceUtils.test.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import * as fs from 'fs/promises'
33
import * as assert from 'assert'
44
import * as path from 'path'
55
import { TestFolder } from '../test/testFolder'
6-
import { readDirectoryRecursively, getEntryPath, isParentFolder, isInWorkspace } from './workspaceUtils'
6+
import {
7+
readDirectoryRecursively,
8+
readDirectoryWithTreeOutput,
9+
getEntryPath,
10+
isParentFolder,
11+
isInWorkspace,
12+
} from './workspaceUtils'
713
import { TestFeatures } from '@aws/language-server-runtimes/testing'
814

915
describe('workspaceUtils', function () {
@@ -209,4 +215,160 @@ describe('workspaceUtils', function () {
209215
assert.deepStrictEqual(result, [subdir1.path, file1, subdir11.path, file3, subdir2.path, file2].sort())
210216
})
211217
})
218+
219+
describe('readDirectoryWithTreeOuput', function () {
220+
let tempFolder: TestFolder
221+
let testFeatures: TestFeatures
222+
223+
before(async function () {
224+
tempFolder = await TestFolder.create()
225+
testFeatures = new TestFeatures()
226+
// Taken from https://github.com/aws/language-server-runtimes/blob/674c02696c150838b4bc93543fb0009c5982e7ad/runtimes/runtimes/standalone.ts#L216
227+
testFeatures.workspace.fs.readdir = path => fs.readdir(path, { withFileTypes: true })
228+
testFeatures.workspace.fs.exists = path =>
229+
fs.access(path).then(
230+
() => true,
231+
() => false
232+
)
233+
})
234+
235+
afterEach(async function () {
236+
await tempFolder.clear()
237+
})
238+
239+
after(async function () {
240+
await tempFolder.delete()
241+
})
242+
243+
it('recurses into subdirectories', async function () {
244+
const subdir1 = await tempFolder.nest('subdir1')
245+
await subdir1.write('file1', 'this is content')
246+
const subdir2 = await tempFolder.nest('subdir2')
247+
await subdir2.write('file2', 'this is other content')
248+
249+
const subdir11 = await subdir1.nest('subdir11')
250+
await subdir11.write('file3', 'this is also content')
251+
const subdir12 = await subdir1.nest('subdir12')
252+
await subdir12.write('file4', 'this is even more content')
253+
await subdir12.write('file5', 'and this is it')
254+
255+
const expected =
256+
'|-- subdir1/\n' +
257+
'| |-- subdir11/\n' +
258+
'| | `-- file3\n' +
259+
'| |-- subdir12/\n' +
260+
'| | |-- file4\n' +
261+
'| | `-- file5\n' +
262+
'| `-- file1\n' +
263+
'`-- subdir2/\n' +
264+
' `-- file2\n'
265+
266+
const result = await readDirectoryWithTreeOutput(testFeatures, tempFolder.path)
267+
assert.ok(result.includes(expected))
268+
})
269+
270+
it('respects maxDepth parameter', async function () {
271+
const subdir1 = await tempFolder.nest('subdir1')
272+
await subdir1.write('file1', 'this is content')
273+
const subdir2 = await subdir1.nest('subdir2')
274+
await subdir2.write('file2', 'this is other content')
275+
const subdir3 = await subdir2.nest('subdir3')
276+
await subdir3.write('file3', 'this is also content')
277+
278+
const testDepth = async (maxDepth: number, expected: string) => {
279+
const result = await readDirectoryWithTreeOutput(testFeatures, tempFolder.path, {
280+
maxDepth,
281+
})
282+
assert.ok(result.includes(expected))
283+
}
284+
285+
await testDepth(0, '`-- subdir1/\n')
286+
await testDepth(1, '`-- subdir1/\n |-- subdir2/\n `-- file1\n')
287+
await testDepth(
288+
2,
289+
'`-- subdir1/\n |-- subdir2/\n | |-- subdir3/\n | `-- file2\n `-- file1\n'
290+
)
291+
await testDepth(
292+
3,
293+
'`-- subdir1/\n |-- subdir2/\n | |-- subdir3/\n | | `-- file3\n | `-- file2\n `-- file1\n'
294+
)
295+
})
296+
297+
// This test doesn't work on windows since it modifies file permissions
298+
if (process.platform !== 'win32') {
299+
it('respects the failOnError flag', async function () {
300+
const subdir1 = await tempFolder.nest('subdir1')
301+
await subdir1.write('file1', 'this is content')
302+
303+
// Temporarily make the file unreadable.
304+
await fs.chmod(subdir1.path, 0)
305+
await assert.rejects(
306+
readDirectoryWithTreeOutput(testFeatures, tempFolder.path, {
307+
failOnError: true,
308+
})
309+
)
310+
311+
const result = await readDirectoryWithTreeOutput(testFeatures, tempFolder.path)
312+
await fs.chmod(subdir1.path, 0o755)
313+
assert.ok(result.includes('`-- subdir1/\n'))
314+
})
315+
}
316+
317+
it('always fail if directory does not exist', async function () {
318+
await assert.rejects(readDirectoryWithTreeOutput(testFeatures, path.join(tempFolder.path, 'notReal')))
319+
})
320+
321+
it('ignores files in the exclude entries', async function () {
322+
const subdir1 = await tempFolder.nest('subdir1')
323+
await subdir1.write('file1', 'this is content')
324+
const subdir2 = await tempFolder.nest('subdir2')
325+
await subdir2.write('file2-bad', 'this is other content')
326+
327+
const subdir11 = await subdir1.nest('subdir11')
328+
await subdir11.write('file3', 'this is also content')
329+
const subdir12 = await subdir1.nest('subdir12')
330+
await subdir12.write('file4-bad', 'this is even more content')
331+
await subdir12.write('file5', 'and this is it')
332+
333+
const result = await readDirectoryWithTreeOutput(testFeatures, tempFolder.path, {
334+
excludeFiles: ['file4-bad', 'file2-bad'],
335+
})
336+
337+
const expected =
338+
'|-- subdir1/\n' +
339+
'| |-- subdir11/\n' +
340+
'| | `-- file3\n' +
341+
'| |-- subdir12/\n' +
342+
'| | `-- file5\n' +
343+
'| `-- file1\n' +
344+
'`-- subdir2/\n'
345+
assert.ok(result.includes(expected))
346+
})
347+
348+
it('ignores directories in the exclude entries', async function () {
349+
const subdir1 = await tempFolder.nest('subdir1')
350+
await subdir1.write('file1', 'this is content')
351+
const subdir2 = await tempFolder.nest('subdir2')
352+
await subdir2.write('file2', 'this is other content')
353+
354+
const subdir11 = await subdir1.nest('subdir11')
355+
await subdir11.write('file3', 'this is also content')
356+
const subdir12 = await subdir1.nest('subdir12-bad')
357+
await subdir12.write('file4-bad', 'this is even more content')
358+
await subdir12.write('file5-bad', 'and this is it')
359+
await subdir12.write('file6-bad', 'and this is it')
360+
361+
const result = await readDirectoryWithTreeOutput(testFeatures, tempFolder.path, {
362+
excludeDirs: ['subdir12-bad'],
363+
})
364+
const expected =
365+
'|-- subdir1/\n' +
366+
'| |-- subdir11/\n' +
367+
'| | `-- file3\n' +
368+
'| `-- file1\n' +
369+
'`-- subdir2/\n' +
370+
' `-- file2\n'
371+
assert.ok(result.includes(expected))
372+
})
373+
})
212374
})

core/aws-lsp-core/src/util/workspaceUtils.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,124 @@ export function isParentFolder(parentPath: string, childPath: string): boolean {
109109
export function isInWorkspace(workspaceFolderPaths: string[], filepath: string) {
110110
return workspaceFolderPaths.some(wsFolder => isParentFolder(wsFolder, filepath) || wsFolder === filepath)
111111
}
112+
113+
/**
114+
* Output the result in tree-like format, this will save tokens as the output contains much less characters
115+
* project-root/
116+
* |-- src/
117+
* | |-- components/
118+
* | | |-- Button.js
119+
* | | |-- Card.js
120+
* | | `-- index.js
121+
* | |-- utils/
122+
* | | |-- helpers.js
123+
* | | `-- formatters.js
124+
* | `-- index.js
125+
* |-- public/
126+
* | |-- images/
127+
* | | |-- logo.png
128+
* | | `-- banner.jpg
129+
* | `-- index.html
130+
* |-- package.json
131+
* `-- README.md
132+
*/
133+
export async function readDirectoryWithTreeOutput(
134+
features: Pick<Features, 'workspace' | 'logging'> & Partial<Features>,
135+
folderPath: string,
136+
options?: {
137+
maxDepth?: number
138+
excludeDirs?: string[]
139+
excludeFiles?: string[]
140+
failOnError?: boolean
141+
},
142+
token?: CancellationToken
143+
): Promise<string> {
144+
const dirExists = await features.workspace.fs.exists(folderPath)
145+
const excludeFiles = options?.excludeFiles ?? []
146+
const excludeDirs = options?.excludeDirs ?? []
147+
if (!dirExists) {
148+
throw new Error(`Directory does not exist: ${folderPath}`)
149+
}
150+
151+
features.logging.info(
152+
`Reading directory in tree like format: ${folderPath} to max depth: ${options?.maxDepth === undefined ? 'unlimited' : options.maxDepth}`
153+
)
154+
155+
// Initialize result with the root directory
156+
let result = `${folderPath}/\n`
157+
158+
// Recursive function to build the tree
159+
const processDirectory = async (dirPath: string, depth: number, prefix: string): Promise<string> => {
160+
if (token?.isCancellationRequested) {
161+
features.logging.info('cancelled readDirectoryRecursively')
162+
throw new CancellationError('user')
163+
}
164+
165+
if (options?.maxDepth !== undefined && depth > options?.maxDepth) {
166+
features.logging.info(`Skipping directory: ${dirPath} (depth ${depth} > max ${options.maxDepth})`)
167+
return ''
168+
}
169+
170+
let entries: Awaited<ReturnType<typeof features.workspace.fs.readdir>>
171+
try {
172+
entries = await features.workspace.fs.readdir(dirPath)
173+
} catch (err) {
174+
if (options?.failOnError) {
175+
throw err
176+
}
177+
const errMsg = `Failed to read: ${dirPath} (${err})`
178+
features.logging.warn(errMsg)
179+
return ''
180+
}
181+
182+
// Sort entries: directories first, then files, both alphabetically
183+
entries.sort((a, b) => {
184+
const aIsDir = a.isDirectory()
185+
const bIsDir = b.isDirectory()
186+
if (aIsDir && !bIsDir) return -1
187+
if (!aIsDir && bIsDir) return 1
188+
return a.name.localeCompare(b.name)
189+
})
190+
191+
let treeOutput = ''
192+
193+
for (let i = 0; i < entries.length; i++) {
194+
const entry = entries[i]
195+
const isLast = i === entries.length - 1
196+
197+
if (entry.isDirectory()) {
198+
if (excludeDirs.includes(entry.name)) {
199+
features.logging.log(`Skipping directory ${entry.name} due to match in [${excludeDirs.join(', ')}]`)
200+
continue
201+
}
202+
} else {
203+
if (excludeFiles.includes(entry.name)) {
204+
features.logging.log(`Skipping file ${entry.name} due to match in [${excludeFiles.join(', ')}]`)
205+
continue
206+
}
207+
}
208+
209+
// Format this entry with proper tree characters
210+
const entryType = entry.isDirectory() ? '/' : entry.isSymbolicLink() ? '@' : ''
211+
212+
// Use '`--' for the last item, '|--' for others
213+
const connector = isLast ? '`-- ' : '|-- '
214+
treeOutput += `${prefix}${connector}${entry.name}${entryType}\n`
215+
216+
// Process subdirectories with proper indentation for the next level
217+
if (entry.isDirectory() && (options?.maxDepth === undefined || depth < options?.maxDepth)) {
218+
const childPath = getEntryPath(entry)
219+
// For the next level, add vertical line for non-last items, spaces for last items
220+
const childPrefix = prefix + (isLast ? ' ' : '| ')
221+
treeOutput += await processDirectory(childPath, depth + 1, childPrefix)
222+
}
223+
}
224+
225+
return treeOutput
226+
}
227+
228+
// Start processing from the root directory
229+
result += await processDirectory(folderPath, 0, '')
230+
231+
return result
232+
}

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

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import * as assert from 'assert'
2-
import { Writable } from 'stream'
32
import { ListDirectory } from './listDirectory'
43
import { testFolder } from '@aws/lsp-core'
54
import * as path from 'path'
@@ -55,11 +54,8 @@ describe('ListDirectory Tool', () => {
5554
const result = await listDirectory.invoke({ path: tempFolder.path, maxDepth: 0 })
5655

5756
assert.strictEqual(result.output.kind, 'text')
58-
const lines = result.output.content.split('\n')
59-
const hasFileA = lines.some((line: string | string[]) => line.includes('[F] ') && line.includes('fileA.txt'))
60-
const hasSubfolder = lines.some(
61-
(line: string | string[]) => line.includes('[D] ') && line.includes('subfolder')
62-
)
57+
const hasFileA = result.output.content.includes('`-- fileA.txt')
58+
const hasSubfolder = result.output.content.includes('subfolder/\n')
6359

6460
assert.ok(hasFileA, 'Should list fileA.txt in the directory output')
6561
assert.ok(hasSubfolder, 'Should list the subfolder in the directory output')
@@ -74,12 +70,9 @@ describe('ListDirectory Tool', () => {
7470
const result = await listDirectory.invoke({ path: tempFolder.path })
7571

7672
assert.strictEqual(result.output.kind, 'text')
77-
const lines = result.output.content.split('\n')
78-
const hasFileA = lines.some((line: string | string[]) => line.includes('[F] ') && line.includes('fileA.txt'))
79-
const hasSubfolder = lines.some(
80-
(line: string | string[]) => line.includes('[D] ') && line.includes('subfolder')
81-
)
82-
const hasFileB = lines.some((line: string | string[]) => line.includes('[F] ') && line.includes('fileB.md'))
73+
const hasFileA = result.output.content.includes('`-- fileA.txt')
74+
const hasSubfolder = result.output.content.includes('subfolder/\n')
75+
const hasFileB = result.output.content.includes('`-- fileB.md')
8376

8477
assert.ok(hasFileA, 'Should list fileA.txt in the directory output')
8578
assert.ok(hasSubfolder, 'Should list the subfolder in the directory output')
@@ -95,11 +88,8 @@ describe('ListDirectory Tool', () => {
9588
const result = await listDirectory.invoke({ path: tempFolder.path })
9689

9790
assert.strictEqual(result.output.kind, 'text')
98-
const lines = result.output.content.split('\n')
99-
const hasNodeModules = lines.some(
100-
(line: string | string[]) => line.includes('[D] ') && line.includes('node_modules')
101-
)
102-
const hasFileC = lines.some((line: string | string[]) => line.includes('[F] ') && line.includes('fileC.md'))
91+
const hasNodeModules = result.output.content.includes('node_modules/\n')
92+
const hasFileC = result.output.content.includes('`-- fileC.md')
10393

10494
assert.ok(!hasNodeModules, 'Should not list node_modules in the directory output')
10595
assert.ok(!hasFileC, 'Should not list fileC.md under node_modules in the directory output')
@@ -113,8 +103,7 @@ describe('ListDirectory Tool', () => {
113103
await listDirectory.validate({ path: tempFolder.path })
114104
const result = await listDirectory.invoke({ path: tempFolder.path })
115105
assert.strictEqual(result.output.kind, 'text')
116-
const lines = result.output.content.split('\n')
117-
const hasOutput = lines.some((line: string) => line.includes('output.md'))
106+
const hasOutput = result.output.content.includes('`-- output.md')
118107

119108
assert.ok(hasOutput, 'Should list output.md under foo in the directory output')
120109
})

0 commit comments

Comments
 (0)