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:
can1357
2026-07-02 08:30:33 +02:00
parent 17552b9135
commit 95b91c7f73
29 changed files with 281 additions and 227 deletions
+12
View File
@@ -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: [
+1 -1
View File
@@ -180,7 +180,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
const toolResultMessage = await executeTool(this.options, "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);
+20 -17
View File
@@ -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(
+24 -20
View File
@@ -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,
+14 -32
View File
@@ -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,
+31 -31
View File
@@ -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();
});
});
+4
View File
@@ -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)}…`;
}