refactor(packages/coding-agent): migrated search args to paths arrays

- Switched search/ast_grep/ast_edit/find inputs from scalar path fields to required `paths` arrays.
- Reworked path resolution to normalize and expand each `paths` entry via explicit helpers in `src/tools/path-utils.ts`.
- Updated search and find tool-call rendering to display explicit `paths` values in mode overlays and export views.
- Updated tool prompt docs and examples to document `paths` array inputs for search, grep, find, and AST tools.
- Raised the default search match limit from 20 to 500 and updated limit-reached messaging.
This commit is contained in:
can1357
2026-05-02 04:33:53 +02:00
parent ffdec04af2
commit f0e9a830ff
21 changed files with 267 additions and 417 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
const toolResultMessage = await executeTool(this.options, "search", toolCallId, {
pattern: args.pattern,
path: searchPath,
paths: [searchPath],
i: args.caseInsensitive || undefined,
});
return toolResultMessage;
File diff suppressed because one or more lines are too long
@@ -416,9 +416,9 @@
return `[bash: ${cmd}${rawCmd.length > 50 ? '...' : ''}]`;
}
case 'grep':
return `[grep: /${args.pattern || ''}/ in ${shortenPath(String(args.path || '.'))}]`;
return `[grep: /${args.pattern || ''}/ in ${shortenPath(String((args.paths || [args.path || '.']).join(', ')))}]`;
case 'find':
return `[find: ${args.pattern || ''} in ${shortenPath(String(args.path || '.'))}]`;
return `[find: ${shortenPath(String((args.paths || [args.pattern || '.']).join(', ')))}]`;
case 'ls':
return `[ls: ${shortenPath(String(args.path || '.'))}]`;
default: {
@@ -519,11 +519,14 @@ export class SessionObserverOverlayComponent extends Container {
case "edit":
return args.path ? `path: ${args.path}` : "";
case "search":
return [args.pattern ? `pattern: ${args.pattern}` : "", args.path ? `path: ${args.path}` : ""]
return [
args.pattern ? `pattern: ${args.pattern}` : "",
Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "",
]
.filter(Boolean)
.join(", ");
case "find":
return args.pattern ? `pattern: ${args.pattern}` : "";
return Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "";
case "bash": {
const cmd = args.command;
return typeof cmd === "string" ? replaceTabs(cmd) : "";
@@ -667,13 +667,12 @@ class TreeList implements Component {
}
case "search": {
const pattern = String(args.pattern || "");
const path = shortenPath(String(args.path || "."));
return `[search: /${pattern}/ in ${path}]`;
const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.path || ".");
return `[search: /${pattern}/ in ${shortenPath(paths)}]`;
}
case "find": {
const pattern = String(args.pattern || "");
const path = shortenPath(String(args.path || "."));
return `[find: ${pattern} in ${path}]`;
const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.pattern || ".");
return `[find: ${shortenPath(paths)}]`;
}
case "ls": {
const path = shortenPath(String(args.path || "."));
@@ -2,8 +2,8 @@ Performs structural AST-aware rewrites via native ast-grep.
<instruction>
- Use for codemods and structural rewrites where plain text replace is unsafe
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
- Language is inferred from `path`; narrow `path` to one language for deterministic rewrites
- `paths` is required and accepts an array of files, directories, globs, or internal URLs
- Language is inferred from `paths`; narrow each call to one language for deterministic rewrites
- Metavariables captured in `pat` (`$A`, `$$$ARGS`) are substituted into that entry's `out` template
- **Patterns match AST structure, not text.** `$NAME` = one node (captured); `$_` = one without binding; `$$$NAME` = zero-or-more (lazy — stops at next matchable element); `$$$` = zero-or-more without binding. Use `$$$NAME`, **NOT** `$$NAME` — the two-dollar form is invalid. Metavariable names are UPPERCASE and **MUST** be the whole AST node — partial text like `prefix$VAR` or `"hello $NAME"` does NOT work
- When the same metavariable appears twice, both occurrences **MUST** match identical code (`$A == $A` matches `x == x`, not `x == y`)
@@ -20,17 +20,17 @@ Performs structural AST-aware rewrites via native ast-grep.
<examples>
# Rename a call site across TypeScript files
`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"path":"src/**/*.ts"}`
`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"paths":["src/**/*.ts"]}`
# Delete matching calls
`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"path":"src/**/*.ts"}`
`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"paths":["src/**/*.ts"]}`
# Rewrite import source path
`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"path":"src/**/*.ts"}`
`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"paths":["src/**/*.ts"]}`
# Modernize to optional chaining (same metavariable enforces identity)
`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"path":"src/**/*.ts"}`
`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"paths":["src/**/*.ts"]}`
# Swap two arguments using captures
`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"path":"tests/**/*.ts"}`
`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"paths":["tests/**/*.ts"]}`
# Python — convert print calls to logging
`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"path":"src/**/*.py"}`
`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"paths":["src/**/*.py"]}`
</examples>
<critical>
@@ -2,8 +2,8 @@ Performs structural code search using AST matching via native ast-grep.
<instruction>
- Use when syntax shape matters more than raw text (calls, declarations, specific language constructs)
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
- Language is inferred from `path`; narrow `path` to one language when mixed-language trees could cause parse noise
- `paths` is required and accepts an array of files, directories, globs, or internal URLs
- Language is inferred from `paths`; narrow each call to one language when mixed-language trees could cause parse noise
- `pat` is a single AST pattern. Run separate calls for distinct unrelated patterns
- **Patterns match AST structure, not text** — whitespace/formatting is ignored
- `$NAME` captures one node; `$_` matches one without binding; `$$$NAME` captures zero-or-more (lazy — stops at next matchable element); `$$$` matches zero-or-more without binding. Use `$$$NAME`, **NOT** `$$NAME` — the two-dollar form is invalid and produces a parse error
@@ -13,7 +13,7 @@ Performs structural code search using AST matching via native ast-grep.
- C++ qualified calls used as expression statements need the statement semicolon in the pattern: use `ns::doThing($ARG);`, `$CALLEE($ARG);`, or wrap a statement snippet. Without `;`, tree-sitter-cpp may parse `ns::doThing($ARG)` as declaration-like syntax and return no matches
- For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }`
- Declaration forms are structurally distinct — top-level `function foo`, class method `foo()`, and `const foo = () => {}` are different AST shapes; search the right form before concluding absence
- Loosest existence check: `pat: "executeBash"` with a narrow `path`
- Loosest existence check: `pat: "executeBash"` with narrow `paths`
</instruction>
<output>
@@ -24,19 +24,19 @@ Performs structural code search using AST matching via native ast-grep.
<examples>
# Search TypeScript files under src
`{"pat":"console.log($$$)","path":"src/**/*.ts"}`
`{"pat":"console.log($$$)","paths":["src/**/*.ts"]}`
# Named imports from a specific package
`{"pat":"import { $$$IMPORTS } from \"react\"","path":"src/**/*.ts"}`
`{"pat":"import { $$$IMPORTS } from \"react\"","paths":["src/**/*.ts"]}`
# Arrow functions assigned to a const
`{"pat":"const $NAME = ($$$ARGS) => $BODY","path":"src/utils/**/*.ts"}`
`{"pat":"const $NAME = ($$$ARGS) => $BODY","paths":["src/utils/**/*.ts"]}`
# Method call on any object, ignoring method name with `$_`
`{"pat":"logger.$_($$$ARGS)","path":"src/**/*.ts"}`
`{"pat":"logger.$_($$$ARGS)","paths":["src/**/*.ts"]}`
# Loosest existence check for a symbol in one file
`{"pat":"processItems","path":"src/worker.ts"}`
`{"pat":"processItems","paths":["src/worker.ts"]}`
</examples>
<critical>
- Avoid repo-root scans — narrow `path` first
- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `path` before concluding "no matches"
- Avoid repo-root scans — narrow `paths` first
- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `paths` before concluding "no matches"
- For broad/open-ended exploration across subsystems, use Task tool with explore subagent first
</critical>
@@ -1,6 +1,7 @@
Finds files using fast pattern matching that works with any codebase size.
<instruction>
- `paths` is required and accepts an array of globs, files, or directories
- You **SHOULD** perform multiple searches in parallel when potentially useful
</instruction>
@@ -10,7 +11,7 @@ Matching file paths sorted by modification time (most recent first). Truncated a
<examples>
# Find files
`{"pattern": "src/**/*.ts", "limit": 1000}`
`{"paths": ["src/**/*.ts"], "limit": 1000}`
</examples>
<avoid>
@@ -2,13 +2,13 @@ Searches files using powerful regex matching.
<instruction>
- Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go)
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
- `paths` is required and accepts an array of files, directories, globs, or internal URLs
- Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern`
</instruction>
<output>
{{#if IS_HASHLINE_MODE}}
- Text output is anchor-prefixed: `*123th|content` (match) or ` 123th|content` (context, leading space). The 2-letter ID is a content fingerprint.
- Text output is anchor-prefixed: `*5th|content` (match) or ` 9x}|content` (context, leading space). The 2-char suffix is a content fingerprint.
{{else}}
{{#if IS_LINE_NUMBER_MODE}}
- Text output is line-number-prefixed
+43 -38
View File
@@ -20,7 +20,7 @@ import {
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
resolveMultiSearchPath,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
@@ -47,9 +47,10 @@ const astEditSchema = Type.Object({
minItems: 1,
description: "rewrite ops",
}),
path: Type.String({
description: "file, directory, glob, or comma-separated paths to rewrite",
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to rewrite" }), {
minItems: 1,
description: "files, directories, globs, or internal URLs to rewrite",
examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
}),
});
@@ -201,42 +202,46 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string | undefined;
let scopePath: string | undefined;
let searchPath: string;
let scopePath: string;
let globFilter: string | undefined;
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
const rawPath = normalizePathLikeInput(params.path);
if (rawPath.length === 0) {
throw new ToolError("`path` must be a non-empty path or glob");
const rawPaths = params.paths.map(normalizePathLikeInput);
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
if (rawPath) {
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(rawPath)) {
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
}
searchPath = resource.sourcePath;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
if (multiSearchPath) {
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
multiTargets = multiSearchPath.targets;
scopePath = multiSearchPath.scopePath;
} else {
const parsedPath = parseSearchPath(rawPath);
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
}
const internalRouter = this.session.internalRouter;
const resolvedPathInputs: string[] = [];
for (const rawPath of rawPaths) {
if (!internalRouter?.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
}
resolvedPathInputs.push(resource.sourcePath);
}
const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
if (resolvedPathInputs.length === 1) {
const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
if (!multiSearchPath) {
throw new ToolError("`paths` must contain at least one path or glob");
}
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
multiTargets = multiSearchPath.targets;
scopePath = multiSearchPath.scopePath;
}
const resolvedSearchPath = searchPath;
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
let isDirectory: boolean;
try {
@@ -350,7 +355,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
count: fileReplacementCounts.get(filePath) ?? 0,
}));
if (result.limitReached) {
outputLines.push("", "Limit reached; narrow path.");
outputLines.push("", "Limit reached; narrow paths.");
}
if (dedupedParseErrors.length) {
outputLines.push("", ...formatParseErrors(dedupedParseErrors));
@@ -441,7 +446,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
interface AstEditRenderArgs {
ops?: Array<{ pat?: string; out?: string }>;
path?: string;
paths?: string[];
}
const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
@@ -450,7 +455,7 @@ export const astEditToolRenderer = {
inline: true,
renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const meta: string[] = [];
if (args.path) meta.push(`in ${args.path}`);
if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
const rewriteCount = args.ops?.length ?? 0;
if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
+36 -29
View File
@@ -20,7 +20,7 @@ import {
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
resolveMultiSearchPath,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
@@ -38,9 +38,10 @@ import { toolResult } from "./tool-result";
const astGrepSchema = Type.Object({
pat: Type.String({ description: "ast pattern", examples: ["console.log($$$)"] }),
path: Type.String({
description: "file, directory, glob, or comma-separated paths to search",
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to search" }), {
minItems: 1,
description: "files, directories, globs, or internal URLs to search",
examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
}),
skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
});
@@ -146,16 +147,21 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
throw new ToolError("skip must be a non-negative number");
}
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string | undefined;
let scopePath: string | undefined;
let searchPath: string;
let scopePath: string;
let globFilter: string | undefined;
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
const rawPath = normalizePathLikeInput(params.path);
if (rawPath.length === 0) {
throw new ToolError("`path` must be a non-empty path or glob");
const rawPaths = params.paths.map(normalizePathLikeInput);
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(rawPath)) {
const resolvedPathInputs: string[] = [];
for (const rawPath of rawPaths) {
if (!internalRouter?.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
@@ -163,24 +169,25 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
if (!resource.sourcePath) {
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
}
searchPath = resource.sourcePath;
resolvedPathInputs.push(resource.sourcePath);
}
if (resolvedPathInputs.length === 1) {
const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
if (multiSearchPath) {
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
multiTargets = multiSearchPath.targets;
scopePath = multiSearchPath.scopePath;
} else {
const parsedPath = parseSearchPath(rawPath);
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
if (!multiSearchPath) {
throw new ToolError("`paths` must contain at least one path or glob");
}
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
multiTargets = multiSearchPath.targets;
scopePath = multiSearchPath.scopePath;
}
const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
const resolvedSearchPath = searchPath;
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
let isDirectory: boolean;
try {
@@ -241,7 +248,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
if (result.matches.length === 0) {
const noMatchMessage = dedupedParseErrors.length
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path` before concluding absence."
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `paths` before concluding absence."
: "No matches found";
const parseMessage = dedupedParseErrors.length
? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
@@ -307,7 +314,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
displayContent: displayLines.join("\n"),
};
if (result.limitReached) {
outputLines.push("", "Result limit reached; narrow path pattern or increase limit.");
outputLines.push("", "Result limit reached; narrow paths or increase limit.");
}
if (dedupedParseErrors.length) {
outputLines.push("", ...formatParseErrors(dedupedParseErrors));
@@ -324,7 +331,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
interface AstGrepRenderArgs {
pat?: string;
path?: string;
paths?: string[];
skip?: number;
}
@@ -334,7 +341,7 @@ export const astGrepToolRenderer = {
inline: true,
renderCall(args: AstGrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const meta: string[] = [];
if (args.path) meta.push(`in ${args.path}`);
if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
const description = args.pat ?? "?";
@@ -368,7 +375,7 @@ export const astGrepToolRenderer = {
const header = renderStatusLine({ icon: "warning", title: "AST Grep", description, meta }, uiTheme);
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
if (details?.parseErrors?.length) {
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence"));
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
for (const err of capped) {
lines.push(uiTheme.fg("warning", ` - ${err}`));
@@ -420,7 +427,7 @@ export const astGrepToolRenderer = {
const extraLines: string[] = [];
if (limitReached) {
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path pattern or increase limit"));
extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
}
if (details?.parseErrors?.length) {
const total = details.parseErrors.length;
+16 -15
View File
@@ -27,7 +27,7 @@ import {
formatPathRelativeToCwd,
normalizePathLikeInput,
parseFindPattern,
resolveMultiFindPattern,
resolveExplicitFindPatterns,
resolveToCwd,
} from "./path-utils";
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
@@ -35,9 +35,10 @@ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result";
const findSchema = Type.Object({
pattern: Type.String({
description: "glob including search path",
examples: ["src/**/*.ts", "lib/*.json", "apps/,packages/", "*.ts"],
paths: Type.Array(Type.String({ description: "glob including search path" }), {
minItems: 1,
description: "globs including search paths",
examples: [["src/**/*.ts"], ["lib/*.json"], ["apps/", "packages/"], ["*.ts"]],
}),
hidden: Type.Optional(Type.Boolean({ description: "include hidden files", default: true })),
limit: Type.Optional(Type.Number({ description: "max results", default: 1000 })),
@@ -104,17 +105,17 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<FindToolDetails>> {
const { pattern, limit, hidden } = params;
const { paths, limit, hidden } = params;
return untilAborted(signal, async () => {
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
const normalizedPattern = normalizePathLikeInput(pattern).replace(/\\/g, "/");
if (!normalizedPattern) {
throw new ToolError("Pattern must not be empty");
const normalizedPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
if (normalizedPatterns.some(pattern => pattern.length === 0)) {
throw new ToolError("`paths` must contain non-empty globs or paths");
}
const multiPattern = await resolveMultiFindPattern(normalizedPattern, this.session.cwd);
const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPattern);
const multiPattern = await resolveExplicitFindPatterns(normalizedPatterns, this.session.cwd);
const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPatterns[0] ?? ".");
const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false);
const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*";
const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd);
@@ -292,7 +293,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
// =============================================================================
interface FindRenderArgs {
pattern: string;
paths?: string[];
limit?: number;
}
@@ -305,7 +306,7 @@ export const findToolRenderer = {
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
const text = renderStatusLine(
{ icon: "pending", title: "Find", description: args.pattern || "*", meta },
{ icon: "pending", title: "Find", description: args.paths?.join(", ") || "*", meta },
uiTheme,
);
return new Text(text, 0, 0);
@@ -342,7 +343,7 @@ export const findToolRenderer = {
{
icon: "success",
title: "Find",
description: args?.pattern,
description: args?.paths?.join(", "),
meta: [formatCount("file", lines.length)],
},
uiTheme,
@@ -381,7 +382,7 @@ export const findToolRenderer = {
if (fileCount === 0) {
const header = renderStatusLine(
{ icon: "warning", title: "Find", description: args?.pattern, meta: ["0 files"] },
{ icon: "warning", title: "Find", description: args?.paths?.join(", "), meta: ["0 files"] },
uiTheme,
);
return new Text([header, formatEmptyMessage("No files found", uiTheme)].join("\n"), 0, 0);
@@ -390,7 +391,7 @@ export const findToolRenderer = {
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
const header = renderStatusLine(
{ icon: truncated ? "warning" : "success", title: "Find", description: args?.pattern, meta },
{ icon: truncated ? "warning" : "success", title: "Find", description: args?.paths?.join(", "), meta },
uiTheme,
);
+21 -196
View File
@@ -47,15 +47,6 @@ function fileExists(filePath: string): boolean {
}
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
function normalizeAtPrefix(filePath: string): string {
if (!filePath.startsWith("@")) return filePath;
@@ -300,77 +291,6 @@ export function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): st
return `${normalizedPrefix}/${normalizedSuffix}`;
}
type TopLevelSeparator = "comma" | "whitespace";
function splitTopLevel(value: string, separator: TopLevelSeparator): string[] {
const parts: string[] = [];
let current = "";
let braceDepth = 0;
let bracketDepth = 0;
let parenDepth = 0;
let quote: '"' | "'" | undefined;
let escaped = false;
const pushCurrent = () => {
const normalized = current.trim();
if (normalized.length > 0) {
parts.push(normalized);
}
current = "";
};
for (const char of value) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) {
quote = undefined;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === "{") braceDepth += 1;
else if (char === "}" && braceDepth > 0) braceDepth -= 1;
else if (char === "[") bracketDepth += 1;
else if (char === "]" && bracketDepth > 0) bracketDepth -= 1;
else if (char === "(") parenDepth += 1;
else if (char === ")" && parenDepth > 0) parenDepth -= 1;
const topLevel = braceDepth === 0 && bracketDepth === 0 && parenDepth === 0;
const isWhitespace = /\s/.test(char);
if (topLevel && separator === "comma" && char === ",") {
pushCurrent();
continue;
}
if (topLevel && separator === "whitespace" && isWhitespace) {
pushCurrent();
continue;
}
current += char;
}
pushCurrent();
return parts.length > 1 ? parts : [value.trim()];
}
function normalizePosixPath(filePath: string): string {
return filePath.replace(/\\/g, "/");
}
@@ -418,121 +338,12 @@ function toScopeDisplay(items: string[], cwd: string): string {
.join(", ");
}
function looksLikeDelimitedPathToken(token: string): boolean {
return (
TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix)) ||
token.startsWith(".") ||
token.startsWith("/") ||
token.startsWith("~") ||
token.startsWith("@") ||
token.includes("/") ||
token.includes("\\") ||
hasGlobPathChars(token) ||
/\.[^./\\]+$/.test(token)
);
}
async function areDelimitedTokensResolvable(
tokens: string[],
cwd: string,
parseBasePath: (value: string) => string,
allowBareExistingTokens: boolean,
): Promise<boolean> {
for (const token of tokens) {
if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) {
return false;
}
if (!allowBareExistingTokens && !looksLikeDelimitedPathToken(token)) {
// Bare names like "packages" don't look like path tokens syntactically,
// but may still be valid directory names. Check existence before rejecting.
const resolvedExactPath = resolveToCwd(token, cwd);
if (!(await pathExists(resolvedExactPath))) {
return false;
}
continue;
}
const basePath = parseBasePath(token);
const resolvedBasePath = resolveToCwd(basePath, cwd);
if (await pathExists(resolvedBasePath)) {
continue;
}
if (!allowBareExistingTokens) {
return false;
}
const resolvedExactPath = resolveToCwd(token, cwd);
if (!(await pathExists(resolvedExactPath))) {
return false;
}
}
return true;
}
async function filterResolvableTokens(
tokens: string[],
cwd: string,
parseBasePath: (value: string) => string,
): Promise<string[]> {
const out: string[] = [];
for (const token of tokens) {
if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) continue;
const basePath = parseBasePath(token);
const resolvedBasePath = resolveToCwd(basePath, cwd);
if (await pathExists(resolvedBasePath)) {
out.push(token);
continue;
}
const resolvedExactPath = resolveToCwd(token, cwd);
if (await pathExists(resolvedExactPath)) {
out.push(token);
}
}
return out;
}
async function splitDelimitedSearchInput(
rawInput: string,
cwd: string,
parseBasePath: (value: string) => string,
): Promise<string[] | undefined> {
const trimmed = rawInput.trim();
if (!trimmed) return undefined;
const resolvedExactPath = resolveToCwd(trimmed, cwd);
if (await pathExists(resolvedExactPath)) {
return undefined;
}
const commaSeparated = splitTopLevel(trimmed, "comma");
if (commaSeparated.length > 1) {
const resolvable = await filterResolvableTokens(commaSeparated, cwd, parseBasePath);
if (resolvable.length >= 1) {
return [...new Set(resolvable)];
}
}
const whitespaceSeparated = splitTopLevel(trimmed, "whitespace");
if (
whitespaceSeparated.length > 1 &&
(await areDelimitedTokensResolvable(whitespaceSeparated, cwd, parseBasePath, false))
) {
return [...new Set(whitespaceSeparated)];
}
return undefined;
}
export async function resolveMultiSearchPath(
rawPath: string,
async function resolveSearchPathItems(
pathItems: string[],
cwd: string,
suffixGlob?: string,
): Promise<ResolvedMultiSearchPath | undefined> {
const pathItems = await splitDelimitedSearchInput(rawPath, cwd, value => parseSearchPath(value).basePath);
if (!pathItems || pathItems.length < 1) {
if (pathItems.length < 1) {
return undefined;
}
@@ -580,12 +391,19 @@ export async function resolveMultiSearchPath(
};
}
export async function resolveMultiFindPattern(
rawPattern: string,
export async function resolveExplicitSearchPaths(
pathItems: string[],
cwd: string,
suffixGlob?: string,
): Promise<ResolvedMultiSearchPath | undefined> {
return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob);
}
async function resolveFindPatternItems(
patternItems: string[],
cwd: string,
): Promise<ResolvedMultiFindPattern | undefined> {
const patternItems = await splitDelimitedSearchInput(rawPattern, cwd, value => parseFindPattern(value).basePath);
if (!patternItems || patternItems.length <= 1) {
if (patternItems.length <= 1) {
return undefined;
}
@@ -617,6 +435,13 @@ export async function resolveMultiFindPattern(
};
}
export async function resolveExplicitFindPatterns(
patternItems: string[],
cwd: string,
): Promise<ResolvedMultiFindPattern | undefined> {
return resolveFindPatternItems([...new Set(patternItems)], cwd);
}
export function resolveReadPath(filePath: string, cwd: string): string {
const resolved = resolveToCwd(filePath, cwd);
const shellEscapedVariant = tryShellEscapedPath(resolved);
+34 -27
View File
@@ -22,7 +22,7 @@ import {
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
resolveMultiSearchPath,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
@@ -37,9 +37,10 @@ import { toolResult } from "./tool-result";
const searchSchema = Type.Object({
pattern: Type.String({ description: "regex pattern", examples: ["function\\s+\\w+", "TODO"] }),
path: Type.String({
description: "file, directory, glob, comma-separated paths, or internal URL to search",
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to search" }), {
minItems: 1,
description: "files, directories, globs, or internal URLs to search",
examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
}),
i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })),
gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })),
@@ -48,7 +49,7 @@ const searchSchema = Type.Object({
export type SearchToolInput = Static<typeof searchSchema>;
const DEFAULT_MATCH_LIMIT = 20;
const DEFAULT_MATCH_LIMIT = 500;
export interface SearchToolDetails {
truncation?: TruncationResult;
@@ -93,7 +94,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
_onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
_toolContext?: AgentToolContext,
): Promise<AgentToolResult<SearchToolDetails>> {
const { pattern, path: searchDir, i, gitignore, skip } = params;
const { pattern, paths, i, gitignore, skip } = params;
return untilAborted(signal, async () => {
const normalizedPattern = pattern.trim();
@@ -119,12 +120,17 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
let exactFilePaths: string[] | undefined;
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
let globFilter: string | undefined;
const rawPath = normalizePathLikeInput(searchDir);
if (rawPath.length === 0) {
throw new ToolError("`path` must be a non-empty path or glob");
const rawPaths = paths.map(normalizePathLikeInput);
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(rawPath)) {
const resolvedPathInputs: string[] = [];
for (const rawPath of rawPaths) {
if (!internalRouter?.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
@@ -132,29 +138,30 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
if (!resource.sourcePath) {
throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
}
searchPath = resource.sourcePath;
resolvedPathInputs.push(resource.sourcePath);
}
if (resolvedPathInputs.length === 1) {
const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
if (multiSearchPath) {
searchPath = multiSearchPath.basePath;
exactFilePaths = multiSearchPath.exactFilePaths;
multiTargets = multiSearchPath.targets;
globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
scopePath = multiSearchPath.scopePath;
} else {
const parsedPath = parseSearchPath(rawPath);
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
if (!multiSearchPath) {
throw new ToolError("`paths` must contain at least one path or glob");
}
searchPath = multiSearchPath.basePath;
exactFilePaths = multiSearchPath.exactFilePaths;
multiTargets = multiSearchPath.targets;
globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
scopePath = multiSearchPath.scopePath;
}
let isDirectory: boolean;
try {
const stat = await Bun.file(searchPath).stat();
isDirectory = stat.isDirectory();
} catch {
const hint = scopePath.includes(",") ? ` (comma-separated paths must each exist relative to cwd)` : "";
const hint = rawPaths.length > 1 ? " (`paths` entries must each exist relative to cwd)" : "";
throw new ToolError(`Path not found: ${scopePath}${hint}`);
}
@@ -275,7 +282,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
: result.matches.slice(0, effectiveLimit);
const matchLimitReached = result.matches.length > effectiveLimit;
const nextSkip = normalizedSkip + selectedMatches.length;
const limitMessage = `Result limit reached; narrow path or use skip=${nextSkip}.`;
const limitMessage = `Result limit reached; narrow paths or use skip=${nextSkip}.`;
const { record: recordFile, list: fileList } = createFileRecorder();
const fileMatchCounts = new Map<string, number>();
if (selectedMatches.length === 0) {
@@ -395,7 +402,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
interface SearchRenderArgs {
pattern: string;
path?: string;
paths?: string[];
i?: boolean;
gitignore?: boolean;
skip?: number;
@@ -407,7 +414,7 @@ export const searchToolRenderer = {
inline: true,
renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const meta: string[] = [];
if (args.path) meta.push(`in ${args.path}`);
if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
if (args.i) meta.push("case:insensitive");
if (args.gitignore === false) meta.push("gitignore:false");
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
+25 -25
View File
@@ -1175,7 +1175,7 @@ function b() {
const result = await searchTool.execute("test-call-11", {
pattern: "match",
path: testFile,
paths: [testFile],
});
const output = getTextOutput(result);
@@ -1184,14 +1184,14 @@ function b() {
expect(output).toMatch(/\*2\|match line/);
});
it("should accept wildcard patterns in the path parameter", async () => {
it("should accept wildcard patterns in paths", async () => {
fs.writeFileSync(path.join(testDir, "schema-review-alpha.test.ts"), "review target\n");
fs.writeFileSync(path.join(testDir, "schema-review-beta.test.ts"), "review target\n");
fs.writeFileSync(path.join(testDir, "schema-other.test.ts"), "review target\n");
const result = await searchTool.execute("test-call-11-path-glob", {
pattern: "review target",
path: `${testDir}/schema-review-*.test.ts`,
paths: [`${testDir}/schema-review-*.test.ts`],
});
const output = getTextOutput(result);
@@ -1200,7 +1200,7 @@ function b() {
expect(output).not.toContain("schema-other.test.ts");
expect(result.details?.fileCount).toBe(2);
});
it("should accept nested wildcard filters in the path parameter", async () => {
it("should accept nested wildcard filters in paths", async () => {
const packageDir = path.join(testDir, "node_modules", ".bun");
const aiDir = path.join(packageDir, "ai@6.0.119+build123", "node_modules", "ai");
const nestedDir = path.join(aiDir, "nested");
@@ -1212,7 +1212,7 @@ function b() {
const result = await searchTool.execute("test-call-11-path-and-glob", {
pattern: "providerOptions",
path: `${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`,
paths: [`${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`],
gitignore: false,
});
@@ -1235,7 +1235,7 @@ function b() {
);
const result = await contextSearchTool.execute("test-call-12", {
pattern: "match",
path: testFile,
paths: [testFile],
});
const output = getTextOutput(result);
@@ -1252,7 +1252,7 @@ function b() {
const result = await searchTool.execute("test-call-12-skip", {
pattern: "needle",
path: testFile,
paths: [testFile],
skip: 1,
});
@@ -1270,7 +1270,7 @@ function b() {
const result = await searchTool.execute("test-call-13-round-robin", {
pattern: "needle",
path: testDir,
paths: [testDir],
});
const output = getTextOutput(result);
@@ -1290,7 +1290,7 @@ function b() {
const result = await searchTool.execute("test-call-14-grouped-headings", {
pattern: "needle",
path: testDir,
paths: [testDir],
});
const output = getTextOutput(result);
@@ -1314,7 +1314,7 @@ function b() {
const result = await searchTool.execute("test-call-15-directory-headings", {
pattern: "Claude Opus",
path: testDir,
paths: [testDir],
});
const output = getTextOutput(result);
@@ -1333,7 +1333,7 @@ function b() {
const result = await searchTool.execute("test-call-15-gitignore-default", {
pattern: "needle",
path: scenarioDir,
paths: [scenarioDir],
});
const output = getTextOutput(result);
@@ -1351,7 +1351,7 @@ function b() {
const result = await searchTool.execute("test-call-16-gitignore-off", {
pattern: "needle",
path: scenarioDir,
paths: [scenarioDir],
gitignore: false,
});
@@ -1373,7 +1373,7 @@ function b() {
const result = await searchTool.execute("test-call-16-fifo-dir", {
pattern: "needle",
path: scenarioDir,
paths: [scenarioDir],
gitignore: false,
});
@@ -1386,18 +1386,18 @@ function b() {
expect(result.details?.matchCount).toBe(1);
});
it("should apply the fixed default match cap", async () => {
const lines = Array.from({ length: 60 }, (_, i) => `needle ${i + 1}`);
const lines = Array.from({ length: 600 }, (_, i) => `needle ${i + 1}`);
fs.writeFileSync(path.join(testDir, "default-limit.txt"), lines.join("\n"));
const result = await searchTool.execute("test-call-14-default-limit", {
pattern: "needle",
path: testDir,
paths: [testDir],
});
const output = getTextOutput(result);
expect(output).toContain("Result limit reached; narrow path or use skip=20.");
expect(result.details?.matchCount).toBe(20);
expect(result.details?.matchLimitReached).toBe(20);
expect(output).toContain("Result limit reached; narrow paths or use skip=500.");
expect(result.details?.matchCount).toBe(500);
expect(result.details?.matchLimitReached).toBe(500);
});
});
@@ -1407,7 +1407,7 @@ function b() {
fs.writeFileSync(testFile, "single");
const result = await findTool.execute("test-call-13a", {
pattern: testFile,
paths: [testFile],
});
const outputLines = getTextOutput(result)
@@ -1425,7 +1425,7 @@ function b() {
fs.writeFileSync(path.join(testDir, "visible.txt"), "visible");
const result = await findTool.execute("test-call-13", {
pattern: `${testDir}/**/*.txt`,
paths: [`${testDir}/**/*.txt`],
hidden: true,
});
@@ -1445,7 +1445,7 @@ function b() {
fs.writeFileSync(path.join(testDir, "kept.txt"), "kept");
const result = await findTool.execute("test-call-14", {
pattern: `${testDir}/**/*.txt`,
paths: [`${testDir}/**/*.txt`],
});
const output = getTextOutput(result);
@@ -1470,7 +1470,7 @@ function b() {
fs.utimesSync(newerFile, newerTime, newerTime);
const result = await findTool.execute("test-call-14b", {
pattern: `${testDir}/**/auth-actions.spec.ts`,
paths: [`${testDir}/**/auth-actions.spec.ts`],
});
const outputLines = getTextOutput(result)
@@ -1487,7 +1487,7 @@ function b() {
fs.writeFileSync(path.join(nestedDir, "daemon-telemetry.ts"), "telemetry\n");
const result = await findTool.execute("test-call-14c", {
pattern: "apps/daemon/src/**/daemon-telemetry.ts",
paths: ["apps/daemon/src/**/daemon-telemetry.ts"],
});
const outputLines = getTextOutput(result)
@@ -1507,7 +1507,7 @@ function b() {
fs.writeFileSync(path.join(clientDir, "client.ts"), "client\n");
const result = await findTool.execute("test-call-14e", {
pattern: "apps/daemon/src/**/*.ts,apps/client/src/**/*.ts",
paths: ["apps/daemon/src/**/*.ts", "apps/client/src/**/*.ts"],
});
const outputLines = getTextOutput(result)
@@ -1528,7 +1528,7 @@ function b() {
const startedAt = performance.now();
const result = await findTool.execute("test-call-14d", {
pattern: "**/.env*",
paths: ["**/.env*"],
});
const elapsedMs = performance.now() - startedAt;
@@ -4,6 +4,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { adaptSchemaForStrict } from "@oh-my-pi/pi-ai/utils/schema";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { HASHLINE_HASH_RE_SRC } from "@oh-my-pi/pi-coding-agent/edit";
import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
@@ -72,7 +73,7 @@ describe("ast_edit tool schema", () => {
const result = await tool!.execute("ast-edit-test", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
path: filePath,
paths: [filePath],
});
const text = result.content.find(content => content.type === "text")?.text ?? "";
const lines = text.split("\n");
@@ -81,8 +82,8 @@ describe("ast_edit tool schema", () => {
expect(removedLine).toBeDefined();
expect(addedLine).toBeDefined();
expect(removedLine).toMatch(/^-\d+[a-z]{2}\|/);
expect(addedLine).toMatch(/^\+\d+[a-z]{2}\|/);
expect(removedLine).toMatch(new RegExp(`^-\\d+${HASHLINE_HASH_RE_SRC}\\|`));
expect(addedLine).toMatch(new RegExp(`^\\+\\d+${HASHLINE_HASH_RE_SRC}\\|`));
expect(removedLine?.split("|", 1)[0].length).toBe(addedLine?.split("|", 1)[0].length);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
@@ -108,7 +109,7 @@ describe("ast_edit tool schema", () => {
const previewResult = await tool!.execute("ast-edit-preview", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
path: filePath,
paths: [filePath],
});
expect(previewResult.details).toBeDefined();
expect((previewResult.details as { applied?: boolean }).applied).toBe(false);
@@ -153,7 +154,7 @@ describe("ast_edit tool schema", () => {
const previewResult = await tool!.execute("ast-edit-preview", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
path: filePath,
paths: [filePath],
});
expect((previewResult.details as { totalReplacements?: number } | undefined)?.totalReplacements).toBe(1);
@@ -203,7 +204,7 @@ describe("ast_edit tool schema", () => {
const previewResult = await tool!.execute("ast-edit-glob", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
path: `${packagesDir}/pkg-*/src/**/*.ts`,
paths: [`${packagesDir}/pkg-*/src/**/*.ts`],
});
const text = previewResult.content.find(content => content.type === "text")?.text ?? "";
@@ -259,7 +260,7 @@ describe("ast_edit tool schema", () => {
const previewResult = await tool!.execute("ast-edit-tlaplus", {
ops: [{ pat: "Init", out: "Start" }],
path: filePath,
paths: [filePath],
});
const text = previewResult.content.find(content => content.type === "text")?.text ?? "";
@@ -29,7 +29,7 @@ describe("ast_grep parse errors", () => {
const result = await tool!.execute("ast-grep-parse", {
pat: "someUnlikelyCall($A)",
path: filePath,
paths: [filePath],
});
const text = result.content.find(content => content.type === "text")?.text ?? "";
@@ -64,7 +64,7 @@ describe("ast_grep parse errors", () => {
const result = await tool!.execute("ast-grep-glob", {
pat: "providerOptions",
path: `${packagesDir}/pkg-*/src/**/*.ts`,
paths: [`${packagesDir}/pkg-*/src/**/*.ts`],
});
const text = result.content.find(content => content.type === "text")?.text ?? "";
@@ -96,7 +96,7 @@ describe("ast_grep parse errors", () => {
const result = await tool!.execute("ast-grep-tlaplus", {
pat: "Inc",
path: filePath,
paths: [filePath],
});
const text = result.content.find(content => content.type === "text")?.text ?? "";
@@ -4,7 +4,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { resolveMultiSearchPath } from "@oh-my-pi/pi-coding-agent/tools/path-utils";
import { resolveExplicitSearchPaths } from "@oh-my-pi/pi-coding-agent/tools/path-utils";
const isWindows = process.platform === "win32";
@@ -26,17 +26,17 @@ function getText(result: { content: Array<{ type: string; text?: string }> }): s
.join("\n");
}
describe.skipIf(isWindows)("resolveMultiSearchPath cross-tree degeneracy", () => {
describe.skipIf(isWindows)("resolveExplicitSearchPaths cross-tree degeneracy", () => {
it("returns per-path targets when commonBasePath collapses to filesystem root", async () => {
// Two real top-level directories that exist on every Unix host. Their only
// shared ancestor is `/`. A naive shared-base scan would walk the entire
// filesystem; the resolver must surface explicit `targets` so callers can
// fan out instead.
const cwd = os.tmpdir();
const resolved = await resolveMultiSearchPath("/tmp,/usr", cwd);
const resolved = await resolveExplicitSearchPaths(["/tmp", "/usr"], cwd);
expect(resolved).toBeDefined();
if (!resolved) throw new Error("expected resolveMultiSearchPath to resolve");
if (!resolved) throw new Error("expected resolveExplicitSearchPaths to resolve");
expect(resolved.basePath).toBe(path.parse(resolved.basePath).root);
expect(resolved.targets).toBeDefined();
const targetBases = (resolved.targets ?? []).map(target => target.basePath).sort();
@@ -76,7 +76,7 @@ describe.skipIf(isWindows)("search across unrelated filesystem trees", () => {
const start = performance.now();
const result = await tool.execute("search-cross-tree", {
pattern: "shared-needle",
path: `${dirA},${dirB}`,
paths: [dirA, dirB],
});
const durationMs = performance.now() - start;
@@ -70,7 +70,7 @@ describe("tool path root alias", () => {
const result = await tool.execute("search-root-alias", {
pattern: "root-alias-needle",
path: "/",
paths: ["/"],
});
const details = result.details as { scopePath?: string } | undefined;
@@ -99,7 +99,7 @@ describe("tool path root alias", () => {
if (!tool) throw new Error("Missing find tool");
const result = await tool.execute("find-root-alias", {
pattern: "/",
paths: ["/"],
});
const details = result.details as { scopePath?: string } | undefined;
const text = getText(result);
@@ -117,7 +117,7 @@ describe("tool path root alias", () => {
const result = await tool.execute("ast-grep-root-alias", {
pat: "rootAliasSymbol",
path: "/**/*.ts",
paths: ["/**/*.ts"],
});
const details = result.details as { scopePath?: string } | undefined;
@@ -140,7 +140,7 @@ describe("tool path root alias", () => {
const preview = await tool.execute("ast-edit-root-alias", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
path: "/**/*.ts",
paths: ["/**/*.ts"],
});
const details = preview.details as { scopePath?: string; totalReplacements?: number } | undefined;
@@ -56,7 +56,7 @@ describe("SearchTool internal URL resolution", () => {
const result = await tool.execute("test-call", {
pattern: "needle",
path: "artifact://5",
paths: ["artifact://5"],
});
const text = getResultText(result);
@@ -73,7 +73,7 @@ describe("SearchTool internal URL resolution", () => {
const result = await tool.execute("test-call", {
pattern: "ERROR.*",
path: "artifact://3",
paths: ["artifact://3"],
});
const text = getResultText(result);
@@ -99,7 +99,7 @@ describe("SearchTool internal URL resolution", () => {
const session = createSession({ internalRouter: router });
const tool = new SearchTool(session);
expect(tool.execute("test-call", { pattern: "foo", path: "agent://0" })).rejects.toThrow(
expect(tool.execute("test-call", { pattern: "foo", paths: ["agent://0"] })).rejects.toThrow(
"Cannot search internal URL without a backing file",
);
});
@@ -112,7 +112,7 @@ describe("SearchTool internal URL resolution", () => {
const result = await tool.execute("test-call", {
pattern: "hello",
path: "test.txt",
paths: ["test.txt"],
});
const text = getResultText(result);
@@ -128,7 +128,7 @@ describe("SearchTool internal URL resolution", () => {
const result = await tool.execute("test-call", {
pattern: "data",
path: "data.log",
paths: ["data.log"],
});
const text = getResultText(result);
@@ -140,7 +140,7 @@ describe("SearchTool internal URL resolution", () => {
const session = createSession({ internalRouter: router });
const tool = new SearchTool(session);
expect(tool.execute("test-call", { pattern: "foo", path: "artifact://999" })).rejects.toThrow(
expect(tool.execute("test-call", { pattern: "foo", paths: ["artifact://999"] })).rejects.toThrow(
"Artifact 999 not found",
);
});
@@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { HASHLINE_HASH_RE_SRC } from "@oh-my-pi/pi-coding-agent/edit";
import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
@@ -56,7 +57,7 @@ async function createSearchFixture(rootDir: string): Promise<void> {
);
}
describe("search tool path lists", () => {
describe("tool path arrays", () => {
let tempDir: string;
beforeEach(async () => {
@@ -68,15 +69,15 @@ describe("search tool path lists", () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("search accepts space-separated path lists", async () => {
it("search accepts explicit path arrays", async () => {
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "search");
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing search tool");
const result = await tool.execute("search-space-paths", {
const result = await tool.execute("search-path-array", {
pattern: "shared-needle",
path: "apps/ packages/ phases/",
paths: ["apps/", "packages/", "phases/"],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -98,7 +99,7 @@ describe("search tool path lists", () => {
const result = await tool.execute("search-space-directory", {
pattern: "space-needle",
path: "folder with spaces/",
paths: ["folder with spaces/"],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -116,7 +117,7 @@ describe("search tool path lists", () => {
const result = await tool.execute("search-quoted-path", {
pattern: "shared-needle",
path: '"packages/"',
paths: ['"packages/"'],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -136,7 +137,7 @@ describe("search tool path lists", () => {
const absoluteAppsPath = path.join(tempDir, "apps");
const result = await tool.execute("search-absolute-in-cwd", {
pattern: "shared-needle",
path: absoluteAppsPath,
paths: [absoluteAppsPath],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -174,7 +175,7 @@ describe("search tool path lists", () => {
const result = await tool.execute("ast-grep-quoted-path", {
pat: "providerOptions",
path: '"packages/**/*.ts"',
paths: ['"packages/**/*.ts"'],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -185,15 +186,15 @@ describe("search tool path lists", () => {
expect(details?.scopePath).toBe("packages");
});
it("ast_grep accepts comma-separated path lists", async () => {
it("ast_grep accepts explicit path arrays", async () => {
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "ast_grep");
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing ast_grep tool");
const result = await tool.execute("ast-grep-comma-paths", {
const result = await tool.execute("ast-grep-path-array", {
pat: "providerOptions",
path: "apps/**/*.ts,packages/**/*.ts,phases/**/*.ts",
paths: ["apps/**/*.ts", "packages/**/*.ts", "phases/**/*.ts"],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -207,7 +208,7 @@ describe("search tool path lists", () => {
expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts");
});
it("ast_edit applies across a space-separated path list", async () => {
it("ast_edit applies across an explicit path array", async () => {
const queue = new ToolChoiceQueue();
const tools = await createTools(
createTestSession(tempDir, {
@@ -220,9 +221,9 @@ describe("search tool path lists", () => {
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing ast_edit tool");
const preview = await tool.execute("ast-edit-space-paths", {
const preview = await tool.execute("ast-edit-path-array", {
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
path: "apps/**/*.ts packages/**/*.ts phases/**/*.ts",
paths: ["apps/**/*.ts", "packages/**/*.ts", "phases/**/*.ts"],
});
const text = getText(preview);
const details = preview.details as { totalReplacements?: number; scopePath?: string } | undefined;
@@ -252,14 +253,14 @@ describe("search tool path lists", () => {
);
});
it("find accepts comma-separated path lists", async () => {
it("find accepts explicit path arrays", async () => {
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "find");
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing find tool");
const result = await tool.execute("find-comma-paths", {
pattern: "apps/,packages/,phases/",
const result = await tool.execute("find-path-array", {
paths: ["apps/", "packages/", "phases/"],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -280,7 +281,7 @@ describe("search tool path lists", () => {
if (!tool) throw new Error("Missing find tool");
const result = await tool.execute("find-quoted-pattern", {
pattern: '"packages/"',
paths: ['"packages/"'],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -302,7 +303,7 @@ describe("search tool path lists", () => {
if (!tool) throw new Error("Missing find tool");
const result = await tool.execute("find-outside-cwd", {
pattern: outsideDir,
paths: [outsideDir],
});
const text = getText(result);
const expectedPath = path.join(outsideDir, "outside.txt").replace(/\\/g, "/");
@@ -317,15 +318,15 @@ describe("search tool path lists", () => {
}
});
it("grep accepts bare space-separated directory names (no trailing slash)", async () => {
it("grep accepts bare directory name arrays", async () => {
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "search");
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing search tool");
const result = await tool.execute("grep-bare-space-paths", {
const result = await tool.execute("grep-bare-path-array", {
pattern: "shared-needle",
path: "apps packages phases",
paths: ["apps", "packages", "phases"],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -338,7 +339,7 @@ describe("search tool path lists", () => {
expect(details?.scopePath).toBe("apps, packages, phases");
});
it("grep keeps comma-separated explicit files exact", async () => {
it("grep keeps explicit files exact", async () => {
await fs.mkdir(path.join(tempDir, "nested"), { recursive: true });
await Bun.write(path.join(tempDir, "alpha.txt"), "exact-needle alpha\n");
await Bun.write(path.join(tempDir, "beta.txt"), "exact-needle beta\n");
@@ -350,9 +351,9 @@ describe("search tool path lists", () => {
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing search tool");
const result = await tool.execute("grep-exact-comma-files", {
const result = await tool.execute("grep-exact-file-array", {
pattern: "exact-needle",
path: "alpha.txt,beta.txt",
paths: ["alpha.txt", "beta.txt"],
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
@@ -374,7 +375,7 @@ describe("search tool path lists", () => {
const result = await tool.execute("grep-no-empty-headings", {
pattern: "shared-needle",
path: "apps/,packages/,phases/",
paths: ["apps/", "packages/", "phases/"],
});
const lines = getText(result).split("\n");
@@ -404,12 +405,12 @@ describe("search tool path lists", () => {
const result = await tool.execute("grep-context-label", {
pattern: "needle",
path: "context.txt",
paths: ["context.txt"],
});
const text = getText(result);
expect(text).toMatch(/ 1(?:[a-z]{2})?\|#if FLAG/);
expect(text).toMatch(/\*2(?:[a-z]{2})?\|needle/);
expect(text).toMatch(/ 3(?:[a-z]{2})?\|#endif/);
expect(text).toMatch(new RegExp(` 1(?:${HASHLINE_HASH_RE_SRC})?\\|#if FLAG`));
expect(text).toMatch(new RegExp(`\\*2(?:${HASHLINE_HASH_RE_SRC})?\\|needle`));
expect(text).toMatch(new RegExp(` 3(?:${HASHLINE_HASH_RE_SRC})?\\|#endif`));
});
});