diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 11098023c..f49d18c7f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts index 267354e85..babc4cb7d 100644 --- a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts +++ b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts @@ -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, diff --git a/packages/coding-agent/src/cli/gallery-fixtures/fs.ts b/packages/coding-agent/src/cli/gallery-fixtures/fs.ts index c7ae7d137..23e3baee7 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/fs.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/fs.ts @@ -182,8 +182,8 @@ export const fsFixtures: Record = { 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: [ { diff --git a/packages/coding-agent/src/cli/gallery-fixtures/search.ts b/packages/coding-agent/src/cli/gallery-fixtures/search.ts index 241f930fa..3789f3b43 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/search.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/search.ts @@ -9,7 +9,7 @@ export const searchFixtures: Record = { }, args: { pattern: "useState", - paths: ["packages/tui/src"], + path: "packages/tui/src", }, result: { content: [ @@ -160,7 +160,7 @@ export const searchFixtures: Record = { }, args: { pat: "useState($A)", - paths: ["packages/tui/src/components"], + path: "packages/tui/src/components", }, result: { content: [ diff --git a/packages/coding-agent/src/cursor.ts b/packages/coding-agent/src/cursor.ts index b63fed4dd..5865189c5 100644 --- a/packages/coding-agent/src/cursor.ts +++ b/packages/coding-agent/src/cursor.ts @@ -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; diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts index 4fbe42c17..6a16310a9 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts @@ -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, ); diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index 2d0dffe60..4dd49bd4d 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -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 || ".")); diff --git a/packages/coding-agent/src/prompts/tools/ast-grep.md b/packages/coding-agent/src/prompts/tools/ast-grep.md index fe9555ea3..17da00837 100644 --- a/packages/coding-agent/src/prompts/tools/ast-grep.md +++ b/packages/coding-agent/src/prompts/tools/ast-grep.md @@ -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` @@ -19,7 +19,7 @@ Structural code search via ast-grep. -- 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 diff --git a/packages/coding-agent/src/prompts/tools/glob.md b/packages/coding-agent/src/prompts/tools/glob.md index d9bb0a06e..a28bea663 100644 --- a/packages/coding-agent/src/prompts/tools/glob.md +++ b/packages/coding-agent/src/prompts/tools/glob.md @@ -1,7 +1,7 @@ Globs files and directories via fast pattern matching, any codebase size. -- `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. diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index 7f49be8a8..0518bd77c 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -2,7 +2,7 @@ Greps files using regex. - Rust regex (RE2-style) — no lookaround/backreferences; use line anchors or post-filters instead of (?!…)/(? diff --git a/packages/coding-agent/src/session/session-history-format.ts b/packages/coding-agent/src/session/session-history-format.ts index b0fe01579..a9c5d8f59 100644 --- a/packages/coding-agent/src/session/session-history-format.ts +++ b/packages/coding-agent/src/session/session-history-format.ts @@ -113,15 +113,19 @@ function primaryArg(name: string, args: Record | 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); diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 0bb3300b3..a492c29f0 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -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[] = [ { 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 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 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( diff --git a/packages/coding-agent/src/tools/glob.ts b/packages/coding-agent/src/tools/glob.ts index 0b5252a1f..436951fb8 100644 --- a/packages/coding-agent/src/tools/glob.ts +++ b/packages/coding-agent/src/tools/glob.ts @@ -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 { readonly examples: readonly ToolExample[] = [ { 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 { onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, ): Promise> { - 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 { 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 { // ============================================================================= 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, diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index b2e18f10b..23e4e1daf 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -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 ":" 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 ":" 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 `:` to scope a file to specific line ranges. Omitted or empty -> searches the workspace root (".")', - ), + "path?": searchPathEntry.describe( + 'file, directory, glob, internal URL, or ":" 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 { 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 _onUpdate?: AgentToolUpdateCallback, _toolContext?: AgentToolContext, ): Promise> { - 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 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 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"); diff --git a/packages/coding-agent/src/tools/path-utils.ts b/packages/coding-agent/src/tools/path-utils.ts index 8a0382f17..d4b60c849 100644 --- a/packages/coding-agent/src/tools/path-utils.ts +++ b/packages/coding-agent/src/tools/path-utils.ts @@ -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 { diff --git a/packages/coding-agent/test/edit/seen-line-guard.test.ts b/packages/coding-agent/test/edit/seen-line-guard.test.ts index d6d2ab7b8..635b7abca 100644 --- a/packages/coding-agent/test/edit/seen-line-guard.test.ts +++ b/packages/coding-agent/test/edit/seen-line-guard.test.ts @@ -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( diff --git a/packages/coding-agent/test/session/session-history-format.test.ts b/packages/coding-agent/test/session/session-history-format.test.ts index 70508a049..b744f5a7f 100644 --- a/packages/coding-agent/test/session/session-history-format.test.ts +++ b/packages/coding-agent/test/session/session-history-format.test.ts @@ -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, diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index e9e222ce8..a556a4351 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -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(); diff --git a/packages/coding-agent/test/tools/ast-grep.test.ts b/packages/coding-agent/test/tools/ast-grep.test.ts index 45cadb39d..29131d2ce 100644 --- a/packages/coding-agent/test/tools/ast-grep.test.ts +++ b/packages/coding-agent/test/tools/ast-grep.test.ts @@ -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 ?? ""; diff --git a/packages/coding-agent/test/tools/grep-internal-urls.test.ts b/packages/coding-agent/test/tools/grep-internal-urls.test.ts index 4da9a979d..417402f7c 100644 --- a/packages/coding-agent/test/tools/grep-internal-urls.test.ts +++ b/packages/coding-agent/test/tools/grep-internal-urls.test.ts @@ -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"); }); }); diff --git a/packages/coding-agent/test/tools/grep-path-lists.test.ts b/packages/coding-agent/test/tools/grep-path-lists.test.ts index 61ea3bd50..4a351bf47 100644 --- a/packages/coding-agent/test/tools/grep-path-lists.test.ts +++ b/packages/coding-agent/test/tools/grep-path-lists.test.ts @@ -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); diff --git a/packages/coding-agent/test/tools/multi-grep-path.test.ts b/packages/coding-agent/test/tools/multi-grep-path.test.ts index 00cc03ad4..02ea2cb7e 100644 --- a/packages/coding-agent/test/tools/multi-grep-path.test.ts +++ b/packages/coding-agent/test/tools/multi-grep-path.test.ts @@ -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); diff --git a/packages/coding-agent/test/tools/ssh-url-localhost-e2e.test.ts b/packages/coding-agent/test/tools/ssh-url-localhost-e2e.test.ts index eba7b9656..872140acd 100644 --- a/packages/coding-agent/test/tools/ssh-url-localhost-e2e.test.ts +++ b/packages/coding-agent/test/tools/ssh-url-localhost-e2e.test.ts @@ -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. diff --git a/packages/coding-agent/test/tools/ssh-url-ungated-tools.test.ts b/packages/coding-agent/test/tools/ssh-url-ungated-tools.test.ts index d2b7feba0..2d40bb9b7 100644 --- a/packages/coding-agent/test/tools/ssh-url-ungated-tools.test.ts +++ b/packages/coding-agent/test/tools/ssh-url-ungated-tools.test.ts @@ -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(); }); }); diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index 159dea0c1..90220468a 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -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 diff --git a/packages/collab-web/src/tool-render/tools/ast-grep.tsx b/packages/collab-web/src/tool-render/tools/ast-grep.tsx index d169ca699..76da0f204 100644 --- a/packages/collab-web/src/tool-render/tools/ast-grep.tsx +++ b/packages/collab-web/src/tool-render/tools/ast-grep.tsx @@ -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[] { @@ -11,16 +11,9 @@ function patternsOf(args: Record): string[] { return []; } -/** Current schema sends `paths: string[]`; legacy sent a single `path`. */ -function pathsOf(args: Record): 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); diff --git a/packages/collab-web/src/tool-render/tools/glob.tsx b/packages/collab-web/src/tool-render/tools/glob.tsx index a6007c18f..6cf9eb7dd 100644 --- a/packages/collab-web/src/tool-render/tools/glob.tsx +++ b/packages/collab-web/src/tool-render/tools/glob.tsx @@ -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 ; + const raw = args.path ?? args.paths; + if (raw !== undefined && typeof raw !== "string" && !Array.isArray(raw)) return ; + const globs = scopePaths(args).map(shortenPath).join(", "); return {truncate(globs || "*", 120)}; } diff --git a/packages/collab-web/src/tool-render/tools/grep.tsx b/packages/collab-web/src/tool-render/tools/grep.tsx index 7e4b890f8..1cd94679a 100644 --- a/packages/collab-web/src/tool-render/tools/grep.tsx +++ b/packages/collab-web/src/tool-render/tools/grep.tsx @@ -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[] { - 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. */ diff --git a/packages/collab-web/src/tool-render/util.ts b/packages/collab-web/src/tool-render/util.ts index 9c000b1c3..c3ca978da 100644 --- a/packages/collab-web/src/tool-render/util.ts +++ b/packages/collab-web/src/tool-render/util.ts @@ -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[] { + 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)}…`; }