Unverified 提交 b1fa3ad6 authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

Allow local agent to inspect ignored files (#3256)

## Summary - Add include_ignored to local-agent grep and list_files so ignored and hidden paths can be inspected only when requested. - Remove include_hidden from list_files, add a root recursive ignored-listing guard, cap list output at 1000 paths, and sort directories before files. - Update local-agent tests and snapshots, including a less brittle read_logs E2E assertion. ## Test plan - PYTHON=/usr/bin/python3 npm run build - PLAYWRIGHT_HTML_OPEN=never npm run e2e -- e2e-tests/local_agent_search_replace.spec.ts e2e-tests/local_agent_step_limit.spec.ts e2e-tests/local_agent_consent.spec.ts e2e-tests/local_agent_code_search.spec.ts e2e-tests/local_agent_advanced.spec.ts e2e-tests/local_agent_file_upload.spec.ts e2e-tests/local_agent_basic.spec.ts e2e-tests/local_agent_grep.spec.ts e2e-tests/local_agent_auto.spec.ts e2e-tests/local_agent_list_files.spec.ts e2e-tests/local_agent_ask.spec.ts e2e-tests/local_agent_todo_followup.spec.ts e2e-tests/local_agent_summarize.spec.ts e2e-tests/local_agent_read_logs.spec.ts e2e-tests/local_agent_generate_image.spec.ts e2e-tests/local_agent_connection_retry.spec.ts e2e-tests/local_agent_persistent_todos.spec.ts e2e-tests/local_agent_web_fetch.spec.ts e2e-tests/local_agent_run_type_checks.spec.ts - npm run fmt && npm run lint:fix && npm run ts - npm test <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3256" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com>
上级 968c654c
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Search ignored files using grep",
turns: [
{
text: "I'll search the ignored dependency folder for the requested symbol.",
toolCalls: [
{
name: "grep",
args: {
query: "ignoredNeedle",
include_pattern: "node_modules/ignored-pkg/**",
include_ignored: true,
},
},
],
},
{
text: "I found the symbol in the ignored dependency folder.",
},
],
};
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "List files including hidden .dyad files",
description: "List files including ignored .dyad files",
turns: [
{
text: "I'll list all files including the hidden .dyad directory for you.",
text: "I'll list all files including the ignored .dyad directory for you.",
toolCalls: [
{
name: "list_files",
args: {
directory: ".dyad",
recursive: true,
include_hidden: true,
include_ignored: true,
},
},
],
},
{
text: "Here are all the files including the hidden .dyad files.",
text: "Here are the ignored .dyad files.",
},
],
};
import { expect } from "@playwright/test";
import fs from "fs";
import path from "path";
import { testSkipIfWindows } from "./helpers/test_helper";
/**
......@@ -19,3 +21,28 @@ testSkipIfWindows("local-agent - grep search", async ({ po }) => {
await expect(po.page.getByTestId("dyad-grep").first()).toMatchAriaSnapshot();
await expect(po.page.getByTestId("dyad-grep").nth(1)).toMatchAriaSnapshot();
});
testSkipIfWindows(
"local-agent - grep searches ignored files when requested",
async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.chatActions.selectLocalAgentMode();
const appPath = await po.appManagement.getCurrentAppPath();
const ignoredPackageDir = path.join(appPath, "node_modules", "ignored-pkg");
fs.mkdirSync(ignoredPackageDir, { recursive: true });
fs.writeFileSync(
path.join(ignoredPackageDir, "index.js"),
"export const ignoredNeedle = 'search ignored files';\n",
);
await po.sendPrompt("tc=local-agent/grep-include-ignored");
const grepCard = po.page.getByTestId("dyad-grep").first();
await expect(grepCard).toContainText('"ignoredNeedle"');
await grepCard.click();
await expect(grepCard).toContainText("node_modules/ignored-pkg/index.js");
await expect(grepCard).toContainText("ignoredNeedle");
},
);
......@@ -21,13 +21,16 @@ testSkipIfWindows("local-agent - list_files", async ({ po }) => {
await expect(listFiles2).toMatchAriaSnapshot();
});
testSkipIfWindows("local-agent - list_files include_hidden", async ({ po }) => {
testSkipIfWindows(
"local-agent - list_files include_ignored",
async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal-with-dyad");
await po.chatActions.selectLocalAgentMode();
await po.sendPrompt("tc=local-agent/list-files-include-hidden");
await po.sendPrompt("tc=local-agent/list-files-include-ignored");
const listFiles = po.page.getByTestId("dyad-list-files").first();
await listFiles.click();
await expect(listFiles).toMatchAriaSnapshot();
});
},
);
import { expect } from "@playwright/test";
import { testSkipIfWindows } from "./helpers/test_helper";
/**
......@@ -18,5 +19,27 @@ testSkipIfWindows("local-agent - read logs with filters", async ({ po }) => {
// - Client logs from last minute
await po.sendPrompt("tc=local-agent/read-logs");
await po.snapshotMessages();
await expect(
po.page.getByText(
"Let me check the recent console logs to see what's happening in the application.",
),
).toBeVisible();
await expect(
po.page.getByRole("button", { name: /LOGS Reading \d+ logs$/ }),
).toBeVisible();
await expect(
po.page.getByRole("button", {
name: /LOGS Reading \d+ logs \(level: error\)/,
}),
).toBeVisible();
await expect(
po.page.getByRole("button", {
name: /LOGS Reading \d+ logs \(type: client\)/,
}),
).toBeVisible();
await expect(
po.page.getByText(
"I've reviewed the console logs. The application appears to be running normally with no critical errors detected.",
),
).toBeVisible();
});
......@@ -86,7 +86,7 @@
"type": "function",
"function": {
"name": "list_files",
"description": "List files in the application directory. By default, lists only the immediate directory contents. Use recursive=true to list all files recursively. If you are not sure, list all files by omitting the directory parameter.",
"description": "List files in the application directory. By default, lists only the immediate directory contents. Use recursive=true to list all files recursively. Use include_ignored=true to include git-ignored and hidden paths; recursive ignored listings require directory to be set. Results are capped at 1000 paths.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
......@@ -99,8 +99,8 @@
"description": "Whether to list files recursively (default: false)",
"type": "boolean"
},
"include_hidden": {
"description": "Whether to include .dyad files which are git-ignored (default: false)",
"include_ignored": {
"description": "Whether to include git-ignored and hidden files/directories such as node_modules (default: false).",
"type": "boolean"
}
},
......@@ -112,7 +112,7 @@
"type": "function",
"function": {
"name": "grep",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')\n- Results are limited to 100 matches by default (max 250). If results are truncated, narrow your search with include_pattern or a more specific query.",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')\n- Use include_ignored=true to search git-ignored and hidden files/directories such as node_modules. Pair it with include_pattern to keep searches scoped.\n- Results are limited to 100 matches by default (max 250). If results are truncated, narrow your search with include_pattern or a more specific query.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
......@@ -129,6 +129,10 @@
"description": "Glob pattern for files to exclude",
"type": "string"
},
"include_ignored": {
"description": "Whether to include git-ignored and hidden files/directories such as node_modules (default: false). Use include_pattern to keep this scoped.",
"type": "boolean"
},
"case_sensitive": {
"description": "Whether the search should be case sensitive (default: false)",
"type": "boolean"
......
......@@ -252,7 +252,7 @@
{
"type": "function",
"name": "list_files",
"description": "List files in the application directory. By default, lists only the immediate directory contents. Use recursive=true to list all files recursively. If you are not sure, list all files by omitting the directory parameter.",
"description": "List files in the application directory. By default, lists only the immediate directory contents. Use recursive=true to list all files recursively. Use include_ignored=true to include git-ignored and hidden paths; recursive ignored listings require directory to be set. Results are capped at 1000 paths.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
......@@ -265,8 +265,8 @@
"description": "Whether to list files recursively (default: false)",
"type": "boolean"
},
"include_hidden": {
"description": "Whether to include .dyad files which are git-ignored (default: false)",
"include_ignored": {
"description": "Whether to include git-ignored and hidden files/directories such as node_modules (default: false).",
"type": "boolean"
}
},
......@@ -276,7 +276,7 @@
{
"type": "function",
"name": "grep",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')\n- Results are limited to 100 matches by default (max 250). If results are truncated, narrow your search with include_pattern or a more specific query.",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')\n- Use include_ignored=true to search git-ignored and hidden files/directories such as node_modules. Pair it with include_pattern to keep searches scoped.\n- Results are limited to 100 matches by default (max 250). If results are truncated, narrow your search with include_pattern or a more specific query.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
......@@ -293,6 +293,10 @@
"description": "Glob pattern for files to exclude",
"type": "string"
},
"include_ignored": {
"description": "Whether to include git-ignored and hidden files/directories such as node_modules (default: false). Use include_pattern to keep this scoped.",
"type": "boolean"
},
"case_sensitive": {
"description": "Whether the search should be case sensitive (default: false)",
"type": "boolean"
......
......@@ -251,7 +251,7 @@
"type": "function",
"function": {
"name": "list_files",
"description": "List files in the application directory. By default, lists only the immediate directory contents. Use recursive=true to list all files recursively. If you are not sure, list all files by omitting the directory parameter.",
"description": "List files in the application directory. By default, lists only the immediate directory contents. Use recursive=true to list all files recursively. Use include_ignored=true to include git-ignored and hidden paths; recursive ignored listings require directory to be set. Results are capped at 1000 paths.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
......@@ -264,8 +264,8 @@
"description": "Whether to list files recursively (default: false)",
"type": "boolean"
},
"include_hidden": {
"description": "Whether to include .dyad files which are git-ignored (default: false)",
"include_ignored": {
"description": "Whether to include git-ignored and hidden files/directories such as node_modules (default: false).",
"type": "boolean"
}
},
......@@ -277,7 +277,7 @@
"type": "function",
"function": {
"name": "grep",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')\n- Results are limited to 100 matches by default (max 250). If results are truncated, narrow your search with include_pattern or a more specific query.",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')\n- Use include_ignored=true to search git-ignored and hidden files/directories such as node_modules. Pair it with include_pattern to keep searches scoped.\n- Results are limited to 100 matches by default (max 250). If results are truncated, narrow your search with include_pattern or a more specific query.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
......@@ -294,6 +294,10 @@
"description": "Glob pattern for files to exclude",
"type": "string"
},
"include_ignored": {
"description": "Whether to include git-ignored and hidden files/directories such as node_modules (default: false). Use include_pattern to keep this scoped.",
"type": "boolean"
},
"case_sensitive": {
"description": "Whether the search should be case sensitive (default: false)",
"type": "boolean"
......
- button "src - src/App.tsx - src/main.tsx - src/vite-env.d.ts (3 files total)" [expanded]:
- button "src - src/App.tsx - src/main.tsx - src/vite-env.d.ts (3 paths total)" [expanded]:
- img
- text: ""
- img
......
- button "src recursive - src/App.tsx - src/main.tsx - src/vite-env.d.ts (3 files total)" [expanded]:
- button "src recursive - src/App.tsx - src/main.tsx - src/vite-env.d.ts (3 paths total)" [expanded]:
- img
- text: ""
- img
......
- button ".dyad recursive include ignored - .dyad/ - .dyad/plans/ - .dyad/plans/test-plan.md (3 paths total)" [expanded]:
- img
- text: ""
- img
- text: ""
\ No newline at end of file
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- button "file1.txt file1.txt Edit":
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- paragraph: More EOM
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Copy Request ID":
- img
- text: ""
- paragraph: tc=local-agent/read-logs
- paragraph: Let me check the recent console logs to see what's happening in the application.
- button /LOGS Reading \d+ logs/:
- img
- text: ""
- img
- paragraph: Now let me filter for only error logs to identify any issues.
- 'button "LOGS Reading 0 logs (level: error)"':
- img
- text: ""
- img
- paragraph: Let me also check client-side logs specifically.
- 'button "LOGS Reading 0 logs (type: client)"':
- img
- text: ""
- img
- paragraph: I've reviewed the console logs. The application appears to be running normally with no critical errors detected.
- button "Copy":
- img
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Copy Request ID":
- img
- text: ""
- button "Undo":
- img
- text: ""
- button "Retry":
- img
- text: ""
\ No newline at end of file
......@@ -15,7 +15,7 @@ interface DyadListFilesProps {
properties: {
directory?: string;
recursive?: string;
include_hidden?: string;
include_ignored?: string;
state?: CustomTagState;
};
};
......@@ -23,10 +23,10 @@ interface DyadListFilesProps {
}
export function DyadListFiles({ node, children }: DyadListFilesProps) {
const { directory, recursive, include_hidden, state } = node.properties;
const { directory, recursive, include_ignored, state } = node.properties;
const isLoading = state === "pending";
const isRecursive = recursive === "true";
const isIncludeHidden = include_hidden === "true";
const isIncludeIgnored = include_ignored === "true";
const content = typeof children === "string" ? children : "";
const [isExpanded, setIsExpanded] = useState(false);
......@@ -45,7 +45,9 @@ export function DyadListFiles({ node, children }: DyadListFilesProps) {
{title}
</span>
{isRecursive && <DyadBadge color="slate">recursive</DyadBadge>}
{isIncludeHidden && <DyadBadge color="slate">include hidden</DyadBadge>}
{isIncludeIgnored && (
<DyadBadge color="slate">include ignored</DyadBadge>
)}
{isLoading && (
<DyadStateIndicator state="pending" pendingLabel="Listing..." />
)}
......
......@@ -710,7 +710,8 @@ function renderCustomTag(
properties: {
directory: attributes.directory || "",
recursive: attributes.recursive || "",
include_hidden: attributes.include_hidden || "",
include_ignored:
attributes.include_ignored || attributes.include_hidden || "",
state: getState({ isStreaming, inProgress }),
},
}}
......
......@@ -289,6 +289,63 @@ function deepHello() {
expect(result).toContain("test1.ts");
expect(result).not.toContain("node_modules");
});
it("searches node_modules when include_ignored is true", async () => {
const nodeModulesDir = path.join(testDir, "node_modules", "some-pkg");
await fs.promises.mkdir(nodeModulesDir, { recursive: true });
await fs.promises.writeFile(
path.join(nodeModulesDir, "index.js"),
`function dependencyHello() { return "hello from node_modules"; }`,
);
const result = await grepTool.execute(
{
query: "dependencyHello",
include_ignored: true,
include_pattern: "node_modules/some-pkg/**",
},
mockContext,
);
expect(result).toContain("node_modules/some-pkg/index.js");
expect(result).toContain("dependencyHello");
});
it("searches hidden ignored files when include_ignored is true", async () => {
const dyadDir = path.join(testDir, ".dyad");
await fs.promises.mkdir(dyadDir, { recursive: true });
await fs.promises.writeFile(
path.join(dyadDir, "backup.txt"),
"hiddenIgnoredNeedle",
);
const result = await grepTool.execute(
{
query: "hiddenIgnoredNeedle",
include_ignored: true,
include_pattern: ".dyad/**",
},
mockContext,
);
expect(result).toContain(".dyad/backup.txt");
});
it("keeps .git excluded when include_ignored is true", async () => {
const gitDir = path.join(testDir, ".git");
await fs.promises.mkdir(gitDir, { recursive: true });
await fs.promises.writeFile(
path.join(gitDir, "config"),
"gitIgnoredNeedle",
);
const result = await grepTool.execute(
{ query: "gitIgnoredNeedle", include_ignored: true },
mockContext,
);
expect(result).toBe("No matches found.");
});
});
describe("execute - regex patterns", () => {
......@@ -357,6 +414,35 @@ function deepHello() {
expect.stringContaining('total="'),
);
});
it("stops ignored searches after collecting enough matches", async () => {
const nodeModulesDir = path.join(testDir, "node_modules", "many-pkg");
await fs.promises.mkdir(nodeModulesDir, { recursive: true });
await Promise.all(
Array.from({ length: 20 }, (_, index) =>
fs.promises.writeFile(
path.join(nodeModulesDir, `file-${index}.js`),
"ignoredSearchNeedle\n",
),
),
);
const result = await grepTool.execute(
{
query: "ignoredSearchNeedle",
include_ignored: true,
include_pattern: "node_modules/many-pkg/**",
limit: 3,
},
mockContext,
);
const matchLines = result
.split("\n")
.filter((line) => line.match(/:\d+:/));
expect(matchLines).toHaveLength(3);
expect(result).toContain("[TRUNCATED: Showing 3 of at least 4 matches.");
});
});
describe("execute - result sorting", () => {
......@@ -460,6 +546,14 @@ function deepHello() {
expect(result).toContain('exclude="*.md"');
});
it("includes include_ignored in attributes", () => {
const result = grepTool.buildXml?.(
{ query: "test", include_ignored: true },
false,
);
expect(result).toContain('include_ignored="true"');
});
it("includes case-sensitive in attributes when true", () => {
const result = grepTool.buildXml?.(
{ query: "test", case_sensitive: true },
......@@ -482,5 +576,13 @@ function deepHello() {
});
expect(preview).toBe('Search for "hello" in *.ts');
});
it("includes include_ignored in preview", () => {
const preview = grepTool.getConsentPreview?.({
query: "hello",
include_ignored: true,
});
expect(preview).toBe('Search for "hello" including ignored files');
});
});
});
......@@ -31,6 +31,12 @@ const grepSchema = z.object({
.string()
.optional()
.describe("Glob pattern for files to exclude"),
include_ignored: z
.boolean()
.optional()
.describe(
"Whether to include git-ignored and hidden files/directories such as node_modules (default: false). Use include_pattern to keep this scoped.",
),
case_sensitive: z
.boolean()
.optional()
......@@ -66,6 +72,9 @@ function buildGrepAttributes(
if (args.exclude_pattern) {
attrs.push(`exclude="${escapeXmlAttr(args.exclude_pattern)}"`);
}
if (args.include_ignored) {
attrs.push(`include_ignored="true"`);
}
if (args.case_sensitive) {
attrs.push(`case-sensitive="true"`);
}
......@@ -91,16 +100,21 @@ async function runRipgrep({
query,
includePat,
excludePat,
includeIgnored,
caseSensitive,
maxMatches,
}: {
appPath: string;
query: string;
includePat?: string;
excludePat?: string;
includeIgnored?: boolean;
caseSensitive?: boolean;
}): Promise<RipgrepMatch[]> {
maxMatches?: number;
}): Promise<{ matches: RipgrepMatch[]; stoppedEarly: boolean }> {
return new Promise((resolve, reject) => {
const results: RipgrepMatch[] = [];
let stoppedEarly = false;
const args: string[] = [
"--json",
"--no-config",
......@@ -108,6 +122,10 @@ async function runRipgrep({
`${MAX_FILE_SEARCH_SIZE}`,
];
if (includeIgnored) {
args.push("--no-ignore", "--hidden");
}
// Case sensitivity: default is case-insensitive
if (!caseSensitive) {
args.push("--ignore-case");
......@@ -126,7 +144,10 @@ async function runRipgrep({
// Exclusion globs come LAST so they always take precedence over any
// include pattern (later --glob flags override earlier ones in ripgrep)
args.push(...RIPGREP_EXCLUDED_GLOBS.flatMap((glob) => ["--glob", glob]));
const exclusionGlobs = includeIgnored
? RIPGREP_EXCLUDED_GLOBS.filter((glob) => glob === "!.git/**")
: RIPGREP_EXCLUDED_GLOBS;
args.push(...exclusionGlobs.flatMap((glob) => ["--glob", glob]));
args.push("--", query, ".");
......@@ -134,6 +155,10 @@ async function runRipgrep({
let buffer = "";
rg.stdout.on("data", (data) => {
if (stoppedEarly) {
return;
}
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
......@@ -159,11 +184,23 @@ async function runRipgrep({
// Normalize path (remove leading ./)
const normalizedPath = matchPath.replace(/^\.\//, "");
if (maxMatches !== undefined && results.length >= maxMatches) {
stoppedEarly = true;
rg.kill();
break;
}
results.push({
path: normalizedPath,
lineNumber,
lineText: lineText.replace(/\r?\n$/, ""),
});
if (maxMatches !== undefined && results.length >= maxMatches) {
stoppedEarly = true;
rg.kill();
break;
}
} catch {
// Skip malformed JSON lines
}
......@@ -175,12 +212,17 @@ async function runRipgrep({
});
rg.on("close", (code) => {
if (stoppedEarly) {
resolve({ matches: results, stoppedEarly });
return;
}
// rg exits with code 1 when no matches are found; treat as success
if (code !== 0 && code !== 1) {
reject(new Error(`ripgrep exited with code ${code}`));
return;
}
resolve(results);
resolve({ matches: results, stoppedEarly });
});
rg.on("error", (error) => {
......@@ -197,6 +239,7 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
- By default, the search is case-insensitive
- Use include_pattern to filter by file type (e.g. '*.tsx')
- Use exclude_pattern to skip certain files (e.g. '*.test.ts')
- Use include_ignored=true to search git-ignored and hidden files/directories such as node_modules. Pair it with include_pattern to keep searches scoped.
- Results are limited to ${DEFAULT_LIMIT} matches by default (max ${MAX_LIMIT}). If results are truncated, narrow your search with include_pattern or a more specific query.`,
inputSchema: grepSchema,
defaultConsent: "always",
......@@ -206,6 +249,9 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
if (args.include_pattern) {
preview += ` in ${args.include_pattern}`;
}
if (args.include_ignored) {
preview += " including ignored files";
}
return preview;
},
......@@ -222,23 +268,25 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
execute: async (args, ctx: AgentContext) => {
const includePatWasWildcard = args.include_pattern === "*";
const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
const allMatches = await runRipgrep({
const { matches: allMatches, stoppedEarly } = await runRipgrep({
appPath: ctx.appPath,
query: args.query,
includePat: args.include_pattern,
excludePat: args.exclude_pattern,
includeIgnored: args.include_ignored,
caseSensitive: args.case_sensitive,
maxMatches: args.include_ignored ? limit + 1 : undefined,
});
const totalCount = allMatches.length;
const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
// Sort for deterministic output (ripgrep's parallel execution can produce varying order)
const sortedMatches = [...allMatches].sort(
(a, b) => a.path.localeCompare(b.path) || a.lineNumber - b.lineNumber,
);
const matches = sortedMatches.slice(0, limit);
const wasTruncated = totalCount > limit;
const wasTruncated = stoppedEarly || totalCount > limit;
const attrs = buildGrepAttributes(args, matches.length, totalCount);
......@@ -255,7 +303,8 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
// Add truncation notice for the AI
if (wasTruncated) {
resultText += `\n\n[TRUNCATED: Showing ${matches.length} of ${totalCount} matches. Use include_pattern to narrow your search (e.g., include_pattern="*.tsx") or use a more specific query.]`;
const totalText = stoppedEarly ? `at least ${totalCount}` : totalCount;
resultText += `\n\n[TRUNCATED: Showing ${matches.length} of ${totalText} matches. Use include_pattern to narrow your search (e.g., include_pattern="*.tsx") or use a more specific query.]`;
}
// Warn the LLM that "*" was ignored so it doesn't retry with the same pattern
......
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { listFilesTool } from "./list_files";
import type { AgentContext } from "./types";
import { DyadErrorKind } from "@/errors/dyad_error";
vi.mock("electron-log", () => ({
default: {
scope: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
},
}));
describe("listFilesTool", () => {
let testDir: string;
let mockContext: AgentContext;
beforeEach(async () => {
testDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "list-files-test-"),
);
await fs.promises.writeFile(path.join(testDir, "src.ts"), "source");
await fs.promises.mkdir(path.join(testDir, "node_modules", "pkg"), {
recursive: true,
});
await fs.promises.writeFile(
path.join(testDir, "node_modules", "pkg", "index.js"),
"dependency",
);
await fs.promises.mkdir(path.join(testDir, ".dyad"), { recursive: true });
await fs.promises.writeFile(
path.join(testDir, ".dyad", "snapshot.json"),
"{}",
);
await fs.promises.mkdir(path.join(testDir, ".git"), { recursive: true });
await fs.promises.writeFile(
path.join(testDir, ".git", "config"),
"should stay hidden",
);
mockContext = {
event: {} as any,
appId: 1,
appPath: testDir,
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
neonProjectId: null,
neonActiveBranchId: null,
frameworkType: null,
messageId: 1,
isSharedModulesChanged: false,
isDyadPro: false,
todos: [],
dyadRequestId: "test-request",
fileEditTracker: {},
onXmlStream: vi.fn(),
onXmlComplete: vi.fn(),
requireConsent: vi.fn().mockResolvedValue(true),
appendUserMessage: vi.fn(),
onUpdateTodos: vi.fn(),
};
});
afterEach(async () => {
await fs.promises.rm(testDir, { recursive: true, force: true });
vi.clearAllMocks();
});
it("accepts include_ignored in the schema", () => {
expect(() =>
listFilesTool.inputSchema.parse({ include_ignored: true }),
).not.toThrow();
});
it("includes ignored files when include_ignored is true", async () => {
const result = await listFilesTool.execute(
{ directory: "node_modules", recursive: true, include_ignored: true },
mockContext,
);
expect(result).toContain(" - node_modules/pkg/");
expect(result).toContain(" - node_modules/pkg/index.js");
expect(result).not.toContain(".git/config");
});
it("lists directories before files", async () => {
const result = await listFilesTool.execute(
{ directory: "node_modules", recursive: true, include_ignored: true },
mockContext,
);
const directoryIndex = result.indexOf(" - node_modules/pkg/");
const fileIndex = result.indexOf(" - node_modules/pkg/index.js");
expect(directoryIndex).toBeGreaterThanOrEqual(0);
expect(fileIndex).toBeGreaterThanOrEqual(0);
expect(directoryIndex).toBeLessThan(fileIndex);
});
it("includes include_ignored in XML", async () => {
await listFilesTool.execute(
{ directory: "node_modules", recursive: true, include_ignored: true },
mockContext,
);
expect(mockContext.onXmlComplete).toHaveBeenCalledWith(
expect.stringContaining('include_ignored="true"'),
);
});
it("rejects recursive ignored listings without a directory", async () => {
await expect(
listFilesTool.execute(
{ recursive: true, include_ignored: true },
mockContext,
),
).rejects.toMatchObject({
kind: DyadErrorKind.Validation,
message:
"include_ignored=true with recursive=true requires a non-root directory to avoid listing too many files.",
});
});
it("rejects recursive ignored listings for the app root", async () => {
await expect(
listFilesTool.execute(
{ directory: ".", recursive: true, include_ignored: true },
mockContext,
),
).rejects.toMatchObject({
kind: DyadErrorKind.Validation,
message:
"include_ignored=true with recursive=true requires a non-root directory to avoid listing too many files.",
});
});
it("caps returned paths at 1000", async () => {
const generatedDir = path.join(testDir, "generated");
await fs.promises.mkdir(generatedDir);
await Promise.all(
Array.from({ length: 1005 }, (_, index) =>
fs.promises.writeFile(
path.join(generatedDir, `file-${String(index).padStart(4, "0")}.txt`),
"generated",
),
),
);
const result = await listFilesTool.execute(
{ directory: "generated", recursive: true, include_ignored: true },
mockContext,
);
const listedPathCount = result
.split("\n")
.filter((line) => line.startsWith(" - ")).length;
expect(listedPathCount).toBe(1000);
expect(result).toContain("[TRUNCATED: Showing 1000 of ");
expect(mockContext.onXmlComplete).toHaveBeenCalledWith(
expect.stringContaining('truncated="true"'),
);
});
});
......@@ -9,6 +9,9 @@ import {
} from "./types";
import { extractCodebase } from "../../../../../../utils/codebase";
import { resolveDirectoryWithinAppPath } from "./path_safety";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const MAX_PATHS_TO_RETURN = 1_000;
const listFilesSchema = z.object({
directory: z.string().optional().describe("Optional subdirectory to list"),
......@@ -16,42 +19,64 @@ const listFilesSchema = z.object({
.boolean()
.optional()
.describe("Whether to list files recursively (default: false)"),
include_hidden: z
include_ignored: z
.boolean()
.optional()
.describe(
"Whether to include .dyad files which are git-ignored (default: false)",
"Whether to include git-ignored and hidden files/directories such as node_modules (default: false).",
),
});
type ListFilesArgs = z.infer<typeof listFilesSchema>;
function getXmlAttributes(args: ListFilesArgs) {
interface ListedPath {
path: string;
isDirectory: boolean;
}
function getDisplayPath(entry: ListedPath): string {
return entry.isDirectory ? `${entry.path}/` : entry.path;
}
function sortListedPaths(entries: ListedPath[]): ListedPath[] {
return [...entries].sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
return a.path.localeCompare(b.path);
});
}
function getXmlAttributes(args: ListFilesArgs, count?: number, total?: number) {
const dirAttr = args.directory
? ` directory="${escapeXmlAttr(args.directory)}"`
: "";
const recursiveAttr =
args.recursive !== undefined ? ` recursive="${args.recursive}"` : "";
const includeHiddenAttr =
args.include_hidden !== undefined
? ` include_hidden="${args.include_hidden}"`
const includeIgnoredAttr =
args.include_ignored !== undefined
? ` include_ignored="${args.include_ignored}"`
: "";
return `${dirAttr}${recursiveAttr}${includeHiddenAttr}`;
const countAttr = count !== undefined ? ` count="${count}"` : "";
const totalAttr =
total !== undefined && total > (count ?? 0) ? ` total="${total}"` : "";
const truncatedAttr = totalAttr ? ` truncated="true"` : "";
return `${dirAttr}${recursiveAttr}${includeIgnoredAttr}${countAttr}${totalAttr}${truncatedAttr}`;
}
export const listFilesTool: ToolDefinition<ListFilesArgs> = {
name: "list_files",
description:
"List files in the application directory. By default, lists only the immediate directory contents. Use recursive=true to list all files recursively. If you are not sure, list all files by omitting the directory parameter.",
"List files in the application directory. By default, lists only the immediate directory contents. Use recursive=true to list all files recursively. Use include_ignored=true to include git-ignored and hidden paths; recursive ignored listings require directory to be set. Results are capped at 1000 paths.",
inputSchema: listFilesSchema,
defaultConsent: "always",
getConsentPreview: (args) => {
const recursiveText = args.recursive ? " (recursive)" : "";
const hiddenText = args.include_hidden ? " (include hidden)" : "";
const ignoredText = args.include_ignored ? " (include ignored)" : "";
return args.directory
? `List ${args.directory}${recursiveText}${hiddenText}`
: `List all files${recursiveText}${hiddenText}`;
? `List ${args.directory}${recursiveText}${ignoredText}`
: `List all files${recursiveText}${ignoredText}`;
},
buildXml: (args, isComplete) => {
......@@ -80,12 +105,40 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
sanitizedDirectory = normalizedRelativePath || undefined;
}
if (args.include_ignored && args.recursive && !sanitizedDirectory) {
throw new DyadError(
"include_ignored=true with recursive=true requires a non-root directory to avoid listing too many files.",
DyadErrorKind.Validation,
);
}
// Use "**" for recursive, "*" for non-recursive (immediate children only)
const globSuffix = args.recursive ? "/**" : "/*";
const globPath = sanitizedDirectory
? sanitizedDirectory + globSuffix
: globSuffix.slice(1); // Remove leading "/" for root directory
let allPaths: ListedPath[];
if (args.include_ignored) {
const normalizedAppPath = ctx.appPath.replace(/\\/g, "/");
const globPattern = `${normalizedAppPath}/${globPath}`;
const ignoredPaths = await glob(globPattern, {
withFileTypes: true,
dot: true,
ignore: ["**/.git", "**/.git/**"],
});
allPaths = sortListedPaths(
ignoredPaths.map((entry) => ({
path: path
.relative(ctx.appPath, entry.fullpath())
.split(path.sep)
.join("/"),
isDirectory: entry.isDirectory(),
})),
);
} else {
const { files } = await extractCodebase({
appPath: ctx.appPath,
chatContext: {
......@@ -96,60 +149,43 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
});
// Build the list of file paths
let allFilePaths = files.map((file) => file.path);
// If include_hidden is true, also include .dyad files
if (args.include_hidden) {
const normalizedAppPath = ctx.appPath.replace(/\\/g, "/");
// Always search .dyad at the app root, regardless of directory filter
const dyadGlobPattern = `${normalizedAppPath}/.dyad${globSuffix}`;
const dyadFiles = await glob(dyadGlobPattern, {
nodir: true,
absolute: true,
ignore: [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/build/**",
"**/.next/**",
"**/.venv/**",
"**/venv/**",
],
});
// Convert to relative paths and add to the list
const dyadRelativePaths = dyadFiles.map((file) =>
path.relative(ctx.appPath, file).split(path.sep).join("/"),
allPaths = sortListedPaths(
files.map((file) => ({
path: file.path,
isDirectory: false,
})),
);
// Deduplicate and sort
allFilePaths = [
...new Set([...allFilePaths, ...dyadRelativePaths]),
].sort();
}
const totalCount = allPaths.length;
const cappedPaths = allPaths.slice(0, MAX_PATHS_TO_RETURN);
const wasTruncated = totalCount > cappedPaths.length;
// Build full file list for LLM
const allFilesList =
allFilePaths.map((filePath) => " - " + filePath).join("\n") || "";
cappedPaths.map((entry) => " - " + getDisplayPath(entry)).join("\n") ||
"";
const resultText = wasTruncated
? `${allFilesList}\n\n[TRUNCATED: Showing ${cappedPaths.length} of ${totalCount} paths. Use directory to narrow the listing.]`
: allFilesList;
// Build abbreviated list for UI display
const MAX_FILES_TO_SHOW = 20;
const totalCount = allFilePaths.length;
const displayedFiles = allFilePaths.slice(0, MAX_FILES_TO_SHOW);
const displayedFiles = cappedPaths.slice(0, MAX_FILES_TO_SHOW);
const abbreviatedList =
displayedFiles.map((filePath) => " - " + filePath).join("\n") || "";
displayedFiles.map((entry) => " - " + getDisplayPath(entry)).join("\n") ||
"";
const countInfo =
totalCount > MAX_FILES_TO_SHOW
? `\n... and ${totalCount - MAX_FILES_TO_SHOW} more files (${totalCount} total)`
: `\n(${totalCount} files total)`;
? `\n... and ${totalCount - MAX_FILES_TO_SHOW} more paths (${totalCount} total)`
: `\n(${totalCount} paths total)`;
// Write abbreviated list to UI
ctx.onXmlComplete(
`<dyad-list-files${getXmlAttributes(args)}>${escapeXmlContent(abbreviatedList + countInfo)}</dyad-list-files>`,
`<dyad-list-files${getXmlAttributes(args, cappedPaths.length, totalCount)}>${escapeXmlContent(abbreviatedList + countInfo)}</dyad-list-files>`,
);
// Return full file list for LLM
return allFilesList;
return resultText;
},
};
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论