From b22837f898bcb933bae3ee4e13e49e20bf559c57 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 26 Apr 2026 04:33:04 +0200 Subject: [PATCH] feat(coding-agent): added unified path targets for grep family - Consolidated grep, ast-grep, and ast-edit on required `path`, replacing `glob`/`lang`/`sel` with inline file, dir, glob, list, and URL targets. - Changed ast-grep and grep schemas to require a single `pat` string and use `skip` pagination instead of array patterns or offsets. - Updated argument validation to reject empty `path` and invalid `skip`, and routed grep context to session settings only. - Updated tool prompts and tests to reflect new path globbing semantics and `first N` truncation output text. --- packages/coding-agent/CHANGELOG.md | 7 + packages/coding-agent/src/cursor.ts | 11 +- packages/coding-agent/src/edit/modes/chunk.ts | 2 +- .../src/prompts/tools/ast-edit.md | 26 ++- .../src/prompts/tools/ast-grep.md | 38 ++--- .../coding-agent/src/prompts/tools/grep.md | 5 +- packages/coding-agent/src/tools/ast-edit.ts | 48 ++---- packages/coding-agent/src/tools/ast-grep.ts | 136 ++++++---------- packages/coding-agent/src/tools/grep.ts | 153 +++++++----------- packages/coding-agent/test/tools.test.ts | 52 +++--- .../coding-agent/test/tools/ast-edit.test.ts | 7 +- .../coding-agent/test/tools/ast-grep.test.ts | 17 +- .../test/tools/grep-renderer.test.ts | 2 +- .../test/tools/root-path-alias.test.ts | 11 +- .../test/tools/search-path-lists.test.ts | 31 ++-- 15 files changed, 210 insertions(+), 336 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2da8fa4a8..4c6fdaabc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,12 @@ # Changelog ## [Unreleased] + ### Breaking Changes +- Removed multi-pattern array input from `ast_grep` by changing `pat` to a single pattern string, so call sites using `pat: [...]` must be updated to send one query per invocation +- Removed `lang`, `glob`, and `sel` options from `ast_edit` and `ast_grep`, and moved those behaviors into the required `path` argument +- Required `path` for `ast_edit` and `ast_grep`, so invocations that relied on implicit repo-root searching are no longer valid - Renamed atom edit operations from `before` and `after` to `pre` and `post`, so existing `atom` payloads using the old operation keys must be updated - Changed the hashline anchor format from `LINE#ID:content` to `LINEID\tcontent` (no `#`/`:` separators, tab between anchor and content, no padding on line numbers); expanded the bigram alphabet from 40 hand-picked English bigrams to the full 647 single-token 2-letter bigrams — invalidates every previously captured `LINE#ID` reference - Renamed the subagent completion contract from `submit_result` to `yield`, so subagent sessions must now finish with the `yield` tool and the `requireYieldTool` option; `submit_result`/`requireSubmitResultTool` and old completion calls are no longer recognized @@ -19,6 +23,9 @@ ### Changed +- Updated `ast_edit` and `ast_grep` to pass file-selection intent through `path` (including inline globs and comma/space-separated path lists) instead of separate `glob` filters +- Changed `ast_grep` pagination API from `offset` to `skip` +- Changed `grep` truncation output to report `Result limit reached; narrow path.` and label match/result caps as `first N` - Changed JSON tree output to truncate inline argument pairs by available width and add an ellipsis when values no longer fit in the display - Changed JSON tree rendering to hide harness-internal `intent` and `__partialJson` fields from top-level tool output - Simplified the `grep` tool schema by requiring `path`, folding glob and type filtering into path globs, auto-detecting multiline patterns, removing model-controlled context and limit options, and renaming result skipping to `skip`. diff --git a/packages/coding-agent/src/cursor.ts b/packages/coding-agent/src/cursor.ts index 6e4fe597f..376c6fd32 100644 --- a/packages/coding-agent/src/cursor.ts +++ b/packages/coding-agent/src/cursor.ts @@ -177,16 +177,11 @@ export class CursorExecHandlers implements ICursorExecHandlers { async grep(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); + const grepPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || "."; const toolResultMessage = await executeTool(this.options, "grep", toolCallId, { pattern: args.pattern, - path: args.path || undefined, - glob: args.glob || undefined, - mode: args.outputMode || undefined, - context: args.context ?? args.contextBefore ?? args.contextAfter ?? undefined, - ignore_case: args.caseInsensitive || undefined, - type: args.type || undefined, - limit: args.headLimit ?? undefined, - multiline: args.multiline || undefined, + path: grepPath, + i: args.caseInsensitive || undefined, }); return toolResultMessage; } diff --git a/packages/coding-agent/src/edit/modes/chunk.ts b/packages/coding-agent/src/edit/modes/chunk.ts index 570a0dc0c..8ca1f599c 100644 --- a/packages/coding-agent/src/edit/modes/chunk.ts +++ b/packages/coding-agent/src/edit/modes/chunk.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import * as nodePath from "node:path"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { StringEnum } from "@oh-my-pi/pi-coding-agent"; +import { StringEnum } from "@oh-my-pi/pi-ai"; import { ChunkAnchorStyle, ChunkEditOp, diff --git a/packages/coding-agent/src/prompts/tools/ast-edit.md b/packages/coding-agent/src/prompts/tools/ast-edit.md index a257aa988..4ade56114 100644 --- a/packages/coding-agent/src/prompts/tools/ast-edit.md +++ b/packages/coding-agent/src/prompts/tools/ast-edit.md @@ -2,12 +2,12 @@ Performs structural AST-aware rewrites via native ast-grep. - Use for codemods and structural rewrites where plain text replace is unsafe -- `path` accepts a comma-separated list in addition to file/dir/glob -- Set `lang` explicitly in mixed-language trees for deterministic rewrites +- `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 - 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`) -- Rewrite patterns **MUST** parse as a single valid AST node. For method fragments or body snippets that don't parse standalone, wrap in context (e.g. `class $_ { … }`) and set `sel` to target the inner node — match and replacement target the selected node, not the wrapper. If ast-grep reports `Multiple AST nodes are detected`, wrap and use `sel` +- Rewrite patterns **MUST** parse as a single valid AST node. For method fragments or body snippets that don't parse standalone, wrap in context (e.g. `class $_ { … }`) - For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }` - Delete matched code with empty `out`: `{"pat":"console.log($$$)","out":""}` - Each rewrite is a 1:1 structural substitution — cannot split one capture across multiple nodes or merge multiple captures into one @@ -19,22 +19,18 @@ Performs structural AST-aware rewrites via native ast-grep. -# Rename a call site across a directory -`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"lang":"typescript","path":"src/"}` -# Delete matching calls (empty `out` removes the node) -`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"lang":"typescript","path":"src/"}` +# Rename a call site across TypeScript files +`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"path":"src/**/*.ts"}` +# Delete matching calls +`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"path":"src/**/*.ts"}` # Rewrite import source path -`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"lang":"typescript","path":"src/"}` +`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"path":"src/**/*.ts"}` # Modernize to optional chaining (same metavariable enforces identity) -`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"lang":"typescript","path":"src/"}` +`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"path":"src/**/*.ts"}` # Swap two arguments using captures -`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"lang":"typescript","path":"tests/"}` -# Rename a function declaration while tolerating any return type annotation -`{"ops":[{"pat":"async function fetchData($$$ARGS): $_ { $$$BODY }","out":"async function loadData($$$ARGS): $_ { $$$BODY }"}],"sel":"function_declaration","lang":"typescript","path":"src/api.ts"}` -# Rewrite a method body fragment by wrapping in parseable context (sel targets the inner node) -`{"ops":[{"pat":"class $_ { async execute($INPUT: $_) { $$$BEFORE; const $PARSED = $_.parse($INPUT); $$$AFTER } }","out":"class $_ { async execute($INPUT: $_) { $$$BEFORE; const $PARSED = $SCHEMA.parse($INPUT); $$$AFTER } }"}],"sel":"method_definition","lang":"typescript","path":"src/tools/todo.ts"}` +`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"path":"tests/**/*.ts"}` # Python — convert print calls to logging -`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"lang":"python","path":"src/"}` +`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"path":"src/**/*.py"}` diff --git a/packages/coding-agent/src/prompts/tools/ast-grep.md b/packages/coding-agent/src/prompts/tools/ast-grep.md index 1c2b5edc3..fd9c794a4 100644 --- a/packages/coding-agent/src/prompts/tools/ast-grep.md +++ b/packages/coding-agent/src/prompts/tools/ast-grep.md @@ -2,18 +2,18 @@ Performs structural code search using AST matching via native ast-grep. - Use when syntax shape matters more than raw text (calls, declarations, specific language constructs) -- `path` accepts a comma-separated list in addition to file/dir/glob -- Set `lang` explicitly in mixed-language trees to avoid parse noise from non-source files -- Multiple patterns in `pat` run in one native pass, merged, then `offset`/`limit` applied +- `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 +- `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 - Metavariable names are UPPERCASE and must be the whole AST node — partial-text like `prefix$VAR`, `"hello $NAME"`, or `a $OP b` does NOT work; match the whole node instead - When the same metavariable appears twice, both occurrences **MUST** match identical code (`$A == $A` matches `x == x`, not `x == y`) -- Patterns **MUST** parse as a single valid AST node for the target language. For method fragments or body snippets that don't parse standalone, wrap in valid context (e.g. `class $_ { … }`) and set `sel` to target the inner node — results return for the selected node, not the outer wrapper. If ast-grep reports `Multiple AST nodes are detected`, the pattern isn't a single parseable node — wrap and use `sel` -- 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 and select `call_expression`. Without `;`, tree-sitter-cpp may parse `ns::doThing($ARG)` as declaration-like syntax and return no matches +- Patterns **MUST** parse as a single valid AST node for the inferred target language. For method fragments or body snippets that don't parse standalone, wrap in valid context (e.g. `class $_ { … }`) +- 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 `sel: "identifier"` +- Loosest existence check: `pat: "executeBash"` with a narrow `path` @@ -22,26 +22,20 @@ Performs structural code search using AST matching via native ast-grep. -# Multi-pattern scoped search -`{"pat":["console.log($$$)","console.error($$$)"],"lang":"typescript","path":"src/"}` -# Named imports from a specific package (quoted string inside pattern) -`{"pat":["import { $$$IMPORTS } from \"react\""],"lang":"typescript","path":"src/"}` -# Arrow functions assigned to a const (distinct AST from function declarations) -`{"pat":["const $NAME = ($$$ARGS) => $BODY"],"lang":"typescript","path":"src/utils/"}` +# Search TypeScript files under src +`{"pat":"console.log($$$)","path":"src/**/*.ts"}` +# Named imports from a specific package +`{"pat":"import { $$$IMPORTS } from \"react\"","path":"src/**/*.ts"}` +# Arrow functions assigned to a const +`{"pat":"const $NAME = ($$$ARGS) => $BODY","path":"src/utils/**/*.ts"}` # Method call on any object, ignoring method name with `$_` -`{"pat":["logger.$_($$$ARGS)"],"lang":"typescript","path":"src/"}` -# Contextual pattern with selector — match the identifier `foo`, not the whole call -`{"pat":["foo()"],"sel":"identifier","lang":"typescript","path":"src/utils.ts"}` -# Match a function declaration while tolerating any return type annotation (sel targets the inner node) -`{"pat":["async function processItems($$$ARGS): $_ { $$$BODY }"],"sel":"function_declaration","lang":"typescript","path":"src/worker.ts"}` -# Match a method body fragment by wrapping in parseable context and selecting the method -`{"pat":["class $_ { async execute($INPUT: $_) { $$$BEFORE; const $PARSED = $_.parse($INPUT); $$$AFTER } }"],"sel":"method_definition","lang":"typescript","path":"src/tools/todo.ts"}` +`{"pat":"logger.$_($$$ARGS)","path":"src/**/*.ts"}` # Loosest existence check for a symbol in one file -`{"pat":["processItems"],"sel":"identifier","lang":"typescript","path":"src/worker.ts"}` +`{"pat":"processItems","path":"src/worker.ts"}` -- Avoid repo-root AST scans when the target is language-specific — narrow `path` first -- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `path`/`glob`/`lang` before concluding "no matches" +- 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" - For broad/open-ended exploration across subsystems, use Task tool with explore subagent first diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index fe4cbb58e..9b5bf4624 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -2,9 +2,8 @@ Searches files using powerful regex matching. - Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go) -- `path` also accepts comma-separated path lists; pair with `glob` when you need a relative file filter in addition to `type` -- For cross-line patterns like `struct \\{[\\s\\S]*?field`, set `multiline: true` -- If the pattern contains a literal `\n`, `multiline` defaults to true automatically +- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL +- Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern` diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index cbadf0193..3b07b64fa 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -15,7 +15,6 @@ import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; import type { OutputMeta } from "./output-meta"; import { - combineSearchGlobs, hasGlobPathChars, normalizePathLikeInput, parseSearchPath, @@ -46,21 +45,10 @@ const astEditSchema = Type.Object({ minItems: 1, description: "rewrite ops", }), - lang: Type.Optional(Type.String({ description: "language override", examples: ["typescript", "python"] })), - path: Type.Optional( - Type.String({ - description: "path or glob to rewrite", - examples: ["src/", "src/foo.ts"], - }), - ), - glob: Type.Optional(Type.String({ description: "glob filter", examples: ["**/*.ts"] })), - sel: Type.Optional( - Type.String({ - description: "contextual pattern selector", - examples: ["function_declaration", "call_expression", "identifier"], - }), - ), - limit: Type.Optional(Type.Number({ description: "max replacements" })), + path: Type.String({ + description: "file, directory, glob, or comma-separated paths to rewrite", + examples: ["src/", "src/foo.ts", "src/**/*.ts"], + }), }); export interface AstEditToolDetails { @@ -115,10 +103,6 @@ export class AstEditTool implements AgentTool { @@ -127,8 +111,11 @@ export class AstEditTool implements AgentTool { const applyResult = await astEdit({ rewrites: normalizedRewrites, - lang: params.lang?.trim(), path: resolvedSearchPath, glob: globFilter, - selector: params.sel?.trim(), dryRun: false, - maxReplacements, maxFiles, failOnParseError: false, }); @@ -391,10 +372,7 @@ export class AstEditTool implements AgentTool; - lang?: string; path?: string; - sel?: string; - limit?: number; } const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2; @@ -403,9 +381,7 @@ export const astEditToolRenderer = { inline: true, renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component { const meta: string[] = []; - if (args.lang) meta.push(`lang:${args.lang}`); if (args.path) meta.push(`in ${args.path}`); - if (args.limit !== undefined) meta.push(`limit:${args.limit}`); const rewriteCount = args.ops?.length ?? 0; if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`); @@ -495,7 +471,7 @@ export const astEditToolRenderer = { const extraLines: string[] = []; if (limitReached) { - extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or increase limit")); + extraLines.push(uiTheme.fg("warning", "limit reached; narrow path")); } if (details?.parseErrors?.length) { const total = details.parseErrors.length; diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index c788b11f1..6bdfc8046 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -15,7 +15,6 @@ import { createFileRecorder, formatResultPath } from "./file-recorder"; import { formatMatchLine } from "./match-line-format"; import type { OutputMeta } from "./output-meta"; import { - combineSearchGlobs, hasGlobPathChars, normalizePathLikeInput, parseSearchPath, @@ -36,28 +35,12 @@ import { ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; const astGrepSchema = Type.Object({ - pat: Type.Array(Type.String(), { - minItems: 1, - description: "ast patterns", - examples: [["console.log($$$)"]], + 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"], }), - lang: Type.Optional(Type.String({ description: "language override", examples: ["typescript", "python"] })), - path: Type.Optional( - Type.String({ - description: "path or glob to search", - examples: ["src/", "src/foo.ts"], - }), - ), - glob: Type.Optional(Type.String({ description: "glob filter", examples: ["**/*.ts"] })), - sel: Type.Optional( - Type.String({ - description: "contextual pattern selector", - examples: ["function_declaration", "call_expression", "identifier"], - }), - ), - limit: Type.Optional(Type.Number({ description: "max matches", default: 50 })), - offset: Type.Optional(Type.Number({ description: "skip first n matches", default: 0 })), - context: Type.Optional(Type.Number({ description: "context lines" })), + skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })), }); export interface AstGrepToolDetails { @@ -94,55 +77,48 @@ export class AstGrepTool implements AgentTool> { return untilAborted(signal, async () => { - const patterns = [...new Set(params.pat.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0))]; - if (patterns.length === 0) { - throw new ToolError("`pat` must include at least one non-empty pattern"); + const pattern = params.pat.trim(); + if (pattern.length === 0) { + throw new ToolError("`pat` must be a non-empty pattern"); } - const limit = params.limit === undefined ? 50 : Math.floor(params.limit); - if (!Number.isFinite(limit) || limit < 1) { - throw new ToolError("Limit must be a positive number"); + const patterns = [pattern]; + const skip = params.skip === undefined ? 0 : Math.floor(params.skip); + if (!Number.isFinite(skip) || skip < 0) { + throw new ToolError("skip must be a non-negative number"); } - const offset = params.offset === undefined ? 0 : Math.floor(params.offset); - if (!Number.isFinite(offset) || offset < 0) { - throw new ToolError("Offset must be a non-negative number"); - } - const context = params.context === undefined ? undefined : Math.floor(params.context); - if (context !== undefined && (!Number.isFinite(context) || context < 0)) { - throw new ToolError("Context must be a non-negative number"); - } - const formatScopePath = (targetPath: string): string => { const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/"); return relative.length === 0 ? "." : relative; }; let searchPath: string | undefined; let scopePath: string | undefined; - let globFilter = params.glob ? normalizePathLikeInput(params.glob) || undefined : undefined; - const rawPath = params.path ? normalizePathLikeInput(params.path) || undefined : undefined; - 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 search internal URL without backing file: ${rawPath}`); - } - searchPath = resource.sourcePath; - scopePath = formatScopePath(searchPath); + let globFilter: string | undefined; + const rawPath = normalizePathLikeInput(params.path); + if (rawPath.length === 0) { + throw new ToolError("`path` must be a non-empty path or glob"); + } + 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 search 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.glob; + scopePath = multiSearchPath.scopePath; } else { - const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter); - if (multiSearchPath) { - searchPath = multiSearchPath.basePath; - globFilter = multiSearchPath.glob; - scopePath = multiSearchPath.scopePath; - } else { - const parsedPath = parseSearchPath(rawPath); - searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); - globFilter = combineSearchGlobs(parsedPath.glob, globFilter); - scopePath = formatScopePath(searchPath); - } + const parsedPath = parseSearchPath(rawPath); + searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); + globFilter = parsedPath.glob; + scopePath = formatScopePath(searchPath); } } @@ -158,13 +134,9 @@ export class AstGrepTool implements AgentTool 0) meta.push(`limit:${args.limit}`); - if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`); - if (args.context !== undefined) meta.push(`context:${args.context}`); - if (args.pat && args.pat.length > 1) meta.push(`${args.pat.length} patterns`); + if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`); - const description = args.pat?.length === 1 ? args.pat[0] : args.pat ? `${args.pat.length} patterns` : "?"; + const description = args.pat ?? "?"; const text = renderStatusLine({ icon: "pending", title: "AST Grep", description, meta }, uiTheme); return new Text(text, 0, 0); }, @@ -355,19 +318,14 @@ export const astGrepToolRenderer = { const limitReached = details?.limitReached ?? false; if (matchCount === 0) { - const description = args?.pat?.length === 1 ? args.pat[0] : undefined; + const description = args?.pat; const meta = ["0 matches"]; if (details?.scopePath) meta.push(`in ${details.scopePath}`); if (filesSearched > 0) meta.push(`searched ${filesSearched}`); 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`/`glob` or set `lang` before concluding absence", - ), - ); + lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence")); const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT); for (const err of capped) { lines.push(uiTheme.fg("warning", ` - ${err}`)); @@ -384,7 +342,7 @@ export const astGrepToolRenderer = { if (details?.scopePath) meta.push(`in ${details.scopePath}`); meta.push(`searched ${filesSearched}`); if (limitReached) meta.push(uiTheme.fg("warning", "limit reached")); - const description = args?.pat?.length === 1 ? args.pat[0] : undefined; + const description = args?.pat; const header = renderStatusLine( { icon: limitReached ? "warning" : "success", title: "AST Grep", description, meta }, uiTheme, diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 7debcaa84..f8191e9a2 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -19,7 +19,6 @@ import { createFileRecorder } from "./file-recorder"; import { formatMatchLine } from "./match-line-format"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; import { - combineSearchGlobs, hasGlobPathChars, normalizePathLikeInput, parseSearchPath, @@ -38,16 +37,13 @@ import { toolResult } from "./tool-result"; const grepSchema = Type.Object({ pattern: Type.String({ description: "regex pattern", examples: ["function\\s+\\w+", "TODO"] }), - path: Type.Optional(Type.String({ description: "path to search", examples: ["src/", "src/foo.ts"] })), - glob: Type.Optional(Type.String({ description: "glob filter", examples: ["*.js"] })), - type: Type.Optional(Type.String({ description: "file type filter", examples: ["js", "py", "rust"] })), + path: Type.String({ + description: "file, directory, glob, comma-separated paths, or internal URL to search", + examples: ["src/", "src/foo.ts", "src/**/*.ts"], + }), i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })), - pre: Type.Optional(Type.Number({ description: "lines before matches" })), - post: Type.Optional(Type.Number({ description: "lines after matches" })), - multiline: Type.Optional(Type.Boolean({ description: "multiline matching" })), gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })), - limit: Type.Optional(Type.Number({ description: "max matches", default: 20 })), - offset: Type.Optional(Type.Number({ description: "skip first n matches", default: 0 })), + skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })), }); export type GrepToolInput = Static; @@ -98,7 +94,7 @@ export class GrepTool implements AgentTool { _onUpdate?: AgentToolUpdateCallback, _toolContext?: AgentToolContext, ): Promise> { - const { pattern, path: searchDir, glob, type, i, gitignore, pre, post, multiline, limit, offset } = params; + const { pattern, path: searchDir, i, gitignore, skip } = params; return untilAborted(signal, async () => { const normalizedPattern = pattern.trim(); @@ -107,25 +103,16 @@ export class GrepTool implements AgentTool { throw new ToolError("Pattern must not be empty"); } - const normalizedOffset = offset === undefined ? 0 : Number.isFinite(offset) ? Math.floor(offset) : Number.NaN; - if (normalizedOffset < 0 || !Number.isFinite(normalizedOffset)) { - throw new ToolError("Offset must be a non-negative number"); + const normalizedSkip = skip === undefined ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN; + if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) { + throw new ToolError("Skip must be a non-negative number"); } - - const rawLimit = limit === undefined ? undefined : Number.isFinite(limit) ? Math.floor(limit) : Number.NaN; - if (rawLimit !== undefined && (!Number.isFinite(rawLimit) || rawLimit < 0)) { - throw new ToolError("Limit must be a non-negative number"); - } - const normalizedLimit = rawLimit !== undefined && rawLimit > 0 ? rawLimit : undefined; - - const defaultContextBefore = this.session.settings.get("grep.contextBefore"); - const defaultContextAfter = this.session.settings.get("grep.contextAfter"); - const normalizedContextBefore = pre ?? defaultContextBefore; - const normalizedContextAfter = post ?? defaultContextAfter; + const normalizedContextBefore = this.session.settings.get("grep.contextBefore"); + const normalizedContextAfter = this.session.settings.get("grep.contextAfter"); const ignoreCase = i ?? false; const useGitignore = gitignore ?? true; const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n"); - const effectiveMultiline = multiline ?? patternHasNewline; + const effectiveMultiline = patternHasNewline; const useHashLines = resolveFileDisplayMode(this.session).hashLines; const formatScopePath = (targetPath: string): string => { @@ -135,41 +122,36 @@ export class GrepTool implements AgentTool { let searchPath: string; let scopePath: string; let exactFilePaths: string[] | undefined; - let globFilter = glob ? normalizePathLikeInput(glob) || undefined : undefined; - const internalRouter = this.session.internalRouter; - if (searchDir?.trim()) { - const rawPath = normalizePathLikeInput(searchDir); - 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 grep internal URL without a 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.exactFilePaths ? undefined : multiSearchPath.glob; - exactFilePaths = multiSearchPath.exactFilePaths; - scopePath = multiSearchPath.scopePath; - } else { - const parsedPath = parseSearchPath(rawPath); - searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); - if (parsedPath.glob) { - globFilter = combineSearchGlobs(parsedPath.glob, globFilter); - } - scopePath = formatScopePath(searchPath); - } - } - } else { - searchPath = resolveToCwd(".", this.session.cwd); - scopePath = "."; + 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 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 grep internal URL without a 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.exactFilePaths ? undefined : multiSearchPath.glob; + exactFilePaths = multiSearchPath.exactFilePaths; + scopePath = multiSearchPath.scopePath; + } else { + const parsedPath = parseSearchPath(rawPath); + searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd); + globFilter = parsedPath.glob; + scopePath = formatScopePath(searchPath); + } } - let isDirectory: boolean; try { const stat = await Bun.file(searchPath).stat(); @@ -180,7 +162,7 @@ export class GrepTool implements AgentTool { } const effectiveOutputMode = GrepOutputMode.Content; - const effectiveLimit = normalizedLimit ?? DEFAULT_MATCH_LIMIT; + const effectiveLimit = DEFAULT_MATCH_LIMIT; const internalLimit = Math.min(effectiveLimit * 5, 2000); // Run grep @@ -194,7 +176,6 @@ export class GrepTool implements AgentTool { { pattern: normalizedPattern, path: exactFilePath, - type: type?.trim() || undefined, ignoreCase, multiline: effectiveMultiline, hidden: true, @@ -211,7 +192,7 @@ export class GrepTool implements AgentTool { const relativeFilePath = path.relative(searchPath, exactFilePath).replace(/\\/g, "/"); matches.push(...fileResult.matches.map(match => ({ ...match, path: relativeFilePath }))); } - const offsetMatches = matches.slice(normalizedOffset); + const offsetMatches = matches.slice(normalizedSkip); result = { matches: offsetMatches, totalMatches: offsetMatches.length, @@ -225,14 +206,13 @@ export class GrepTool implements AgentTool { pattern: normalizedPattern, path: searchPath, glob: globFilter, - type: type?.trim() || undefined, ignoreCase, multiline: effectiveMultiline, hidden: true, gitignore: useGitignore, cache: false, maxCount: internalLimit, - offset: normalizedOffset > 0 ? normalizedOffset : undefined, + offset: normalizedSkip > 0 ? normalizedSkip : undefined, contextBefore: normalizedContextBefore, contextAfter: normalizedContextAfter, maxColumns: DEFAULT_MAX_COLUMN, @@ -407,6 +387,9 @@ export class GrepTool implements AgentTool { outputLines.push(...renderChunkedMatchesForFile(relativePath)); } } + if (matchLimitReached || result.limitReached) { + outputLines.push("", "Result limit reached; narrow path."); + } const rawOutput = outputLines.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated); @@ -424,12 +407,7 @@ export class GrepTool implements AgentTool { resultLimitReached: result.limitReached ? internalLimit : undefined, }; if (truncation.truncated) details.truncation = truncation; - const resultBuilder = toolResult(details) - .text(truncation.content) - .limits({ - matchLimit: matchLimitReached ? effectiveLimit : undefined, - resultLimit: result.limitReached ? internalLimit : undefined, - }); + const resultBuilder = toolResult(details).text(truncation.content); if (truncation.truncated) { resultBuilder.truncation(truncation, { direction: "head" }); } @@ -524,6 +502,9 @@ export class GrepTool implements AgentTool { if (hasContextLines && outputLines.length > 0) { outputLines.unshift("[grep] match lines use ':'; context lines use '-'."); } + if (matchLimitReached || result.limitReached) { + outputLines.push("", "Result limit reached; narrow path."); + } const rawOutput = outputLines.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); const output = truncation.content; @@ -546,11 +527,7 @@ export class GrepTool implements AgentTool { if (linesTruncated) details.linesTruncated = true; const resultBuilder = toolResult(details) .text(output) - .limits({ - matchLimit: matchLimitReached ? effectiveLimit : undefined, - resultLimit: result.limitReached ? internalLimit : undefined, - columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined, - }); + .limits({ columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined }); if (truncation.truncated) { resultBuilder.truncation(truncation, { direction: "head" }); } @@ -566,15 +543,9 @@ export class GrepTool implements AgentTool { interface GrepRenderArgs { pattern: string; path?: string; - glob?: string; - type?: string; i?: boolean; gitignore?: boolean; - pre?: number; - post?: number; - multiline?: boolean; - limit?: number; - offset?: number; + skip?: number; } const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2; @@ -584,19 +555,9 @@ export const grepToolRenderer = { renderCall(args: GrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component { const meta: string[] = []; if (args.path) meta.push(`in ${args.path}`); - if (args.glob) meta.push(`glob:${args.glob}`); - if (args.type) meta.push(`type:${args.type}`); if (args.i) meta.push("case:insensitive"); if (args.gitignore === false) meta.push("gitignore:false"); - if (args.pre !== undefined && args.pre > 0) { - meta.push(`pre:${args.pre}`); - } - if (args.post !== undefined && args.post > 0) { - meta.push(`post:${args.post}`); - } - if (args.multiline) meta.push("multiline"); - if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`); - if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`); + if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`); const text = renderStatusLine( { icon: "pending", title: "Grep", description: args.pattern || "?", meta }, @@ -708,9 +669,11 @@ export const grepToolRenderer = { } } + const renderedMatchLimit = details?.matchLimitReached ?? limits?.matchLimit?.reached; + const renderedResultLimit = details?.resultLimitReached ?? limits?.resultLimit?.reached; const truncationReasons: string[] = []; - if (limits?.matchLimit) truncationReasons.push(`limit ${limits.matchLimit.reached} matches`); - if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`); + if (renderedMatchLimit) truncationReasons.push(`first ${renderedMatchLimit} matches`); + if (renderedResultLimit) truncationReasons.push(`first ${renderedResultLimit} results`); if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit"); if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`); if (truncation?.artifactId) truncationReasons.push(formatFullOutputReference(truncation.artifactId)); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 354e5198d..f63d36383 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1195,7 +1195,7 @@ function b() { expect(output).not.toContain("schema-other.test.ts"); expect(result.details?.fileCount).toBe(2); }); - it("should combine globbing from path and glob parameters", async () => { + it("should accept nested wildcard filters in the path parameter", 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"); @@ -1207,8 +1207,7 @@ function b() { const result = await grepTool.execute("test-call-11-path-and-glob", { pattern: "providerOptions", - path: `${packageDir}/ai@6.0.119+*/node_modules/ai`, - glob: "**/*.{d.ts,ts}", + path: `${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`, gitignore: false, }); @@ -1220,17 +1219,16 @@ function b() { expect(result.details?.fileCount).toBe(2); }); - it("should respect global limit and include context lines", async () => { + it("should include configured context lines", async () => { const testFile = path.join(testDir, "context.txt"); const content = ["before", "match one", "after", "middle", "match two", "after two"].join("\n"); fs.writeFileSync(testFile, content); - const result = await grepTool.execute("test-call-12", { + const contextSettings = Settings.isolated({ "grep.contextBefore": 1, "grep.contextAfter": 1 }); + const contextGrepTool = wrapToolWithMetaNotice(new GrepTool(createTestToolSession(testDir, contextSettings))); + const result = await contextGrepTool.execute("test-call-12", { pattern: "match", path: testFile, - limit: 1, - pre: 1, - post: 1, }); const output = getTextOutput(result); @@ -1238,12 +1236,26 @@ function b() { expect(output).toMatch(/\b1-before/); expect(output).toMatch(/\b2:match one/); expect(output).toMatch(/\b3-after/); - expect(output).toContain("[1 matches limit reached. Use limit=2 for more]"); - // Ensure second match is not present - expect(output).not.toContain("match two"); + expect(output).toMatch(/\b5:match two/); }); - it("should group multi-file matches and distribute limit with round-robin", async () => { + it("should skip matches with the skip parameter", async () => { + const testFile = path.join(testDir, "skip.txt"); + fs.writeFileSync(testFile, ["needle one", "needle two", "needle three"].join("\n")); + + const result = await grepTool.execute("test-call-12-skip", { + pattern: "needle", + path: testFile, + skip: 1, + }); + + const output = getTextOutput(result); + expect(output).not.toContain("needle one"); + expect(output).toContain("needle two"); + expect(output).toContain("needle three"); + }); + + it("should group multi-file matches", async () => { for (let i = 1; i <= 3; i++) { fs.writeFileSync(path.join(testDir, `file-${i}.txt`), `needle in file ${i}\nextra needle ${i}`); } @@ -1252,7 +1264,6 @@ function b() { const result = await grepTool.execute("test-call-13-round-robin", { pattern: "needle", path: testDir, - limit: 4, }); const output = getTextOutput(result); @@ -1261,19 +1272,18 @@ function b() { expect(output).toContain("# file-3.txt"); expect(output).toContain("# dominant.txt"); expect(output).not.toContain("# ."); - expect(output).toContain("[4 matches limit reached. Use limit=8 for more]"); + expect(output).not.toContain("Result limit reached"); expect(result.details?.fileCount).toBe(4); - expect(result.details?.matchCount).toBe(4); + expect(result.details?.matchCount).toBe(10); }); - it("should not repeat file headings when round-robin selects multiple matches per file", async () => { + it("should not repeat file headings for multiple matches per file", async () => { fs.writeFileSync(path.join(testDir, "alpha.txt"), "needle a1\nneedle a2\nneedle a3"); fs.writeFileSync(path.join(testDir, "beta.txt"), "needle b1\nneedle b2\nneedle b3"); const result = await grepTool.execute("test-call-14-grouped-headings", { pattern: "needle", path: testDir, - limit: 4, }); const output = getTextOutput(result); @@ -1283,8 +1293,8 @@ function b() { expect(betaHeadings).toBe(1); expect(result.details?.fileMatches).toEqual( expect.arrayContaining([ - expect.objectContaining({ path: "alpha.txt", count: 2 }), - expect.objectContaining({ path: "beta.txt", count: 2 }), + expect.objectContaining({ path: "alpha.txt", count: 3 }), + expect.objectContaining({ path: "beta.txt", count: 3 }), ]), ); }); @@ -1368,7 +1378,7 @@ function b() { expect(result.details?.fileCount).toBe(1); expect(result.details?.matchCount).toBe(1); }); - it("should apply default limit of 20 when limit is not provided", async () => { + it("should apply the fixed default match cap", async () => { const lines = Array.from({ length: 60 }, (_, i) => `needle ${i + 1}`); fs.writeFileSync(path.join(testDir, "default-limit.txt"), lines.join("\n")); @@ -1378,7 +1388,7 @@ function b() { }); const output = getTextOutput(result); - expect(output).toContain("[20 matches limit reached. Use limit=40 for more]"); + expect(output).toContain("Result limit reached; narrow path."); expect(result.details?.matchCount).toBe(20); expect(result.details?.matchLimitReached).toBe(20); }); diff --git a/packages/coding-agent/test/tools/ast-edit.test.ts b/packages/coding-agent/test/tools/ast-edit.test.ts index b98a62603..d6b530043 100644 --- a/packages/coding-agent/test/tools/ast-edit.test.ts +++ b/packages/coding-agent/test/tools/ast-edit.test.ts @@ -72,7 +72,6 @@ describe("ast_edit tool schema", () => { const result = await tool!.execute("ast-edit-test", { ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }], - lang: "typescript", path: filePath, }); const text = result.content.find(content => content.type === "text")?.text ?? ""; @@ -109,7 +108,6 @@ describe("ast_edit tool schema", () => { const previewResult = await tool!.execute("ast-edit-preview", { ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }], - lang: "typescript", path: filePath, }); expect(previewResult.details).toBeDefined(); @@ -155,7 +153,6 @@ describe("ast_edit tool schema", () => { const previewResult = await tool!.execute("ast-edit-preview", { ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }], - lang: "typescript", path: filePath, }); expect((previewResult.details as { totalReplacements?: number } | undefined)?.totalReplacements).toBe(1); @@ -206,9 +203,7 @@ describe("ast_edit tool schema", () => { const previewResult = await tool!.execute("ast-edit-glob", { ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }], - lang: "typescript", - path: `${packagesDir}/pkg-*/src`, - glob: "**/*.ts", + path: `${packagesDir}/pkg-*/src/**/*.ts`, }); const text = previewResult.content.find(content => content.type === "text")?.text ?? ""; diff --git a/packages/coding-agent/test/tools/ast-grep.test.ts b/packages/coding-agent/test/tools/ast-grep.test.ts index 250234ccb..06d95f403 100644 --- a/packages/coding-agent/test/tools/ast-grep.test.ts +++ b/packages/coding-agent/test/tools/ast-grep.test.ts @@ -17,7 +17,7 @@ function createTestSession(cwd = "/tmp/test", overrides: Partial = } describe("ast_grep parse errors", () => { - it("collapses per-pattern parse errors for the same file", async () => { + it("reports parse errors for the searched file", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-grep-parse-")); try { const filePath = path.join(tempDir, "broken.ts"); @@ -28,8 +28,7 @@ describe("ast_grep parse errors", () => { expect(tool).toBeDefined(); const result = await tool!.execute("ast-grep-parse", { - pat: ["someUnlikelyCall($A)", "anotherUnlikelyCall($A)"], - lang: "typescript", + pat: "someUnlikelyCall($A)", path: filePath, }); @@ -42,7 +41,6 @@ describe("ast_grep parse errors", () => { expect(details?.parseErrors).toHaveLength(1); expect(details?.parseErrors?.[0]).toContain("broken.ts: parse error (syntax tree contains error nodes)"); expect(details?.parseErrors?.[0]).not.toContain("someUnlikelyCall($A):"); - expect(details?.parseErrors?.[0]).not.toContain("anotherUnlikelyCall($A):"); expect(text.match(/parse error \(syntax tree contains error nodes\)/g)?.length ?? 0).toBe(1); } finally { await fs.rm(tempDir, { recursive: true, force: true }); @@ -65,11 +63,8 @@ describe("ast_grep parse errors", () => { expect(tool).toBeDefined(); const result = await tool!.execute("ast-grep-glob", { - pat: ["providerOptions"], - sel: "identifier", - lang: "typescript", - path: `${packagesDir}/pkg-*/src`, - glob: "**/*.ts", + pat: "providerOptions", + path: `${packagesDir}/pkg-*/src/**/*.ts`, }); const text = result.content.find(content => content.type === "text")?.text ?? ""; @@ -100,9 +95,7 @@ describe("ast_grep parse errors", () => { expect(tool).toBeDefined(); const result = await tool!.execute("ast-grep-tlaplus", { - pat: ["Inc"], - sel: "identifier", - lang: "pluscal", + pat: "Inc", path: filePath, }); diff --git a/packages/coding-agent/test/tools/grep-renderer.test.ts b/packages/coding-agent/test/tools/grep-renderer.test.ts index 3ae8ce1d0..0c1834404 100644 --- a/packages/coding-agent/test/tools/grep-renderer.test.ts +++ b/packages/coding-agent/test/tools/grep-renderer.test.ts @@ -34,7 +34,7 @@ describe("grepToolRenderer", () => { const bodyLines = renderedLines.slice(1); expect(bodyLines).toHaveLength(6); - expect(bodyLines.at(-1)).toContain("truncated: limit 6 matches"); + expect(bodyLines.at(-1)).toContain("truncated: first 6 matches"); expect(bodyLines.some(line => line.includes("1 more match"))).toBe(true); expect(bodyLines.some(line => line.includes("gamma:1"))).toBe(false); }); diff --git a/packages/coding-agent/test/tools/root-path-alias.test.ts b/packages/coding-agent/test/tools/root-path-alias.test.ts index 9b24ae39a..4c1a8b292 100644 --- a/packages/coding-agent/test/tools/root-path-alias.test.ts +++ b/packages/coding-agent/test/tools/root-path-alias.test.ts @@ -116,11 +116,8 @@ describe("tool path root alias", () => { if (!tool) throw new Error("Missing ast_grep tool"); const result = await tool.execute("ast-grep-root-alias", { - pat: ["rootAliasSymbol"], - sel: "identifier", - lang: "typescript", - path: "/", - glob: "**/*.ts", + pat: "rootAliasSymbol", + path: "/**/*.ts", }); const details = result.details as { scopePath?: string } | undefined; @@ -143,9 +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)" }], - lang: "typescript", - path: "/", - glob: "**/*.ts", + path: "/**/*.ts", }); const details = preview.details as { scopePath?: string; totalReplacements?: number } | undefined; diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts index d8c7515b9..f491b75c5 100644 --- a/packages/coding-agent/test/tools/search-path-lists.test.ts +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -134,11 +134,8 @@ describe("search tool path lists", () => { if (!tool) throw new Error("Missing ast_grep tool"); const result = await tool.execute("ast-grep-quoted-path", { - pat: ["providerOptions"], - sel: "identifier", - lang: "typescript", - path: '"packages/"', - glob: '"**/*.ts"', + pat: "providerOptions", + path: '"packages/**/*.ts"', }); const text = getText(result); const details = result.details as { fileCount?: number; scopePath?: string } | undefined; @@ -156,11 +153,8 @@ describe("search tool path lists", () => { if (!tool) throw new Error("Missing ast_grep tool"); const result = await tool.execute("ast-grep-comma-paths", { - pat: ["providerOptions"], - sel: "identifier", - lang: "typescript", - path: "apps/,packages/,phases/", - glob: "**/*.ts", + pat: "providerOptions", + path: "apps/**/*.ts,packages/**/*.ts,phases/**/*.ts", }); const text = getText(result); const details = result.details as { fileCount?: number; scopePath?: string } | undefined; @@ -171,7 +165,7 @@ describe("search tool path lists", () => { expect(text).toContain("## └─ ast.ts"); expect(text).not.toContain("# other"); expect(details?.fileCount).toBe(3); - expect(details?.scopePath).toBe("apps/, packages/, phases/"); + expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts"); }); it("ast_edit applies across a space-separated path list", async () => { @@ -189,9 +183,7 @@ describe("search tool path lists", () => { const preview = await tool.execute("ast-edit-space-paths", { ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }], - lang: "typescript", - path: "apps/ packages/ phases/", - glob: "**/*.ts", + path: "apps/**/*.ts packages/**/*.ts phases/**/*.ts", }); const text = getText(preview); const details = preview.details as { totalReplacements?: number; scopePath?: string } | undefined; @@ -202,7 +194,7 @@ describe("search tool path lists", () => { expect(text).toContain("## └─ ast.ts (1 replacement)"); expect(text).not.toContain("# other"); expect(details?.totalReplacements).toBe(3); - expect(details?.scopePath).toBe("apps/, packages/, phases/"); + expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts"); queue.nextToolChoice(); const invoker = queue.peekInFlightInvoker(); @@ -319,7 +311,6 @@ describe("search tool path lists", () => { const result = await tool.execute("grep-no-empty-headings", { pattern: "shared-needle", path: "apps/,packages/,phases/", - limit: 2, }); const lines = getText(result).split("\n"); @@ -338,7 +329,11 @@ describe("search tool path lists", () => { it("grep explains context-line gutters without changing match and context separators", async () => { await Bun.write(path.join(tempDir, "context.txt"), "#if FLAG\nneedle\n#endif\n"); - const tools = await createTools(createTestSession(tempDir)); + const tools = await createTools( + createTestSession(tempDir, { + settings: Settings.isolated({ "grep.contextBefore": 1, "grep.contextAfter": 1 }), + }), + ); const tool = tools.find(entry => entry.name === "grep"); expect(tool).toBeDefined(); if (!tool) throw new Error("Missing grep tool"); @@ -346,8 +341,6 @@ describe("search tool path lists", () => { const result = await tool.execute("grep-context-label", { pattern: "needle", path: "context.txt", - pre: 1, - post: 1, }); const text = getText(result);