diff --git a/packages/cli/src/lib/init/tools/list-dir.ts b/packages/cli/src/lib/init/tools/list-dir.ts index 85f00f5d5..dbdcf77ff 100644 --- a/packages/cli/src/lib/init/tools/list-dir.ts +++ b/packages/cli/src/lib/init/tools/list-dir.ts @@ -36,10 +36,17 @@ export async function listDir(payload: ListDirPayload): Promise { maxDepth, maxEntries, recursive, + truncated: false, }; await walkDirectory(targetPath, 0, state); - return { ok: true, data: { entries: state.entries } }; + return { + ok: true, + data: { + entries: state.entries, + ...(state.truncated ? { truncated: true } : {}), + }, + }; } type WalkState = { @@ -49,13 +56,14 @@ type WalkState = { maxDepth: number; maxEntries: number; recursive: boolean; + truncated: boolean; }; -async function readDirEntries(dir: string): Promise { +async function readDirEntries(dir: string): Promise { try { return await fs.promises.readdir(dir, { withFileTypes: true }); } catch { - return []; + return; } } @@ -93,24 +101,53 @@ function toDirEntry( } } + if (entry.isDirectory()) { + return { + name: entry.name, + path: normalizePath(relNative), + type: "directory", + }; + } + + // Only regular files carry size. lstat avoids following a path that changed + // into a symlink after readdir; special files are never opened. return { name: entry.name, path: normalizePath(relNative), - type: entry.isDirectory() ? "directory" : "file", + type: "file", + ...(entry.isFile() ? fileSize(abs) : {}), }; } +/** Return a regular file's byte size without opening or reading its contents. */ +function fileSize(abs: string): { size?: number } { + try { + const stat = fs.lstatSync(abs); + return stat.isFile() ? { size: stat.size } : {}; + } catch { + return {}; + } +} + async function walkDirectory( dir: string, depth: number, state: WalkState ): Promise { if (depth > state.maxDepth || state.entries.length >= state.maxEntries) { + state.truncated = true; + return; + } + + const entries = await readDirEntries(dir); + if (!entries) { + state.truncated = true; return; } - for (const entry of await readDirEntries(dir)) { + for (const entry of entries) { if (state.entries.length >= state.maxEntries) { + state.truncated = true; return; } const nextEntry = toDirEntry(state, dir, entry); @@ -119,7 +156,11 @@ async function walkDirectory( } state.entries.push(nextEntry); if (shouldRecurseInto(entry, state)) { - await walkDirectory(dir + NATIVE_SEP + entry.name, depth + 1, state); + if (depth >= state.maxDepth) { + state.truncated = true; + } else { + await walkDirectory(dir + NATIVE_SEP + entry.name, depth + 1, state); + } } } } diff --git a/packages/cli/src/lib/init/types.ts b/packages/cli/src/lib/init/types.ts index e84e53929..f3c46734f 100644 --- a/packages/cli/src/lib/init/types.ts +++ b/packages/cli/src/lib/init/types.ts @@ -2,6 +2,9 @@ export type DirEntry = { name: string; path: string; type: "file" | "directory"; + /** File size in bytes; omitted for directories. Lets the agent skip huge + * files without reading them, like `ls -l`. */ + size?: number; }; export type ExistingProjectData = { diff --git a/packages/cli/test/lib/init/tools/filesystem-tools.test.ts b/packages/cli/test/lib/init/tools/filesystem-tools.test.ts index 4142b2daf..ec122acd7 100644 --- a/packages/cli/test/lib/init/tools/filesystem-tools.test.ts +++ b/packages/cli/test/lib/init/tools/filesystem-tools.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -72,9 +73,108 @@ describe("filesystem tools", () => { expect(result.ok).toBe(true); expect(entries.map((entry) => entry.path)).toContain("src/app.ts"); + expect(entries.find((entry) => entry.path === "src/app.ts")).toMatchObject({ + size: 18, + type: "file", + }); expect(precomputed.map((entry) => entry.path)).toContain("src/app.ts"); }); + test("reports when a bounded recursive listing is incomplete", async () => { + fs.mkdirSync(path.join(testDir, "nested", "deeper"), { recursive: true }); + fs.writeFileSync(path.join(testDir, "nested", "deeper", "app.ts"), "x"); + fs.writeFileSync(path.join(testDir, "root.ts"), "x"); + + const byDepth = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 0, maxEntries: 100 }, + }, + makeContext(testDir) + ); + const byEntries = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 10, maxEntries: 1 }, + }, + makeContext(testDir) + ); + + expect(byDepth.data).toMatchObject({ truncated: true }); + expect(byEntries.data).toMatchObject({ truncated: true }); + expect((byEntries.data as { entries: unknown[] }).entries).toHaveLength(1); + }); + + test("reports an unreadable directory as an incomplete listing", async () => { + const readdirSpy = vi + .spyOn(fs.promises, "readdir") + .mockRejectedValueOnce(new Error("permission denied")); + + try { + const result = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true }, + }, + makeContext(testDir) + ); + + expect(result).toMatchObject({ + data: { entries: [], truncated: true }, + ok: true, + }); + } finally { + readdirSpy.mockRestore(); + } + }); + + test.runIf(process.platform !== "win32")( + "lists special files without opening them or attaching a size", + async () => { + const fifoPath = path.join(testDir, "stream.pipe"); + execFileSync("mkfifo", [fifoPath]); + fs.writeFileSync(path.join(testDir, "regular.ts"), "export {};\n"); + const openSpy = vi.spyOn(fs, "openSync"); + const readSpy = vi.spyOn(fs, "readSync"); + + try { + const result = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: "." }, + }, + makeContext(testDir) + ); + const entry = ( + result.data as { entries: Record[] } + ).entries.find(({ path: entryPath }) => entryPath === "stream.pipe"); + const regular = ( + result.data as { entries: Record[] } + ).entries.find(({ path: entryPath }) => entryPath === "regular.ts"); + + expect(entry).toEqual({ + name: "stream.pipe", + path: "stream.pipe", + type: "file", + }); + expect(regular).toMatchObject({ size: 11, type: "file" }); + expect(openSpy).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + readSpy.mockRestore(); + } + } + ); + test("reads files and checks existence in batches", async () => { fs.writeFileSync(path.join(testDir, "exists.txt"), "hello");