Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/cli/src/renderers/__tests__/output-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,56 @@ describe("renderToolPart", () => {
expect(text).toContain("Listing files in pochi://skills/widget-guidelines");
});

it("renders the search limit independently from the match count", () => {
const executingText = renderText({
type: "tool-searchFiles",
toolCallId: "call-1",
state: "input-available",
input: {
path: ".",
regex: "store",
limit: 20,
},
} as ToolUIPart<UITools>);
const completedText = renderText({
type: "tool-searchFiles",
toolCallId: "call-1",
state: "output-available",
input: {
path: ".",
regex: "store",
limit: 20,
},
output: {
matches: [{ file: "store.ts", line: 1, context: "const store = {};" }],
isTruncated: false,
},
} as ToolUIPart<UITools>);

expect(executingText).toContain("Searching for store in . (limit: 20)");
expect(completedText).toContain(
"Searching for store in . (limit: 20), 1 matched",
);
});

it("preserves completed search output when no limit is provided", () => {
const text = renderText({
type: "tool-searchFiles",
toolCallId: "call-1",
state: "output-available",
input: {
path: ".",
regex: "store",
},
output: {
matches: [{ file: "store.ts", line: 1, context: "const store = {};" }],
isTruncated: false,
},
} as ToolUIPart<UITools>);

expect(text).toBe("馃攳 Searching for store in .");
});

it("renders project memory paths in file operation output", () => {
const memoryPath = pochiHomePath(
"projects",
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/renderers/output-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,22 @@ export function renderToolPart(
}

if (part.type === "tool-searchFiles") {
const { regex = "", path = ".", filePattern = "" } = part.input || {};
const {
regex = "",
path = ".",
filePattern = "",
limit,
} = part.input || {};
const searchDesc = filePattern
? `${chalk.bold(regex)} in ${chalk.bold(filePattern)} files`
: `${chalk.bold(regex)}`;
const limitDesc = limit !== undefined ? ` (limit: ${limit})` : "";
const matchDesc =
limit !== undefined && part.state === "output-available" && !hasError
? `, ${part.output.matches.length} matched`
: "";
return {
text: `馃攳 Searching for ${searchDesc} in ${formatCliDisplayPath(path)}`,
text: `馃攳 Searching for ${searchDesc} in ${formatCliDisplayPath(path)}${limitDesc}${matchDesc}`,
stop: hasError ? "fail" : "succeed",
error: errorText,
};
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/tools/search-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ const logger = getLogger("searchFiles");

export const searchFiles =
(context: ToolCallOptions): ToolFunctionType<ClientTools["searchFiles"]> =>
async ({ path, regex, filePattern }, { abortSignal, cwd }) => {
async (
{ path, regex, filePattern, limit, case_sensitive: caseSensitive },
{ abortSignal, cwd },
) => {
const rgPath = context.rg;
if (!rgPath || !fs.existsSync(rgPath)) {
logger.error("Ripgrep not found at path", rgPath);
Expand All @@ -20,6 +23,8 @@ export const searchFiles =
rgPath,
cwd,
filePattern,
limit,
caseSensitive,
abortSignal,
);
};
77 changes: 76 additions & 1 deletion packages/common/src/tool-utils/__tests__/ripgrep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe("searchFilesWithRipgrep", () => {

const baseArgs = [
"--json",
"--case-sensitive",
"--ignore-case",
"--binary",
"--sortr",
"modified",
Expand Down Expand Up @@ -174,6 +174,81 @@ describe("searchFilesWithRipgrep", () => {
);
});

it("should search case-insensitively by default", async () => {
mockSpawnResult({ stdout: "" });

await searchFilesWithRipgrep(".", "hello", rgPath, workspacePath);

expect(spawnMock).toHaveBeenCalledWith(
rgPath,
[
"--json",
"--ignore-case",
"--binary",
"--sortr",
"modified",
"hello",
workspacePath,
],
{ signal: undefined },
);
});

it("should search case-sensitively when requested", async () => {
mockSpawnResult({ stdout: "" });

await searchFilesWithRipgrep(
".",
"hello",
rgPath,
workspacePath,
undefined,
undefined,
true,
);

expect(spawnMock).toHaveBeenCalledWith(
rgPath,
[
"--json",
"--case-sensitive",
"--binary",
"--sortr",
"modified",
"hello",
workspacePath,
],
{ signal: undefined },
);
});

it("should stop at an explicit limit without marking the result truncated", async () => {
const mockRgOutput = Array.from({ length: 3 }, (_, i) => ({
type: "match",
data: {
path: { text: join(workspacePath, `file${i}.ts`) },
lines: { text: `line ${i}\n` },
line_number: i + 1,
},
}))
.map((o) => JSON.stringify(o))
.join("\n");
const child = mockSpawnResult({ stdout: mockRgOutput });

const result = await searchFilesWithRipgrep(
".",
"hello",
rgPath,
workspacePath,
undefined,
2,
);

expect(result.matches).toHaveLength(2);
expect(result.isTruncated).toBe(false);
expect(child.kill).toHaveBeenCalled();
});

it("should stop rg as soon as the global match limit is exceeded", async () => {
const mockRgOutput = Array.from({ length: 501 }, (_, i) => ({
type: "match",
Expand Down
21 changes: 14 additions & 7 deletions packages/common/src/tool-utils/ripgrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,16 @@ export async function searchFilesWithRipgrep(
rgPath: string,
workspacePath: string,
filePattern?: string,
limit?: number,
caseSensitive = false,
abortSignal?: AbortSignal,
): Promise<{
matches: RipgrepMatch[];
isTruncated: boolean;
}> {
logger.debug("searchFiles", path, regex, filePattern);
logger.debug("searchFiles", path, regex, filePattern, limit, caseSensitive);
const matches: RipgrepMatch[] = [];
const matchLimit = limit ?? MaxRipgrepItems;
let isTruncated = false;
let serializedLength = JSON.stringify({
matches: [],
Expand All @@ -137,11 +140,10 @@ export async function searchFilesWithRipgrep(
// quoting with single quotes breaks on Windows because the default shell
// (cmd.exe) does not strip single quotes, so rg would receive literal
// quotes around the path and fail with "path not found".
// - --case-sensitive matches the original implementation's RegExp usage.
// - --binary skips binary files, similar to the original file-type check.
const args = [
"--json",
"--case-sensitive",
caseSensitive ? "--case-sensitive" : "--ignore-case",
"--binary",
"--sortr",
"modified",
Expand Down Expand Up @@ -177,8 +179,8 @@ export async function searchFilesWithRipgrep(
return;
}

if (matches.length >= MaxRipgrepItems) {
isTruncated = true;
if (matches.length >= matchLimit) {
isTruncated = limit === undefined;
stopAfterLimit();
return;
}
Expand All @@ -191,15 +193,20 @@ export async function searchFilesWithRipgrep(
remainingLength,
);
if (!fittedMatch) {
isTruncated = true;
isTruncated = limit === undefined;
stopAfterLimit();
return;
}

matches.push(fittedMatch);
serializedLength += separatorLength + JSON.stringify(fittedMatch).length;
if (fittedMatch !== match) {
isTruncated = true;
isTruncated = limit === undefined;
stopAfterLimit();
return;
}

if (limit !== undefined && matches.length >= matchLimit) {
stopAfterLimit();
}
};
Expand Down
51 changes: 51 additions & 0 deletions packages/tools/src/__test__/search-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { searchFiles } from "../search-files";

const inputSchema = searchFiles.inputSchema as z.ZodType;

describe("searchFiles", () => {
it.each([true, false])("accepts case_sensitive %p", (caseSensitive) => {
expect(
inputSchema.parse({
path: ".",
regex: "hello",
case_sensitive: caseSensitive,
}),
).toMatchObject({ case_sensitive: caseSensitive });
});

it("rejects string values for case_sensitive", () => {
expect(() =>
inputSchema.parse({
path: ".",
regex: "hello",
case_sensitive: "false",
}),
).toThrow();
});

it("accepts a positive integer limit", () => {
expect(
inputSchema.parse({ path: ".", regex: "hello", limit: 10 }),
).toMatchObject({ limit: 10 });
expect(() =>
inputSchema.parse({ path: ".", regex: "hello", limit: 0 }),
).toThrow();
});

it("exposes limit and case_sensitive in the JSON schema", () => {
const properties = inputSchema.toJSONSchema().properties;

expect(properties).toHaveProperty("limit");
expect(properties).toHaveProperty("case_sensitive");
});

it("explains that limit is optional because the host enforces a safety limit", () => {
const properties = inputSchema.toJSONSchema().properties;

expect(properties?.limit?.description).toBe(
"Limit output to the first N matching lines. The host already applies an internal safety limit when omitted, so only specify this when a smaller result set is necessary.",
);
});
});
14 changes: 14 additions & 0 deletions packages/tools/src/search-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ const toolDef = {
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}"). If not provided, it will search all files.',
),
limit: z
.number()
.int()
.positive()
.optional()
.describe(
"Limit output to the first N matching lines. The host already applies an internal safety limit when omitted, so only specify this when a smaller result set is necessary.",
),
case_sensitive: z

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case_sensitive: z
caseSensitive: z

.boolean()
.optional()
.describe(
"Make the match case-sensitive. Defaults to false (case-insensitive).",
),
}),
outputSchema: z.object({
matches: z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,55 @@ describe("tool path display", () => {
expect(visibleText(container)).not.toContain("/.pochi/projects/");
});

it("shows the search limit independently from the match count", () => {
const { container, rerender } = render(
<SearchFilesTool
tool={{
type: "tool-searchFiles",
toolCallId: "call-1",
state: "input-available",
input: {
path: ".",
regex: "store",
limit: 20,
},
}}
isExecuting={true}
isLoading={false}
messages={[]}
/>,
);

expect(visibleText(container)).toContain("(limit: 20)");

rerender(
<SearchFilesTool
tool={{
type: "tool-searchFiles",
toolCallId: "call-1",
state: "output-available",
input: {
path: ".",
regex: "store",
limit: 20,
},
output: {
matches: [
{ file: "store.ts", line: 1, context: "const store = {};" },
],
isTruncated: false,
},
}}
isExecuting={false}
isLoading={false}
messages={[]}
/>,
);

expect(visibleText(container)).toContain("(limit: 20)");
expect(visibleText(container)).toContain("1 toolInvocation.matched");
});

it("shortens project memory paths in globFiles titles", () => {
const { container } = render(
<GlobFilesTool
Expand Down
Loading
Loading