feat(coding-agent/tools)!: replaced paths arrays with path strings
- Replaced `grep`, `glob`, and `ast_grep` `paths` inputs with optional single `path` strings while preserving default workspace-root behavior. - Added shared `toPathList` normalization for legacy arrays and JSON-encoded arrays across tool execution and TUI renderers. - Updated prompts, fixtures, shims, transcript summaries, and tests to send and display the new `path` argument. - Updated collab-web search tool cards to read `path` while falling back to legacy `paths` for historical transcripts. - Recorded the contiguous coding-agent changelog run for the tool-path breaking change and adjacent TTS entries.
This commit is contained in:
@@ -2,6 +2,18 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed the `grep`, `glob`, and `ast_grep` tools to take a single optional `path` argument instead of a `paths` array. `path` accepts one path or a semicolon-delimited list (`src; tests`); omitting it searches the workspace root (`.`). Multi-path search, delimited expansion, and internal-URL scopes are unchanged. (`ast_edit` continues to take `paths`.)
|
||||
|
||||
### Changed
|
||||
|
||||
- Redesigned speech vocalization for low latency and clean spoken content. Assistant markdown now runs through a speakable-text pipeline before synthesis: code blocks and tables are silent, links speak their label, bare URLs speak their host, inline-code ticks/emphasis/heading/bullet markers are stripped, and long file paths collapse to their basename. Segmentation is now parent-side and emits at sentence boundaries immediately (the previous engine-side splitter held each sentence until the next one arrived), with clause-level cuts for long sentences and an idle flush when generation stalls mid-sentence. macOS gains a gapless streaming playback backend (ffmpeg AudioToolbox, sox fallback) instead of spawning `afplay` per sentence.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed speech vocalization starting only after the entire reply was synthesized: ONNX inference blocks the TTS worker's event loop, so per-segment IPC audio chunks queued unflushed and arrived in one burst. Streaming sends now drain the IPC channel before the next segment's inference, cutting time-to-first-audio to ~1.5s regardless of reply length.
|
||||
|
||||
## [16.3.0] - 2026-07-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("advisor", () => {
|
||||
type: "toolCall",
|
||||
id: "search-timeout",
|
||||
name: "grep",
|
||||
arguments: { pattern: "needle", paths: ["packages/coding-agent/src"] },
|
||||
arguments: { pattern: "needle", path: "packages/coding-agent/src" },
|
||||
},
|
||||
],
|
||||
timestamp: 1,
|
||||
|
||||
@@ -182,8 +182,8 @@ export const fsFixtures: Record<string, GalleryFixture> = {
|
||||
glob: {
|
||||
label: "Glob",
|
||||
// Streaming: glob half-typed, no limit yet.
|
||||
streamingArgs: { paths: ["packages/coding-agent/src/tools/*-render"] },
|
||||
args: { paths: ["packages/coding-agent/src/**/*.test.ts"], limit: 50 },
|
||||
streamingArgs: { path: "packages/coding-agent/src/tools/*-render" },
|
||||
args: { path: "packages/coding-agent/src/**/*.test.ts", limit: 50 },
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ export const searchFixtures: Record<string, GalleryFixture> = {
|
||||
},
|
||||
args: {
|
||||
pattern: "useState",
|
||||
paths: ["packages/tui/src"],
|
||||
path: "packages/tui/src",
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
@@ -160,7 +160,7 @@ export const searchFixtures: Record<string, GalleryFixture> = {
|
||||
},
|
||||
args: {
|
||||
pat: "useState($A)",
|
||||
paths: ["packages/tui/src/components"],
|
||||
path: "packages/tui/src/components",
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
|
||||
@@ -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, "grep", toolCallId, {
|
||||
pattern: args.pattern,
|
||||
paths: [searchPath],
|
||||
path: searchPath,
|
||||
case: args.caseInsensitive === true ? false : undefined,
|
||||
});
|
||||
return toolResultMessage;
|
||||
|
||||
@@ -500,7 +500,7 @@ export function createGrepToolDefinition(cwd: string, options?: GrepToolOptions)
|
||||
toolCallId,
|
||||
{
|
||||
pattern,
|
||||
paths: glob ? joinLegacyGlob(searchPath, glob) : searchPath,
|
||||
path: glob ? joinLegacyGlob(searchPath, glob) : searchPath,
|
||||
case: booleanField(params, "ignoreCase") ? false : undefined,
|
||||
},
|
||||
signal,
|
||||
@@ -558,7 +558,7 @@ export function createFindToolDefinition(cwd: string, options?: FindToolOptions)
|
||||
}
|
||||
return tool.execute(
|
||||
toolCallId,
|
||||
{ paths: [joinLegacyGlob(searchPath, pattern)], hidden: true, gitignore: true, limit },
|
||||
{ path: joinLegacyGlob(searchPath, pattern), hidden: true, gitignore: true, limit },
|
||||
signal,
|
||||
onUpdate,
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { TreeFilterMode } from "../../config/settings-schema";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
|
||||
import type { SessionTreeNode } from "../../session/session-entries";
|
||||
import { toPathList } from "../../tools/grep";
|
||||
import { toPathList } from "../../tools/path-utils";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { canonicalizeMessage } from "../../utils/thinking-display";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
@@ -753,8 +753,15 @@ class TreeList implements Component {
|
||||
return `[grep: /${pattern}/ in ${shortenPath(scope)}]`;
|
||||
}
|
||||
case "glob": {
|
||||
const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.pattern || ".");
|
||||
return `[glob: ${shortenPath(paths)}]`;
|
||||
const globInput =
|
||||
typeof args.path === "string"
|
||||
? args.path
|
||||
: typeof args.paths === "string" || Array.isArray(args.paths)
|
||||
? args.paths
|
||||
: undefined;
|
||||
const paths = toPathList(globInput);
|
||||
const scope = paths.length > 0 ? paths.join(", ") : ".";
|
||||
return `[glob: ${shortenPath(scope)}]`;
|
||||
}
|
||||
case "ls": {
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
|
||||
@@ -11,7 +11,7 @@ Structural code search via ast-grep.
|
||||
- C++ expression-statement calls need trailing `;`: `ns::doThing($ARG);`, `$CALLEE($ARG);`
|
||||
- TS declarations/methods — tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }`
|
||||
- Declaration forms are distinct shapes — `function foo`, method `foo()`, `const foo = () => {}`; search the right form before concluding absence
|
||||
- Loosest existence check: `pat: "executeBash"` with narrow `paths`
|
||||
- Loosest existence check: `pat: "executeBash"` with narrow `path`
|
||||
</instruction>
|
||||
|
||||
<output>
|
||||
@@ -19,7 +19,7 @@ Structural code search via ast-grep.
|
||||
</output>
|
||||
|
||||
<critical>
|
||||
- AVOID repo-root scans — narrow `paths` first
|
||||
- Parse issues = query failure, not absence: fix the pattern or tighten `paths` before concluding "no matches"
|
||||
- AVOID repo-root scans — narrow `path` first
|
||||
- Parse issues = query failure, not absence: fix the pattern or tighten `path` before concluding "no matches"
|
||||
- Broad cross-subsystem exploration: you SHOULD use the Task tool + explore subagent first
|
||||
</critical>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Globs files and directories via fast pattern matching, any codebase size.
|
||||
|
||||
<instruction>
|
||||
- `paths`: array of globs, files, or directories.
|
||||
- `path`: a glob, file, or directory. Search several at once by passing a semicolon-delimited list (`src/**/*.ts; test/**/*.ts`).
|
||||
- `gitignore` (default `true`) hides `.gitignore` matches. Set `gitignore: false` to find `.env*`, `*.log`, fresh build outputs, or anything your repo ignores.
|
||||
- `hidden` (default `true`); combine with `gitignore: false` to surface dotfiles also gitignored.
|
||||
</instruction>
|
||||
|
||||
@@ -2,7 +2,7 @@ Greps files using regex.
|
||||
|
||||
<instruction>
|
||||
- Rust regex (RE2-style) — no lookaround/backreferences; use line anchors or post-filters instead of (?!…)/(?<!…).
|
||||
- `paths`: SHOULD scope to known paths (e.g. `["src","tests"]`).
|
||||
- `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`).
|
||||
- Cross-line patterns detected from literal `\n` or `\\n` in `pattern`.
|
||||
</instruction>
|
||||
|
||||
|
||||
@@ -113,15 +113,19 @@ function primaryArg(name: string, args: Record<string, unknown> | undefined): st
|
||||
}
|
||||
if (name === "grep") {
|
||||
const pattern = primaryArgValue(args.pattern);
|
||||
const paths = primaryArgValue(args.paths);
|
||||
const paths = primaryArgValue(args.path) || primaryArgValue(args.paths);
|
||||
if (pattern && paths) return oneLine(`${pattern} @ ${paths}`);
|
||||
if (pattern) return oneLine(pattern);
|
||||
if (paths) return oneLine(paths);
|
||||
}
|
||||
if (name === "glob") {
|
||||
const paths = primaryArgValue(args.paths);
|
||||
const paths = primaryArgValue(args.path) || primaryArgValue(args.paths);
|
||||
if (paths) return oneLine(paths);
|
||||
}
|
||||
if (name === "ast_grep") {
|
||||
const pattern = primaryArgValue(args.pat);
|
||||
if (pattern) return oneLine(pattern);
|
||||
}
|
||||
for (const key of PRIMARY_ARG_KEYS) {
|
||||
const value = args[key];
|
||||
const summary = primaryArgValue(value);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { createFileRecorder, formatResultPath } from "./file-recorder";
|
||||
import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
|
||||
import { formatMatchLine } from "./match-line-format";
|
||||
import type { OutputMeta } from "./output-meta";
|
||||
import { resolveToolSearchScope } from "./path-utils";
|
||||
import { resolveToolSearchScope, toPathList } from "./path-utils";
|
||||
import {
|
||||
appendParseErrorsBulletList,
|
||||
capParseErrors,
|
||||
@@ -37,11 +37,9 @@ import { toolResult } from "./tool-result";
|
||||
|
||||
const astGrepSchema = type({
|
||||
pat: type("string").describe("ast pattern"),
|
||||
paths: type("string")
|
||||
.describe("file, directory, glob, or internal URL to search")
|
||||
.array()
|
||||
.atLeastLength(1)
|
||||
.describe("files, directories, globs, or internal URLs to search"),
|
||||
"path?": type("string").describe(
|
||||
'file, directory, glob, or internal URL to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
|
||||
),
|
||||
"skip?": type("number").describe("matches to skip"),
|
||||
});
|
||||
|
||||
@@ -159,23 +157,23 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
readonly examples: readonly ToolExample<typeof astGrepSchema.inferIn>[] = [
|
||||
{
|
||||
caption: "Search TypeScript files under src",
|
||||
call: { pat: "console.log($$$)", paths: ["src/**/*.ts"] },
|
||||
call: { pat: "console.log($$$)", path: "src/**/*.ts" },
|
||||
},
|
||||
{
|
||||
caption: "Named imports from a specific package",
|
||||
call: { pat: 'import { $$$IMPORTS } from "react"', paths: ["src/**/*.ts"] },
|
||||
call: { pat: 'import { $$$IMPORTS } from "react"', path: "src/**/*.ts" },
|
||||
},
|
||||
{
|
||||
caption: "Arrow functions assigned to a const",
|
||||
call: { pat: "const $NAME = ($$$ARGS) => $BODY", paths: ["src/utils/**/*.ts"] },
|
||||
call: { pat: "const $NAME = ($$$ARGS) => $BODY", path: "src/utils/**/*.ts" },
|
||||
},
|
||||
{
|
||||
caption: "Method call on any object, ignoring method name with `$_`",
|
||||
call: { pat: "logger.$_($$$ARGS)", paths: ["src/**/*.ts"] },
|
||||
call: { pat: "logger.$_($$$ARGS)", path: "src/**/*.ts" },
|
||||
},
|
||||
{
|
||||
caption: "Loosest existence check for a symbol in one file",
|
||||
call: { pat: "processItems", paths: ["src/worker.ts"] },
|
||||
call: { pat: "processItems", path: "src/worker.ts" },
|
||||
},
|
||||
];
|
||||
readonly loadMode = "discoverable";
|
||||
@@ -201,8 +199,10 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
if (!Number.isFinite(skip) || skip < 0) {
|
||||
throw new ToolError("skip must be a non-negative number");
|
||||
}
|
||||
const scopedPaths = toPathList(params.path);
|
||||
const rawPaths = scopedPaths.length > 0 ? scopedPaths : ["."];
|
||||
const scope = await resolveToolSearchScope({
|
||||
rawPaths: params.paths,
|
||||
rawPaths,
|
||||
cwd: this.session.cwd,
|
||||
internalUrlAction: "search",
|
||||
settings: this.session.settings,
|
||||
@@ -275,7 +275,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
const noMatchMessage = cappedParseErrors.length
|
||||
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `paths` before concluding absence."
|
||||
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path` before concluding absence."
|
||||
: "No matches found";
|
||||
const parseMessage = cappedParseErrors.length
|
||||
? `\n${formatParseErrors(cappedParseErrors, parseErrorsTotal).join("\n")}`
|
||||
@@ -375,7 +375,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
displayContent: displayLines.join("\n"),
|
||||
};
|
||||
if (result.limitReached) {
|
||||
outputLines.push("", "Result limit reached; narrow paths or increase limit.");
|
||||
outputLines.push("", "Result limit reached; narrow path or increase limit.");
|
||||
}
|
||||
if (cappedParseErrors.length) {
|
||||
outputLines.push("", ...formatParseErrors(cappedParseErrors, parseErrorsTotal));
|
||||
@@ -392,6 +392,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
|
||||
interface AstGrepRenderArgs {
|
||||
pat?: string;
|
||||
path?: string | string[];
|
||||
/** Legacy pre-`path` argument name; kept so historical transcripts still render a scope. */
|
||||
paths?: string[];
|
||||
skip?: number;
|
||||
}
|
||||
@@ -402,7 +404,8 @@ export const astGrepToolRenderer = {
|
||||
inline: true,
|
||||
renderCall(args: AstGrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const meta: string[] = [];
|
||||
if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
|
||||
const scopePaths = toPathList(args.path ?? args.paths);
|
||||
if (scopePaths.length) meta.push(`in ${scopePaths.join(", ")}`);
|
||||
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
|
||||
|
||||
const description = args.pat ?? "?";
|
||||
@@ -436,7 +439,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 `paths` before concluding absence"));
|
||||
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence"));
|
||||
appendParseErrorsBulletList(lines, details.parseErrors, uiTheme, details.parseErrorsTotal);
|
||||
}
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
@@ -487,7 +490,7 @@ export const astGrepToolRenderer = {
|
||||
|
||||
const extraLines: string[] = [];
|
||||
if (limitReached) {
|
||||
extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
|
||||
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or increase limit"));
|
||||
}
|
||||
if (details?.parseErrors?.length) {
|
||||
extraLines.push(
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
partitionExistingPaths,
|
||||
resolveExplicitFindPatterns,
|
||||
resolveToCwd,
|
||||
toPathList,
|
||||
} from "./path-utils";
|
||||
import {
|
||||
createCachedComponent,
|
||||
@@ -38,11 +39,9 @@ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
|
||||
const findSchema = type({
|
||||
paths: type("string")
|
||||
.describe("glob including search path")
|
||||
.array()
|
||||
.atLeastLength(1)
|
||||
.describe("globs including search paths"),
|
||||
"path?": type("string").describe(
|
||||
'glob, file, or directory to search — a single path or a semicolon-delimited list ("src/**/*.ts; test/**/*.ts"). Omitted -> searches the workspace root (".")',
|
||||
),
|
||||
"hidden?": type("boolean").describe("include hidden files"),
|
||||
"gitignore?": type("boolean").describe("respect gitignore"),
|
||||
"limit?": type("number").describe("max results"),
|
||||
@@ -110,19 +109,19 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
|
||||
readonly examples: readonly ToolExample<typeof findSchema.infer>[] = [
|
||||
{
|
||||
caption: "Glob files",
|
||||
call: { paths: ["src/**/*.ts"] },
|
||||
call: { path: "src/**/*.ts" },
|
||||
},
|
||||
{
|
||||
caption: "Multiple targets — separate array elements",
|
||||
call: { paths: ["src/**/*.ts", "test/**/*.ts"] },
|
||||
caption: "Multiple targets — semicolon-delimited list",
|
||||
call: { path: "src/**/*.ts; test/**/*.ts" },
|
||||
},
|
||||
{
|
||||
caption: "Glob gitignored files like .env",
|
||||
call: { paths: [".env*"], gitignore: false },
|
||||
call: { path: ".env*", gitignore: false },
|
||||
},
|
||||
{
|
||||
caption: "Glob directories matching a name (returns both files and dirs; directories are suffixed with `/`)",
|
||||
call: { paths: ["**/tests"] },
|
||||
call: { path: "**/tests" },
|
||||
},
|
||||
];
|
||||
readonly strict = true;
|
||||
@@ -144,13 +143,15 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
|
||||
onUpdate?: AgentToolUpdateCallback<GlobToolDetails>,
|
||||
_context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<GlobToolDetails>> {
|
||||
const { paths, limit, hidden, gitignore } = params;
|
||||
const { path: pathInput, limit, hidden, gitignore } = params;
|
||||
|
||||
return untilAborted(signal, async () => {
|
||||
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
||||
const scopedPaths = toPathList(pathInput);
|
||||
const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."];
|
||||
const rawPatternInputs = this.#customOps
|
||||
? paths
|
||||
: await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern });
|
||||
? effectivePaths
|
||||
: await expandDelimitedPathEntries(effectivePaths, this.session.cwd, { splitter: parseFindPattern });
|
||||
const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
|
||||
const internalRouter = InternalUrlRouter.instance();
|
||||
const normalizedPatterns: string[] = [];
|
||||
@@ -180,7 +181,7 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
|
||||
normalizedPatterns.push(resource.sourcePath);
|
||||
}
|
||||
if (normalizedPatterns.some(pattern => pattern.length === 0)) {
|
||||
throw new ToolError("`paths` must contain non-empty globs or paths");
|
||||
throw new ToolError("`path` must contain non-empty globs or paths");
|
||||
}
|
||||
|
||||
// Tolerate missing entries in a multi-path call: skip ones whose base
|
||||
@@ -468,12 +469,15 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
|
||||
// =============================================================================
|
||||
|
||||
interface GlobRenderArgs {
|
||||
path?: string | string[];
|
||||
/** Legacy pre-`path` argument name; kept so historical transcripts still render a scope. */
|
||||
paths?: string | string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function formatGlobRenderPaths(paths: GlobRenderArgs["paths"]): string | undefined {
|
||||
return Array.isArray(paths) ? paths.join(", ") : paths;
|
||||
function formatGlobRenderPaths(args: GlobRenderArgs | undefined): string | undefined {
|
||||
const list = toPathList(args?.path ?? args?.paths);
|
||||
return list.length > 0 ? list.join(", ") : undefined;
|
||||
}
|
||||
|
||||
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
|
||||
@@ -493,7 +497,7 @@ export const globToolRenderer = {
|
||||
icon: "pending",
|
||||
title: "Glob",
|
||||
titleColor: "toolTitle",
|
||||
description: formatGlobRenderPaths(args.paths) || "*",
|
||||
description: formatGlobRenderPaths(args) || "*",
|
||||
meta,
|
||||
},
|
||||
uiTheme,
|
||||
@@ -533,7 +537,7 @@ export const globToolRenderer = {
|
||||
iconOverride: globStatusIcon(uiTheme),
|
||||
title: "Glob",
|
||||
titleColor: "toolTitle",
|
||||
description: formatGlobRenderPaths(args?.paths),
|
||||
description: formatGlobRenderPaths(args),
|
||||
meta: [formatCount("file", lines.length)],
|
||||
},
|
||||
uiTheme,
|
||||
@@ -573,7 +577,7 @@ export const globToolRenderer = {
|
||||
icon: "warning",
|
||||
title: "Glob",
|
||||
titleColor: "toolTitle",
|
||||
description: formatGlobRenderPaths(args?.paths),
|
||||
description: formatGlobRenderPaths(args),
|
||||
meta: ["0 files"],
|
||||
},
|
||||
uiTheme,
|
||||
@@ -590,7 +594,7 @@ export const globToolRenderer = {
|
||||
...(truncated ? { icon: "warning" as const } : { iconOverride: globStatusIcon(uiTheme) }),
|
||||
title: "Glob",
|
||||
titleColor: "toolTitle",
|
||||
description: formatGlobRenderPaths(args?.paths),
|
||||
description: formatGlobRenderPaths(args),
|
||||
meta,
|
||||
},
|
||||
uiTheme,
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
selectorLineRanges,
|
||||
splitInternalUrlSel,
|
||||
splitPathAndSel,
|
||||
toPathList,
|
||||
} from "./path-utils";
|
||||
import {
|
||||
createCachedComponent,
|
||||
@@ -69,15 +70,13 @@ import { ToolError } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
|
||||
const searchPathEntry = type("string").describe(
|
||||
'file, directory, glob, internal URL, or "<file>:<lines>" selector (e.g. "src/foo.ts:50-100", "src/foo.ts:50+10", "src/foo.ts:50-100,200-300")',
|
||||
'file, directory, glob, internal URL, or "<file>:<lines>" selector to search (e.g. "src/foo.ts:50-100", "src/foo.ts:50+10", "src/foo.ts:50-100,200-300")',
|
||||
);
|
||||
const searchSchema = type({
|
||||
pattern: type("string").describe("regex pattern"),
|
||||
"paths?": searchPathEntry
|
||||
.or(searchPathEntry.array())
|
||||
.describe(
|
||||
'file, directory, glob, internal URL, or array of those to search; append `:<lines>` to scope a file to specific line ranges. Omitted or empty -> searches the workspace root (".")',
|
||||
),
|
||||
"path?": searchPathEntry.describe(
|
||||
'file, directory, glob, internal URL, or "<file>:<lines>" selector to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
|
||||
),
|
||||
"case?": type("boolean").describe("case-sensitive search"),
|
||||
"gitignore?": type("boolean").describe("respect gitignore"),
|
||||
"skip?": type("number")
|
||||
@@ -86,27 +85,6 @@ const searchSchema = type({
|
||||
});
|
||||
|
||||
export type GrepToolInput = typeof searchSchema.infer;
|
||||
function parseStringEncodedPathArray(input: string): string[] | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.some(entry => typeof entry !== "string")) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function toPathList(input: string | string[] | undefined): string[] {
|
||||
if (typeof input === "string") return parseStringEncodedPathArray(input) ?? [input];
|
||||
return input ?? [];
|
||||
}
|
||||
|
||||
/** Maximum number of distinct files surfaced in a single response. The
|
||||
* agent paginates further pages via `skip`. */
|
||||
@@ -858,8 +836,10 @@ type SearchParams = typeof searchSchema.infer;
|
||||
|
||||
export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails> {
|
||||
readonly name = "grep";
|
||||
readonly approval = (args: unknown): ToolTier =>
|
||||
toPathList((args as { paths?: string | string[] }).paths).some(pathTargetsSsh) ? "exec" : "read";
|
||||
readonly approval = (args: unknown): ToolTier => {
|
||||
const a = args as { path?: string | string[]; paths?: string | string[] };
|
||||
return toPathList(a.path ?? a.paths).some(pathTargetsSsh) ? "exec" : "read";
|
||||
};
|
||||
readonly label = "Grep";
|
||||
readonly loadMode = "discoverable";
|
||||
readonly summary = "Grep file contents using ripgrep (fast regex search)";
|
||||
@@ -882,7 +862,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
_onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
|
||||
_toolContext?: AgentToolContext,
|
||||
): Promise<AgentToolResult<GrepToolDetails>> {
|
||||
const { pattern, paths: rawPaths, case: caseSensitive, gitignore, skip } = params;
|
||||
const { pattern, path: rawPath, case: caseSensitive, gitignore, skip } = params;
|
||||
|
||||
return untilAborted(signal, async () => {
|
||||
// Preserve the pattern verbatim — leading/trailing whitespace is
|
||||
@@ -897,7 +877,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
|
||||
throw new ToolError("Skip must be a non-negative number");
|
||||
}
|
||||
const scopedPaths = toPathList(rawPaths);
|
||||
const scopedPaths = toPathList(rawPath);
|
||||
const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."];
|
||||
const rawEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd);
|
||||
const pathSpecs = parsePathSpecs(rawEntries);
|
||||
@@ -1505,6 +1485,8 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
||||
|
||||
interface GrepRenderArgs {
|
||||
pattern: string;
|
||||
path?: string | string[];
|
||||
/** Legacy pre-`path` argument name; kept so historical transcripts still render a scope. */
|
||||
paths?: string | string[];
|
||||
case?: boolean;
|
||||
gitignore?: boolean;
|
||||
@@ -1674,7 +1656,7 @@ function grepStatusIcon(uiTheme: Theme): string {
|
||||
export const grepToolRenderer = {
|
||||
inline: true,
|
||||
renderCall(args: GrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const paths = toPathList(args.paths);
|
||||
const paths = toPathList(args.path ?? args.paths);
|
||||
const meta: string[] = [];
|
||||
if (paths.length) meta.push(`in ${paths.join(", ")}`);
|
||||
if (args.case === false) meta.push("case:insensitive");
|
||||
|
||||
@@ -510,6 +510,39 @@ export function normalizePathLikeInput(input: string): string {
|
||||
return stripOuterDoubleQuotes(input.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON-encoded array of path strings (e.g. `'["a.ts","b.ts"]'`).
|
||||
* Returns `null` when the input is not a bracketed JSON string array, so the
|
||||
* caller can fall back to treating the input as a single literal path.
|
||||
*/
|
||||
function parseStringEncodedPathArray(input: string): string[] | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.some(entry => typeof entry !== "string")) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path argument that may arrive as a single string, a JSON-encoded
|
||||
* string array (`'["a.ts"]'`), or an actual array into a flat `string[]`.
|
||||
* Delimited single strings (`"a.ts b.ts"`) are left for
|
||||
* {@link expandDelimitedPathEntries} to split.
|
||||
*/
|
||||
export function toPathList(input: string | string[] | undefined): string[] {
|
||||
if (typeof input === "string") return parseStringEncodedPathArray(input) ?? [input];
|
||||
return input ?? [];
|
||||
}
|
||||
|
||||
const GLOB_PATH_CHARS = ["*", "?", "[", "{"] as const;
|
||||
|
||||
export function hasGlobPathChars(filePath: string): boolean {
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("search → edit seen-line guard", () => {
|
||||
await Bun.write(file, `${lines.join("\n")}\n`);
|
||||
const session = searchSession(tmpDir);
|
||||
|
||||
const search = await new GrepTool(session).execute("s1", { pattern: "NEEDLE", paths: [file] });
|
||||
const search = await new GrepTool(session).execute("s1", { pattern: "NEEDLE", path: file });
|
||||
const tag = tagFromOutput(resultText(search));
|
||||
|
||||
const seen = getFileSnapshotStore(session).byHash(canonicalSnapshotKey(file), tag)?.seenLines;
|
||||
@@ -223,7 +223,7 @@ describe("search → edit seen-line guard", () => {
|
||||
await Bun.write(file, `${lines.join("\n")}\n`);
|
||||
const session = searchSession(tmpDir);
|
||||
|
||||
const search = await new GrepTool(session).execute("s1", { pattern: "NEEDLE", paths: [file] });
|
||||
const search = await new GrepTool(session).execute("s1", { pattern: "NEEDLE", path: file });
|
||||
const tag = tagFromOutput(resultText(search));
|
||||
|
||||
await expect(executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 8.=8:\n+X`, session))).rejects.toThrow(
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("formatSessionHistoryMarkdown", () => {
|
||||
type: "toolCall",
|
||||
id: "tc-glob",
|
||||
name: "glob",
|
||||
arguments: { paths: ["packages/coding-agent/src/**/*.ts"] },
|
||||
arguments: { path: "packages/coding-agent/src/**/*.ts" },
|
||||
},
|
||||
],
|
||||
timestamp: 1,
|
||||
@@ -152,7 +152,7 @@ describe("formatSessionHistoryMarkdown", () => {
|
||||
type: "toolCall",
|
||||
id: "tc-grep",
|
||||
name: "grep",
|
||||
arguments: { pattern: "PRIMARY_ARG_KEYS", paths: ["packages/coding-agent/src/session"] },
|
||||
arguments: { pattern: "PRIMARY_ARG_KEYS", path: "packages/coding-agent/src/session" },
|
||||
},
|
||||
],
|
||||
timestamp: 1,
|
||||
@@ -181,7 +181,7 @@ describe("formatSessionHistoryMarkdown", () => {
|
||||
type: "toolCall",
|
||||
id: "tc-astgrep",
|
||||
name: "ast_grep",
|
||||
arguments: { pat: "console.log($$$)", paths: ["packages/coding-agent/src/**/*.ts"] },
|
||||
arguments: { pat: "console.log($$$)", path: "packages/coding-agent/src/**/*.ts" },
|
||||
},
|
||||
],
|
||||
timestamp: 1,
|
||||
|
||||
@@ -1625,7 +1625,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-11", {
|
||||
pattern: "match",
|
||||
paths: [testFile],
|
||||
path: testFile,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1639,7 +1639,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-useless-search", {
|
||||
pattern: "ZZZ_NO_SUCH_TOKEN_999",
|
||||
paths: [testDir],
|
||||
path: testDir,
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("No matches found");
|
||||
@@ -1651,7 +1651,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-useless-search-warn", {
|
||||
pattern: "ZZZ_NO_SUCH_TOKEN_999",
|
||||
paths: [testDir, path.join(testDir, "missing-file.txt")],
|
||||
path: `${testDir}; ${path.join(testDir, "missing-file.txt")}`,
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("Skipped missing paths");
|
||||
@@ -1665,7 +1665,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-11-path-glob", {
|
||||
pattern: "review target",
|
||||
paths: [`${testDir}/schema-review-*.test.ts`],
|
||||
path: `${testDir}/schema-review-*.test.ts`,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1686,7 +1686,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-11-path-and-glob", {
|
||||
pattern: "providerOptions",
|
||||
paths: [`${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`],
|
||||
path: `${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`,
|
||||
gitignore: false,
|
||||
});
|
||||
|
||||
@@ -1709,7 +1709,7 @@ function b() {
|
||||
);
|
||||
const result = await contextSearchTool.execute("test-call-12", {
|
||||
pattern: "match",
|
||||
paths: [testFile],
|
||||
path: testFile,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1731,7 +1731,7 @@ function b() {
|
||||
);
|
||||
const result = await noContextSearchTool.execute("test-call-12-gap", {
|
||||
pattern: "match",
|
||||
paths: [testFile],
|
||||
path: testFile,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1747,13 +1747,13 @@ function b() {
|
||||
|
||||
const first = await searchTool.execute("test-call-12-skip-first", {
|
||||
pattern: "needle",
|
||||
paths: [skipDir],
|
||||
path: skipDir,
|
||||
});
|
||||
expect(first.details?.fileCount).toBe(4);
|
||||
|
||||
const second = await searchTool.execute("test-call-12-skip-page", {
|
||||
pattern: "needle",
|
||||
paths: [skipDir],
|
||||
path: skipDir,
|
||||
skip: 2,
|
||||
});
|
||||
const secondOutput = getTextOutput(second);
|
||||
@@ -1771,14 +1771,14 @@ function b() {
|
||||
// 1. By default, search is case-sensitive (only matches the lowercase pattern "hello")
|
||||
const defaultResult = await searchTool.execute("test-case-default", {
|
||||
pattern: "hello",
|
||||
paths: [caseFile],
|
||||
path: caseFile,
|
||||
});
|
||||
expect(defaultResult.details?.matchCount).toBe(1);
|
||||
|
||||
// 2. With case: true, search is case-sensitive (only matches "hello")
|
||||
const sensitiveResult = await searchTool.execute("test-case-sensitive", {
|
||||
pattern: "hello",
|
||||
paths: [caseFile],
|
||||
path: caseFile,
|
||||
case: true,
|
||||
});
|
||||
expect(sensitiveResult.details?.matchCount).toBe(1);
|
||||
@@ -1786,7 +1786,7 @@ function b() {
|
||||
// 3. With case: false, search is case-insensitive (matches both "Hello World" and "hello world")
|
||||
const insensitiveResult = await searchTool.execute("test-case-insensitive", {
|
||||
pattern: "hello",
|
||||
paths: [caseFile],
|
||||
path: caseFile,
|
||||
case: false,
|
||||
});
|
||||
expect(insensitiveResult.details?.matchCount).toBe(2);
|
||||
@@ -1800,7 +1800,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-13-round-robin", {
|
||||
pattern: "needle",
|
||||
paths: [testDir],
|
||||
path: testDir,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1820,7 +1820,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-14-grouped-headings", {
|
||||
pattern: "needle",
|
||||
paths: [testDir],
|
||||
path: testDir,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1844,7 +1844,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-15-directory-headings", {
|
||||
pattern: "Claude Opus",
|
||||
paths: [testDir],
|
||||
path: testDir,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1863,7 +1863,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-15-gitignore-default", {
|
||||
pattern: "needle",
|
||||
paths: [scenarioDir],
|
||||
path: scenarioDir,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1881,7 +1881,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-16-gitignore-off", {
|
||||
pattern: "needle",
|
||||
paths: [scenarioDir],
|
||||
path: scenarioDir,
|
||||
gitignore: false,
|
||||
});
|
||||
|
||||
@@ -1903,7 +1903,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-16-fifo-dir", {
|
||||
pattern: "needle",
|
||||
paths: [scenarioDir],
|
||||
path: scenarioDir,
|
||||
gitignore: false,
|
||||
});
|
||||
|
||||
@@ -1925,7 +1925,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-14-file-limit", {
|
||||
pattern: "needle",
|
||||
paths: [limitDir],
|
||||
path: limitDir,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1948,7 +1948,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-14-per-file-cap", {
|
||||
pattern: "needle",
|
||||
paths: [concDir],
|
||||
path: concDir,
|
||||
});
|
||||
|
||||
const hotCount = result.details?.fileMatches?.find(entry => entry.path.endsWith("hot.txt"))?.count ?? 0;
|
||||
@@ -1963,7 +1963,7 @@ function b() {
|
||||
|
||||
const result = await searchTool.execute("test-call-14-single-file-cap", {
|
||||
pattern: "needle",
|
||||
paths: [single],
|
||||
path: single,
|
||||
});
|
||||
|
||||
expect(result.details?.matchCount).toBe(count);
|
||||
@@ -1978,7 +1978,7 @@ function b() {
|
||||
fs.writeFileSync(testFile, "single");
|
||||
|
||||
const result = await findTool.execute("test-call-13a", {
|
||||
paths: [testFile],
|
||||
path: testFile,
|
||||
});
|
||||
|
||||
const outputLines = getTextOutput(result)
|
||||
@@ -1996,7 +1996,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(testDir, "visible.txt"), "visible");
|
||||
|
||||
const result = await findTool.execute("test-call-13", {
|
||||
paths: [`${testDir}/**/*.txt`],
|
||||
path: `${testDir}/**/*.txt`,
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
@@ -2012,7 +2012,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(testDir, "kept.txt"), "kept");
|
||||
|
||||
const result = await findTool.execute("test-call-14", {
|
||||
paths: [`${testDir}/**/*.txt`],
|
||||
path: `${testDir}/**/*.txt`,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -2037,7 +2037,7 @@ function b() {
|
||||
fs.utimesSync(newerFile, newerTime, newerTime);
|
||||
|
||||
const result = await findTool.execute("test-call-14b", {
|
||||
paths: [`${testDir}/**/auth-actions.spec.ts`],
|
||||
path: `${testDir}/**/auth-actions.spec.ts`,
|
||||
});
|
||||
|
||||
expect(result.details?.files).toEqual(["z/auth-actions.spec.ts", "a/auth-actions.spec.ts"]);
|
||||
@@ -2049,7 +2049,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(nestedDir, "daemon-telemetry.ts"), "telemetry\n");
|
||||
|
||||
const result = await findTool.execute("test-call-14c", {
|
||||
paths: ["apps/daemon/src/**/daemon-telemetry.ts"],
|
||||
path: "apps/daemon/src/**/daemon-telemetry.ts",
|
||||
});
|
||||
|
||||
expect(result.details?.files).toEqual(["apps/daemon/src/telemetry/daemon-telemetry.ts"]);
|
||||
@@ -2064,7 +2064,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(clientDir, "client.ts"), "client\n");
|
||||
|
||||
const result = await findTool.execute("test-call-14e", {
|
||||
paths: ["apps/daemon/src/**/*.ts", "apps/client/src/**/*.ts"],
|
||||
path: JSON.stringify(["apps/daemon/src/**/*.ts", "apps/client/src/**/*.ts"]),
|
||||
});
|
||||
|
||||
const files = (result.details?.files ?? []).slice().sort();
|
||||
@@ -2080,7 +2080,7 @@ function b() {
|
||||
|
||||
const startedAt = performance.now();
|
||||
const result = await findTool.execute("test-call-14d", {
|
||||
paths: ["**/.env*"],
|
||||
path: "**/.env*",
|
||||
});
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
|
||||
@@ -2098,7 +2098,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(testDir, "pkg", "nested", "deep.txt"), "d");
|
||||
|
||||
const result = await findTool.execute("test-call-14f", {
|
||||
paths: [`${testDir}/pkg/**/*`],
|
||||
path: `${testDir}/pkg/**/*`,
|
||||
});
|
||||
|
||||
const files = (result.details?.files ?? []).slice().sort();
|
||||
@@ -2111,7 +2111,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(testDir, "alpha", "tests", "a.ts"), "a");
|
||||
|
||||
const result = await findTool.execute("test-call-14g", {
|
||||
paths: [`${testDir}/**/tests`],
|
||||
path: `${testDir}/**/tests`,
|
||||
});
|
||||
|
||||
const files = (result.details?.files ?? []).slice().sort();
|
||||
@@ -2126,7 +2126,7 @@ function b() {
|
||||
fs.writeFileSync(path.join(sub, "nested.tsx"), "n");
|
||||
|
||||
const result = await findTool.execute("test-call-14h", {
|
||||
paths: [`${dir}/*.tsx`],
|
||||
path: `${dir}/*.tsx`,
|
||||
});
|
||||
|
||||
const files = (result.details?.files ?? []).slice().sort();
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("ast_grep parse errors", () => {
|
||||
|
||||
const result = await tool!.execute("ast-grep-parse", {
|
||||
pat: "someUnlikelyCall($A)",
|
||||
paths: [filePath],
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
@@ -61,7 +61,7 @@ describe("ast_grep parse errors", () => {
|
||||
|
||||
const result = await tool!.execute("ast-grep-parse-cap", {
|
||||
pat: "someUnlikelyCall($A)",
|
||||
paths: [tempDir],
|
||||
path: tempDir,
|
||||
});
|
||||
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
@@ -95,7 +95,7 @@ describe("ast_grep parse errors", () => {
|
||||
|
||||
const result = await tool!.execute("ast-grep-glob", {
|
||||
pat: "providerOptions",
|
||||
paths: [`${packagesDir}/pkg-*/src/**/*.ts`],
|
||||
path: `${packagesDir}/pkg-*/src/**/*.ts`,
|
||||
});
|
||||
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
@@ -132,7 +132,7 @@ describe("ast_grep parse errors", () => {
|
||||
|
||||
const result = await tool!.execute("ast-grep-multi-page", {
|
||||
pat: "marker($A)",
|
||||
paths: [lateDir, earlyDir],
|
||||
path: `${lateDir}; ${earlyDir}`,
|
||||
});
|
||||
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
@@ -164,7 +164,7 @@ describe("ast_grep parse errors", () => {
|
||||
|
||||
const result = await tool!.execute("ast-grep-tlaplus", {
|
||||
pat: "Inc",
|
||||
paths: [filePath],
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
|
||||
@@ -170,10 +170,10 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const searchResult = await searchTool.execute("test-search", {
|
||||
pattern: "deep needle",
|
||||
paths: ["skill://demo/references"],
|
||||
path: "skill://demo/references",
|
||||
});
|
||||
const findResult = await findTool.execute("test-find", {
|
||||
paths: ["skill://demo/references"],
|
||||
path: "skill://demo/references",
|
||||
});
|
||||
|
||||
const searchText = getResultText(searchResult);
|
||||
@@ -191,7 +191,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "needle",
|
||||
paths: ["artifact://5"],
|
||||
path: "artifact://5",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -207,7 +207,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "ERROR.*",
|
||||
paths: ["artifact://3"],
|
||||
path: "artifact://3",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -225,7 +225,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "needle",
|
||||
paths: ["virtual://doc.md"],
|
||||
path: "virtual://doc.md",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -241,7 +241,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "needle",
|
||||
paths: ["virtual://doc.md:3-3"],
|
||||
path: "virtual://doc.md:3-3",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -255,7 +255,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
const content = `${Array.from({ length: 2100 }, (_, i) => `needle ${i + 1}`).join("\n")}\n`;
|
||||
registerVirtualDocs(new Map([["big.md", content]]));
|
||||
const tool = new GrepTool(createSession());
|
||||
const result = await tool.execute("ranged-cap", { pattern: "needle", paths: ["virtual://big.md:2090-2100"] });
|
||||
const result = await tool.execute("ranged-cap", { pattern: "needle", path: "virtual://big.md:2090-2100" });
|
||||
expect(getResultText(result)).toContain("needle 2095");
|
||||
});
|
||||
|
||||
@@ -265,26 +265,26 @@ describe("GrepTool internal URL resolution", () => {
|
||||
const content = `${"filler line\n".repeat(380_000)}needle here\n`;
|
||||
registerVirtualDocs(new Map([["big.md", content]]));
|
||||
const tool = new GrepTool(createSession());
|
||||
const result = await tool.execute("big-virtual", { pattern: "(?i)NEEDLE", paths: ["virtual://big.md"] });
|
||||
const result = await tool.execute("big-virtual", { pattern: "(?i)NEEDLE", path: "virtual://big.md" });
|
||||
expect(getResultText(result)).toContain("needle");
|
||||
});
|
||||
|
||||
it("rejects a malformed selector on a selector-capable internal URL instead of widening the search", async () => {
|
||||
const session = createSession();
|
||||
const tool = new GrepTool(session);
|
||||
await expect(tool.execute("bad-sel", { pattern: "needle", paths: ["artifact://5:-10"] })).rejects.toThrow(
|
||||
await expect(tool.execute("bad-sel", { pattern: "needle", path: "artifact://5:-10" })).rejects.toThrow(
|
||||
/invalid selector/i,
|
||||
);
|
||||
await expect(tool.execute("bad-mixed", { pattern: "needle", paths: ["artifact://5:1-1:-10"] })).rejects.toThrow(
|
||||
await expect(tool.execute("bad-mixed", { pattern: "needle", path: "artifact://5:1-1:-10" })).rejects.toThrow(
|
||||
/invalid selector/i,
|
||||
);
|
||||
// Multi-range colon compounds are rejected by read's parseSel; search must match.
|
||||
await expect(tool.execute("bad-multi", { pattern: "needle", paths: ["artifact://5:1-1:1-2"] })).rejects.toThrow(
|
||||
await expect(tool.execute("bad-multi", { pattern: "needle", path: "artifact://5:1-1:1-2" })).rejects.toThrow(
|
||||
/invalid selector/i,
|
||||
);
|
||||
// A `conflicts` display chunk is not valid in a range compound (only `raw` is).
|
||||
await expect(
|
||||
tool.execute("bad-conflicts", { pattern: "needle", paths: ["artifact://5:conflicts:1-1"] }),
|
||||
tool.execute("bad-conflicts", { pattern: "needle", path: "artifact://5:conflicts:1-1" }),
|
||||
).rejects.toThrow(/invalid selector/i);
|
||||
});
|
||||
|
||||
@@ -308,7 +308,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "Grep file contents with a regex across files",
|
||||
paths: ["omp://"],
|
||||
path: "omp://",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -322,7 +322,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "Read files, directories, archives",
|
||||
paths: ["omp://docs"],
|
||||
path: "omp://docs",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -334,7 +334,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
const session = createSession();
|
||||
const tool = new GrepTool(session);
|
||||
|
||||
expect(tool.execute("test-call", { pattern: "foo", paths: ["artifact://999"] })).rejects.toThrow(
|
||||
expect(tool.execute("test-call", { pattern: "foo", path: "artifact://999" })).rejects.toThrow(
|
||||
"Artifact 999 not found",
|
||||
);
|
||||
});
|
||||
@@ -347,7 +347,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "hello",
|
||||
paths: ["test.txt"],
|
||||
path: "test.txt",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -362,7 +362,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "data",
|
||||
paths: ["data.log"],
|
||||
path: "data.log",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -378,7 +378,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "needle",
|
||||
paths: ["artifact://9"],
|
||||
path: "artifact://9",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -399,7 +399,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
const tool = new GlobTool(session);
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
paths: ["local://PLAN.md"],
|
||||
path: "local://PLAN.md",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -416,7 +416,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
const session = createSession({ hasEditTool: true });
|
||||
const readResult = await new ReadTool(session).execute("test-read", { path: "local://notes" });
|
||||
const findResult = await new GlobTool(session).execute("test-find", {
|
||||
paths: ["local://notes"],
|
||||
path: "local://notes",
|
||||
});
|
||||
const dirResource = await InternalUrlRouter.instance().resolve("local://notes");
|
||||
|
||||
@@ -440,7 +440,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "needle",
|
||||
paths: ["local://plan.md"],
|
||||
path: "local://plan.md",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -460,7 +460,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "needle",
|
||||
paths: ["artifact://11", "mixed.txt"],
|
||||
path: JSON.stringify(["artifact://11", "mixed.txt"]),
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -474,7 +474,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
const session = createSession();
|
||||
const tool = new GrepTool(session);
|
||||
|
||||
expect(tool.execute("test-call", { pattern: "foo", paths: ["artifact://999"] })).rejects.toThrow(
|
||||
expect(tool.execute("test-call", { pattern: "foo", path: "artifact://999" })).rejects.toThrow(
|
||||
"Artifact 999 not found",
|
||||
);
|
||||
});
|
||||
@@ -489,7 +489,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "needle",
|
||||
paths: ["virtual://doc.md"],
|
||||
path: "virtual://doc.md",
|
||||
});
|
||||
|
||||
const text = getResultText(result);
|
||||
@@ -509,7 +509,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
it("matches an RE2 inline-flag pattern on a virtual resource (native dialect, not JS RegExp)", async () => {
|
||||
registerVirtualDocs(new Map([["doc.md", "needle here\n"]]));
|
||||
const tool = new GrepTool(createSession());
|
||||
const result = await tool.execute("re2-virtual", { pattern: "(?i)NEEDLE", paths: ["virtual://doc.md"] });
|
||||
const result = await tool.execute("re2-virtual", { pattern: "(?i)NEEDLE", path: "virtual://doc.md" });
|
||||
expect(getResultText(result)).toContain("needle");
|
||||
});
|
||||
|
||||
@@ -519,7 +519,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
const tool = new GrepTool(createSession());
|
||||
const result = await tool.execute("re2-mixed", {
|
||||
pattern: "(?i)NEEDLE",
|
||||
paths: [path.join(tmpDir, "local.txt"), "virtual://doc.md"],
|
||||
path: `${path.join(tmpDir, "local.txt")}; virtual://doc.md`,
|
||||
});
|
||||
const text = getResultText(result);
|
||||
expect(text).toContain("local");
|
||||
@@ -535,7 +535,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
pattern: "needle",
|
||||
paths: ["."],
|
||||
path: ".",
|
||||
skip: 5,
|
||||
});
|
||||
|
||||
@@ -556,7 +556,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
},
|
||||
});
|
||||
const tool = new GrepTool(createSession());
|
||||
await expect(tool.execute("dir-search", { pattern: "x", paths: ["dirstub://host/dir"] })).rejects.toThrow(
|
||||
await expect(tool.execute("dir-search", { pattern: "x", path: "dirstub://host/dir" })).rejects.toThrow(
|
||||
/directory listing|cannot recurse/,
|
||||
);
|
||||
});
|
||||
@@ -572,7 +572,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
vi.spyOn(sshFileTransfer, "statRemotePath").mockResolvedValue("directory");
|
||||
const listSpy = vi.spyOn(sshFileTransfer, "listRemoteDir").mockResolvedValue([]);
|
||||
const tool = new GrepTool(createSession());
|
||||
await expect(tool.execute("ssh-dir-search", { pattern: "x", paths: ["ssh://h/etc"] })).rejects.toThrow(
|
||||
await expect(tool.execute("ssh-dir-search", { pattern: "x", path: "ssh://h/etc" })).rejects.toThrow(
|
||||
/directory listing|cannot recurse/,
|
||||
);
|
||||
expect(listSpy).not.toHaveBeenCalled();
|
||||
@@ -591,7 +591,7 @@ describe("GrepTool internal URL resolution", () => {
|
||||
truncated: false,
|
||||
});
|
||||
const tool = new GrepTool(createSession());
|
||||
const result = await tool.execute("ssh-ipv6", { pattern: "needle", paths: ["ssh://[::1]/etc/hosts"] });
|
||||
const result = await tool.execute("ssh-ipv6", { pattern: "needle", path: "ssh://[::1]/etc/hosts" });
|
||||
expect(getResultText(result)).toContain("needle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("tool path arrays", () => {
|
||||
resetSettingsForTest();
|
||||
});
|
||||
|
||||
it("search accepts explicit path arrays", async () => {
|
||||
it("search accepts a semicolon-delimited path list", async () => {
|
||||
const tools = await createTools(createTestSession(tempDir));
|
||||
const tool = tools.find(entry => entry.name === "grep");
|
||||
expect(tool).toBeDefined();
|
||||
@@ -150,7 +150,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("search-path-array", {
|
||||
pattern: "shared-needle",
|
||||
paths: ["apps/", "packages/", "phases/"],
|
||||
path: "apps/; packages/; phases/",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -172,7 +172,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("search-json-array-string-paths", {
|
||||
pattern: "shared-needle",
|
||||
paths: JSON.stringify(["apps/", "packages/", "phases/"]),
|
||||
path: JSON.stringify(["apps/", "packages/", "phases/"]),
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -198,7 +198,7 @@ describe("tool path arrays", () => {
|
||||
] as const) {
|
||||
const result = await tool.execute(`search-delimited-${name}`, {
|
||||
pattern: "shared-needle",
|
||||
paths: [entry],
|
||||
path: entry,
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -220,7 +220,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("search-delimited-missing", {
|
||||
pattern: "shared-needle",
|
||||
paths: ["missing.txt, packages/grep.txt"],
|
||||
path: "missing.txt, packages/grep.txt",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; missingPaths?: string[] } | undefined;
|
||||
@@ -241,7 +241,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("search-records-snapshot", {
|
||||
pattern: "shared-needle",
|
||||
paths: ["apps/"],
|
||||
path: "apps/",
|
||||
});
|
||||
const text = getText(result);
|
||||
const tag = /^# apps\/\n## grep\.txt#([0-9A-F]{4})/m.exec(text)?.[1];
|
||||
@@ -267,7 +267,7 @@ describe("tool path arrays", () => {
|
||||
name: tool.name,
|
||||
arguments: {
|
||||
pattern: "space-needle",
|
||||
paths: "folder with spaces/",
|
||||
path: "folder with spaces/",
|
||||
},
|
||||
});
|
||||
const result = await tool.execute("search-single-string-path", args);
|
||||
@@ -292,13 +292,13 @@ describe("tool path arrays", () => {
|
||||
|
||||
const single = await tool.execute("search-bracket-literal-single", {
|
||||
pattern: "bracket-needle",
|
||||
paths: ["apps/[id]/page.tsx"],
|
||||
path: "apps/[id]/page.tsx",
|
||||
});
|
||||
expect(getText(single)).toContain("bracket-needle");
|
||||
|
||||
const dir = await tool.execute("search-bracket-literal-dir", {
|
||||
pattern: "bracket-needle",
|
||||
paths: ["apps/[id]"],
|
||||
path: "apps/[id]",
|
||||
});
|
||||
expect(getText(dir)).toContain("bracket-needle");
|
||||
await removeWithRetries(tmp);
|
||||
@@ -455,7 +455,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("search-space-directory", {
|
||||
pattern: "space-needle",
|
||||
paths: ["folder with spaces/"],
|
||||
path: "folder with spaces/",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -473,7 +473,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("search-quoted-path", {
|
||||
pattern: "shared-needle",
|
||||
paths: ['"packages/"'],
|
||||
path: '"packages/"',
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -493,7 +493,7 @@ describe("tool path arrays", () => {
|
||||
const absoluteAppsPath = path.join(tempDir, "apps");
|
||||
const result = await tool.execute("search-absolute-in-cwd", {
|
||||
pattern: "shared-needle",
|
||||
paths: [absoluteAppsPath],
|
||||
path: absoluteAppsPath,
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -572,7 +572,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("ast-grep-quoted-path", {
|
||||
pat: "providerOptions",
|
||||
paths: ['"packages/**/*.ts"'],
|
||||
path: '"packages/**/*.ts"',
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -583,7 +583,7 @@ describe("tool path arrays", () => {
|
||||
expect(details?.scopePath).toBe("packages");
|
||||
});
|
||||
|
||||
it("ast_grep accepts explicit path arrays", async () => {
|
||||
it("ast_grep accepts a semicolon-delimited path list", async () => {
|
||||
const tools = await createTools(createTestSession(tempDir));
|
||||
const tool = tools.find(entry => entry.name === "ast_grep");
|
||||
expect(tool).toBeDefined();
|
||||
@@ -591,7 +591,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("ast-grep-path-array", {
|
||||
pat: "providerOptions",
|
||||
paths: ["apps/**/*.ts", "packages/**/*.ts", "phases/**/*.ts"],
|
||||
path: "apps/**/*.ts; packages/**/*.ts; phases/**/*.ts",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -617,7 +617,7 @@ describe("tool path arrays", () => {
|
||||
] as const) {
|
||||
const result = await tool.execute(`ast-grep-delimited-${name}`, {
|
||||
pat: "providerOptions",
|
||||
paths: [entry],
|
||||
path: entry,
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -673,14 +673,14 @@ describe("tool path arrays", () => {
|
||||
await removeWithRetries(tmp);
|
||||
});
|
||||
|
||||
it("find accepts explicit path arrays", async () => {
|
||||
it("find accepts a semicolon-delimited path list", async () => {
|
||||
const tools = await createTools(createTestSession(tempDir));
|
||||
const tool = tools.find(entry => entry.name === "glob");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("Missing glob tool");
|
||||
|
||||
const result = await tool.execute("find-path-array", {
|
||||
paths: ["apps/", "packages/", "phases/"],
|
||||
path: "apps/; packages/; phases/",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string; files?: string[] } | undefined;
|
||||
@@ -715,7 +715,7 @@ describe("tool path arrays", () => {
|
||||
["space", "apps/grep.txt packages/grep.txt"],
|
||||
] as const) {
|
||||
const result = await tool.execute(`find-delimited-${name}`, {
|
||||
paths: [entry],
|
||||
path: entry,
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string; files?: string[] } | undefined;
|
||||
@@ -737,7 +737,7 @@ describe("tool path arrays", () => {
|
||||
if (!tool) throw new Error("Missing glob tool");
|
||||
|
||||
const result = await tool.execute("find-delimited-missing", {
|
||||
paths: ["missing.txt, packages/grep.txt"],
|
||||
path: "missing.txt, packages/grep.txt",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; missingPaths?: string[]; files?: string[] } | undefined;
|
||||
@@ -757,7 +757,7 @@ describe("tool path arrays", () => {
|
||||
if (!tool) throw new Error("Missing glob tool");
|
||||
|
||||
const result = await tool.execute("find-space-directory", {
|
||||
paths: ["folder with spaces/"],
|
||||
path: "folder with spaces/",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string; files?: string[] } | undefined;
|
||||
@@ -775,7 +775,7 @@ describe("tool path arrays", () => {
|
||||
if (!tool) throw new Error("Missing glob tool");
|
||||
|
||||
const result = await tool.execute("find-quoted-pattern", {
|
||||
paths: ['"packages/"'],
|
||||
path: '"packages/"',
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -797,7 +797,7 @@ describe("tool path arrays", () => {
|
||||
if (!tool) throw new Error("Missing glob tool");
|
||||
|
||||
const result = await tool.execute("find-outside-cwd", {
|
||||
paths: [outsideDir],
|
||||
path: outsideDir,
|
||||
});
|
||||
const text = getText(result);
|
||||
const expectedPath = path.join(outsideDir, "outside.txt").replace(/\\/g, "/");
|
||||
@@ -813,7 +813,7 @@ describe("tool path arrays", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("grep accepts bare directory name arrays", async () => {
|
||||
it("grep accepts a bare semicolon-delimited directory list", async () => {
|
||||
const tools = await createTools(createTestSession(tempDir));
|
||||
const tool = tools.find(entry => entry.name === "grep");
|
||||
expect(tool).toBeDefined();
|
||||
@@ -821,7 +821,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("grep-bare-path-array", {
|
||||
pattern: "shared-needle",
|
||||
paths: ["apps", "packages", "phases"],
|
||||
path: "apps; packages; phases",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -849,7 +849,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("grep-exact-file-array", {
|
||||
pattern: "exact-needle",
|
||||
paths: ["alpha.txt", "beta.txt"],
|
||||
path: "alpha.txt; beta.txt",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -872,7 +872,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("grep-no-empty-headings", {
|
||||
pattern: "shared-needle",
|
||||
paths: ["apps/", "packages/", "phases/"],
|
||||
path: "apps/; packages/; phases/",
|
||||
});
|
||||
const lines = getText(result).split("\n");
|
||||
|
||||
@@ -903,7 +903,7 @@ describe("tool path arrays", () => {
|
||||
|
||||
const result = await tool.execute("grep-context-label", {
|
||||
pattern: "needle",
|
||||
paths: ["context.txt"],
|
||||
path: "context.txt",
|
||||
});
|
||||
const text = getText(result);
|
||||
|
||||
|
||||
@@ -62,8 +62,8 @@ describe.skipIf(isWindows)("search with omitted paths", () => {
|
||||
const tool = tools.find(entry => entry.name === "grep");
|
||||
if (!tool) throw new Error("Missing grep tool");
|
||||
|
||||
// Callers that omit `paths` would otherwise be rejected at schema
|
||||
// validation with `paths: Invalid input` and never run. Omission must
|
||||
// Callers that omit `path` would otherwise be rejected at schema
|
||||
// validation with `path: Invalid input` and never run. Omission must
|
||||
// degrade to a workspace-root scan rather than fail the tool call.
|
||||
const result = await tool.execute("search-default-paths", { pattern: "default-needle" });
|
||||
|
||||
@@ -73,14 +73,14 @@ describe.skipIf(isWindows)("search with omitted paths", () => {
|
||||
expect(details?.fileCount).toBe(1);
|
||||
});
|
||||
|
||||
it("defaults to the workspace root when paths is an empty array", async () => {
|
||||
it("defaults to the workspace root when path is an empty JSON array", async () => {
|
||||
const tools = await createTools(createTestSession(cwd));
|
||||
const tool = tools.find(entry => entry.name === "grep");
|
||||
if (!tool) throw new Error("Missing grep tool");
|
||||
|
||||
const result = await tool.execute("search-empty-paths", {
|
||||
pattern: "default-needle",
|
||||
paths: [],
|
||||
path: "[]",
|
||||
});
|
||||
|
||||
expect(getText(result)).toContain("default-needle here");
|
||||
@@ -115,7 +115,7 @@ describe.skipIf(isWindows)("search across unrelated filesystem trees", () => {
|
||||
const start = performance.now();
|
||||
const result = await tool.execute("search-cross-tree", {
|
||||
pattern: "shared-needle",
|
||||
paths: [dirA, dirB],
|
||||
path: `${dirA}; ${dirB}`,
|
||||
});
|
||||
const durationMs = performance.now() - start;
|
||||
|
||||
@@ -208,7 +208,7 @@ describe.skipIf(isWindows)("search with explicit walker-pruned file targets", ()
|
||||
|
||||
const result = await tool.execute("search-git-config", {
|
||||
pattern: "followTags",
|
||||
paths: [".", ".git/config"],
|
||||
path: ".; .git/config",
|
||||
});
|
||||
const details = result.details as { matchCount?: number; files?: string[] } | undefined;
|
||||
expect(getText(result)).toContain("followTags = true");
|
||||
@@ -225,7 +225,7 @@ describe.skipIf(isWindows)("search with explicit walker-pruned file targets", ()
|
||||
|
||||
const result = await tool.execute("search-overlap", {
|
||||
pattern: "needle-dup",
|
||||
paths: [".", "src/a.ts"],
|
||||
path: ".; src/a.ts",
|
||||
});
|
||||
const details = result.details as { matchCount?: number } | undefined;
|
||||
expect(details?.matchCount).toBe(1);
|
||||
|
||||
@@ -221,7 +221,7 @@ describe.skipIf(!SSH_OK)("ssh:// through the real read/grep/write tools (localho
|
||||
it("GrepTool reports matches under the ssh:// URL with no scratch-temp leak", async () => {
|
||||
mockEmptyHosts();
|
||||
const tool = new GrepTool(createSession());
|
||||
const result = await tool.execute("s", { pattern: "beta", paths: [`ssh://localhost${TMP}/read.txt`] });
|
||||
const result = await tool.execute("s", { pattern: "beta", path: `ssh://localhost${TMP}/read.txt` });
|
||||
const out = textOf(result);
|
||||
expect(out).toContain("beta");
|
||||
// The resource is reported under its ssh:// URL, not a local scratch path.
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("ssh:// is rejected before any connection in read/write-tier tools", ()
|
||||
.spyOn(InternalUrlRouter.instance(), "resolve")
|
||||
.mockRejectedValue(new Error("resolve must not run for ssh://"));
|
||||
const tool = new GlobTool(createTestToolSession(os.tmpdir()));
|
||||
await expect(tool.execute("f", { paths: ["ssh://h/x"] })).rejects.toThrow(/ssh:\/\//);
|
||||
await expect(tool.execute("f", { path: "ssh://h/x" })).rejects.toThrow(/ssh:\/\//);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the glob, grep, and ast_grep tool cards to read the new single `path` argument, falling back to the legacy `paths` array so historical transcripts still render their search scope.
|
||||
|
||||
## [16.3.0] - 2026-07-02
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Badges, CodeBlock, InvalidArg, Kv, KvGrid, Output, PathText, ResultText } from "../parts";
|
||||
import type { ToolRenderer, ToolRenderProps } from "../types";
|
||||
import { detailsRecord, normalizeWs, num, str, truncate } from "../util";
|
||||
import { detailsRecord, normalizeWs, num, scopePaths, str, truncate } from "../util";
|
||||
|
||||
/** `pat` is a string in the current schema, an array on the legacy wire. */
|
||||
function patternsOf(args: Record<string, unknown>): string[] {
|
||||
@@ -11,16 +11,9 @@ function patternsOf(args: Record<string, unknown>): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Current schema sends `paths: string[]`; legacy sent a single `path`. */
|
||||
function pathsOf(args: Record<string, unknown>): string[] {
|
||||
if (Array.isArray(args.paths)) return args.paths.filter((p): p is string => typeof p === "string");
|
||||
const single = str(args.path);
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
function Summary({ args }: ToolRenderProps): ReactNode {
|
||||
const patterns = patternsOf(args);
|
||||
const paths = pathsOf(args);
|
||||
const paths = scopePaths(args);
|
||||
const lang = str(args.lang);
|
||||
return (
|
||||
<>
|
||||
@@ -35,7 +28,7 @@ function Summary({ args }: ToolRenderProps): ReactNode {
|
||||
|
||||
function Body({ args, result }: ToolRenderProps): ReactNode {
|
||||
const patterns = patternsOf(args);
|
||||
const paths = pathsOf(args);
|
||||
const paths = scopePaths(args);
|
||||
const lang = str(args.lang);
|
||||
const glob = str(args.glob);
|
||||
const sel = str(args.sel);
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Badges, InvalidArg, Note, ResultText } from "../parts";
|
||||
import type { ToolRenderer, ToolRenderProps } from "../types";
|
||||
import { detailsRecord, display, isRecord, num, shortenPath, str, truncate } from "../util";
|
||||
|
||||
/** `args.paths` accepts one glob or an array; anything else is invalid. */
|
||||
function joinedGlobs(paths: unknown): string | null {
|
||||
if (typeof paths === "string") return shortenPath(paths);
|
||||
if (Array.isArray(paths)) return paths.map(p => shortenPath(display(p))).join(", ");
|
||||
return null;
|
||||
}
|
||||
import { detailsRecord, isRecord, num, scopePaths, shortenPath, str, truncate } from "../util";
|
||||
|
||||
function Summary({ args }: ToolRenderProps): ReactNode {
|
||||
const globs = joinedGlobs(args.paths);
|
||||
if (globs === null && args.paths !== undefined) return <InvalidArg what="paths" />;
|
||||
const raw = args.path ?? args.paths;
|
||||
if (raw !== undefined && typeof raw !== "string" && !Array.isArray(raw)) return <InvalidArg what="path" />;
|
||||
const globs = scopePaths(args).map(shortenPath).join(", ");
|
||||
return <span className="tv-pattern">{truncate(globs || "*", 120)}</span>;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Badges, InvalidArg, Note, ResultText } from "../parts";
|
||||
import type { ToolRenderer, ToolRenderProps } from "../types";
|
||||
import { detailsRecord, num, resultTextOf, shortenPath, str } from "../util";
|
||||
import { detailsRecord, num, resultTextOf, scopePaths, shortenPath, str } from "../util";
|
||||
|
||||
/** Grep targets: current `paths` (string | string[]) or legacy `path`. */
|
||||
/** Grep scope: current `path` (string, delimited, or JSON array) or legacy `paths`; defaults to workspace root. */
|
||||
function pathsOf(args: Record<string, unknown>): string[] {
|
||||
const raw = args.paths ?? args.path;
|
||||
if (typeof raw === "string") return [shortenPath(raw)];
|
||||
if (Array.isArray(raw)) {
|
||||
const out: string[] = [];
|
||||
for (const p of raw) {
|
||||
if (typeof p === "string") out.push(shortenPath(p));
|
||||
}
|
||||
if (out.length) return out;
|
||||
}
|
||||
return ["."];
|
||||
const list = scopePaths(args).map(shortenPath);
|
||||
return list.length ? list : ["."];
|
||||
}
|
||||
|
||||
/** Flag badges covering current and legacy arg dialects. */
|
||||
|
||||
@@ -41,6 +41,32 @@ export function shortenPath(p: string): string {
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search scope for display: the current `path` argument (else the legacy
|
||||
* `paths`), normalized from a single string, a JSON-encoded string array
|
||||
* (`'["a.ts","b.ts"]'`), or an actual array into a flat `string[]`. Mirrors the
|
||||
* coding-agent `toPathList` so web cards render the same scope the tool searched.
|
||||
*/
|
||||
export function scopePaths(args: Record<string, unknown>): string[] {
|
||||
const raw = args.path ?? args.paths;
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed) && parsed.every((p): p is string => typeof p === "string")) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON — treat the whole string as one path.
|
||||
}
|
||||
}
|
||||
return [raw];
|
||||
}
|
||||
if (Array.isArray(raw)) return raw.filter((p): p is string => typeof p === "string");
|
||||
return [];
|
||||
}
|
||||
|
||||
export function truncate(s: string, maxLen = 100): string {
|
||||
return s.length <= maxLen ? s : `${s.slice(0, maxLen)}…`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user