From 756a88350b18abd2519be80af6127deea7337433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=A7=80=E5=A5=87?= Date: Wed, 21 Oct 2026 02:22:49 +0800 Subject: [PATCH] fix(step): read find_files paths from the native tool instead of its text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Step find adapter dropped every native output line starting with "[" or "WARNING:" to remove the notices the find tool appends. A narrowed search returns paths relative to the search root, so `[id]/page.tsx` arrives with the bracket in the first position and was discarded as a notice: `find_files` reported `(no matches)` and `matchedFiles: 0` for a directory the backend had already resolved. The find tool now reports its match list in `details.files`, and the adapter reads that list instead of parsing rendered text. Text parsing stays as a fallback for injected backends that report no list, and it now removes the notice block by shape — one bracketed block after a blank line — rather than by line prefix. --- packages/coding-agent/src/core/tools/find.ts | 13 ++++- .../coding-agent/src/step/tool-profile.ts | 42 +++++++++++---- .../test/step-tool-profile.test.ts | 53 +++++++++++++++++++ 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 06c96538..5f88abcf 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -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[]; } /** @@ -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: [] }, }; } @@ -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( diff --git a/packages/coding-agent/src/step/tool-profile.ts b/packages/coding-agent/src/step/tool-profile.ts index fe04f4be..72b6cc34 100644 --- a/packages/coding-agent/src/step/tool-profile.ts +++ b/packages/coding-agent/src/step/tool-profile.ts @@ -597,17 +597,38 @@ function nativeDetails(result: AnyResult): Record { return result.details && typeof result.details === "object" ? (result.details as Record) : {}; } +/** + * 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 { @@ -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) => { diff --git a/packages/coding-agent/test/step-tool-profile.test.ts b/packages/coding-agent/test/step-tool-profile.test.ts index 8ed3581c..a6220182 100644 --- a/packages/coding-agent/test/step-tool-profile.test.ts +++ b/packages/coding-agent/test/step-tool-profile.test.ts @@ -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 {