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
13 changes: 11 additions & 2 deletions packages/coding-agent/src/core/tools/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ export interface FindToolDetails {
truncation?: TruncationResult;
resultLimitReached?: number;
backend?: FindBackendName;
/**
* Resolved match list, exactly as rendered, with no notices attached.
* Consumers that need paths should read this rather than parsing the
* rendered text: a valid path may itself start with `[` or `WARNING:`,
* which are the same prefixes used by the appended notice block.
* `undefined` means the backend did not report paths (custom operations);
* an empty array means the search genuinely matched nothing.
*/
files?: string[];
}

/**
Expand Down Expand Up @@ -440,7 +449,7 @@ function finalizeOutput(
if (rawPaths.length === 0) {
return {
content: [{ type: "text", text: "No files found matching pattern" }],
details: { backend },
details: { backend, files: [] },
};
}

Expand All @@ -455,7 +464,7 @@ function finalizeOutput(
const rawOutput = relativized.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let resultOutput = truncation.content;
const details: FindToolDetails = { backend };
const details: FindToolDetails = { backend, files: relativized };
const notices: string[] = [];
if (resultLimitReached) {
notices.push(
Expand Down
42 changes: 33 additions & 9 deletions packages/coding-agent/src/step/tool-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,17 +597,38 @@ function nativeDetails(result: AnyResult): Record<string, unknown> {
return result.details && typeof result.details === "object" ? (result.details as Record<string, unknown>) : {};
}

/**
* Read the structured match list the find tool reports in `details.files`.
* Returns `[]` when the search genuinely matched nothing, and `undefined` when
* the backend did not report paths at all (custom/remote find operations), so
* the caller knows whether to trust an empty result or fall back to text.
*/
function findFilesFromDetails(result: AnyResult): string[] | undefined {
const files = nativeDetails(result).files;
if (!Array.isArray(files)) return undefined;
const paths: string[] = [];
for (const entry of files) {
if (typeof entry !== "string") return undefined;
const display = entry.trim();
if (display.length > 0) paths.push(display);
}
return paths;
}

/**
* Fallback for backends that do not report `details.files` (custom/remote find
* operations). Native find appends its notices as one bracketed block after a
* blank line, so strip that block by shape instead of dropping every line that
* starts with "[" — bracket-prefixed paths such as `[id]/page.tsx` are valid
* matches, and a narrowed search returns them relative to the search root, so
* they arrive with the bracket in the first position.
*/
function stripNativeFindNotices(text: string): string[] {
return text
const withoutNotice = text.replace(/\n\n\[[^\n]*\]$/u, "");
return withoutNotice
.split("\n")
.map((line) => line.trim())
.filter(
(line) =>
line.length > 0 &&
line !== "No files found matching pattern" &&
!line.startsWith("[") &&
!line.startsWith("WARNING:"),
);
.filter((line) => line.length > 0 && line !== "No files found matching pattern");
}

function resolveNativePath(display: string, searchRoot: string): string {
Expand Down Expand Up @@ -663,7 +684,10 @@ async function executeFindFiles(
}

const result = execution.result;
const rawPaths = stripNativeFindNotices(textFromResult(result));
// Prefer the paths the find tool already resolved; only backends that report
// no structured list need the rendered text parsed.
const structuredPaths = findFilesFromDetails(result);
const rawPaths = structuredPaths ?? stripNativeFindNotices(textFromResult(result));
const searchRoot = resolveToCwd(nativeArgs.path ?? ".", effectiveCwd);
const ranked = await Promise.all(
rawPaths.map(async (display) => {
Expand Down
53 changes: 53 additions & 0 deletions packages/coding-agent/test/step-tool-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,59 @@ describe("Step tool profile", () => {
}
});

it("keeps bracket-prefixed paths in find_files when the search is narrowed", async () => {
// A narrowed search returns paths relative to the search root, so a route
// directory such as "[id]" lands in the first position of the line — the
// same position a native notice block uses.
const root = await mkdtemp(join(tmpdir(), "step-tool-profile-"));
try {
for (const route of ["[id]", "[...slug]", "[[...slug]]", "static"]) {
await mkdir(join(root, "routes", route), { recursive: true });
await writeFile(join(root, "routes", route, "page.tsx"), "export default 1;\n");
}
const find = createStepToolProfile(root).find((tool) => tool.name === "find_files")!;
const result = await find.execute(
"find",
{ pattern: "*.tsx", path: "routes", max_results: 10 },
undefined,
undefined,
undefined as never,
);
expect(text(result).split("\n").sort()).toEqual([
"[...slug]/page.tsx",
"[[...slug]]/page.tsx",
"[id]/page.tsx",
"static/page.tsx",
]);
expect(result.details).toMatchObject({ matchedFiles: 4, returnedFiles: 4 });
} finally {
await rm(root, { recursive: true, force: true });
}
});

it("keeps paths that start with a notice keyword in find_files", async () => {
// "WARNING:" is only usable in a filename off Windows; the stripping this
// guards against was prefix-based, so the case is platform-bound.
if (process.platform === "win32") return;
const root = await mkdtemp(join(tmpdir(), "step-tool-profile-"));
try {
await writeFile(join(root, "WARNING: read me.ts"), "export default 1;\n");
await writeFile(join(root, "plain.ts"), "export default 1;\n");
const find = createStepToolProfile(root).find((tool) => tool.name === "find_files")!;
const result = await find.execute(
"find",
{ pattern: "*.ts", max_results: 10 },
undefined,
undefined,
undefined as never,
);
expect(text(result).split("\n").sort()).toEqual(["WARNING: read me.ts", "plain.ts"]);
expect(result.details).toMatchObject({ matchedFiles: 2, returnedFiles: 2 });
} finally {
await rm(root, { recursive: true, force: true });
}
});

it("maps search_files context into native grep output", async () => {
const root = await mkdtemp(join(tmpdir(), "step-tool-profile-"));
try {
Expand Down
Loading