mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): take grep context lines from ripgrep instead of reading matched files (#11705)
When `context` is set, the grep tool read every matched file whole and split it into lines, only to print the lines around each match. Memory grew with the size of the matched files, not the output. In the issue's reproduction (8 log files of about 95 MB, one match each, `context: 2`, `--max-old-space-size=1024`), the process ran out of heap after about 10 s. ripgrep already supports context. This change passes `--context N` to rg and formats the `context` events from its JSON output. The tool no longer opens matched files. Details: - After the last allowed match (`limit`), rg still sends that match's trailing context. The tool keeps those lines, then stops rg. A match inside that window is shown as a context line, as before. - Lines past the 50 KB output cap are counted but not stored, since truncation drops them anyway. Match counts, the match-limit notice, and `details.matchLimitReached` stay as before. - rg merges overlapping context, so lines shared by nearby matches are printed once instead of repeated per match. This is the one intended output change. - Context lines are decoded by rg, so UTF-16 files with a BOM show correctly with context (#11423, closed as no-action). Non-UTF-8 lines and paths, which rg sends base64-encoded, are decoded too. Two related grep fixes in the same code: - **Exact match limit:** with exactly N matches and `limit: N`, grep said "N matches limit reached. Use limit=2N". It now sets the notice and `details.matchLimitReached` only after rg sends one more match. Cost: when no further match exists, rg finishes the search instead of being stopped at the Nth match, the same as a search with fewer than N matches. - **Lone CR:** grep removed every `\r` in a line, so `one\rtwo needle` showed as `onetwo needle` and text copied from grep failed to match in edit. It now strips only the line terminator (`\n` or `\r\n`), like the read tool. The TUI still hides CRs when rendering. This updates the expectation in the #11398 test from `beforeprogress` to `before\rprogress`. Breaking: `GrepOperations.readFile` is removed because nothing uses it anymore. rg always ran locally, so this hook never made grep work on a remote backend. Docs now say to override `execute()` for remote search, as the Gondolin example does. Code that passes `readFile` still runs, and the value is ignored. Only code that type-checks an object literal against `GrepOperations` fails to compile. Known remaining differences and limits: - Memory no longer grows with file size or match count, but a single huge matching line (tens of MB) is still buffered whole before being cut to 500 characters. That was already true for `context: 0` on main. Bounding it would mean discarding oversized rg JSON records, which is a separate change. - In a directory search, rg stops reading a file once it finds a NUL byte, so context past that point in a text-looking file that turns binary later is omitted. main reread the file and printed it. Explicit file paths are unaffected. - `details.truncation.totalLines`/`totalBytes` count formatted rows. With deduplicated context, or file names containing newlines, they can differ from main. No renderer or notice displays them. **Testing** - Issue reproduction: before, `FATAL ERROR ... JavaScript heap out of memory`. After, correct output in about 60 ms with 135 MB RSS. - `pattern: "line", context: 3, limit: 100000` over the same 770 MB of logs reports both limit notices and peaks at 154 MB RSS. - New tests in `test/tools.test.ts`: - Merged overlapping context, plus trailing context at the limit including a match inside it. Fails on main. - UTF-16 with context. Fails on main. - Truncation reported when stored output ends exactly at 50 KB. Guards the counting logic. - Matches still counted past the byte cap. Passes on main too; it guards against stopping rg early at the cap. - Huge `context` value accepted. - No limit notice with exactly `limit` matches (with and without context), notice with more. Fails before this change. - Lone CR kept and CRLF stripped, with and without context. Fails before this change. - All 94 tests in the file pass. `npm run check` passes. fixes #9276 This PR is AI-generated.
This commit is contained in:
parent
2bd2c2d0a6
commit
8da141c2ad
4 changed files with 215 additions and 69 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `readFile` from `GrepOperations`. The grep tool now takes context lines from ripgrep and no longer reads matched files itself. To search a remote system, override grep's `execute()`; see [Remote Execution](docs/extensions.md#remote-execution) ([#9276](https://github.com/earendil-works/pi/issues/9276))
|
||||
|
||||
### Added
|
||||
|
||||
- `pi update` now lists each updated package with its npm version or git commit change, for example `npm:foo 1.2.0 -> 1.3.0`, and prints `Packages are already up to date` when nothing changed. When nothing changed but an update printed a warning, for example because npm left the old version installed after resolving stale registry metadata, it prints `No packages were updated, see the warnings above` instead; in offline mode it prints `Skipped package updates (offline mode)`. `PackageManager.update()` reports each changed package as a new `result` progress event whose `message` holds that line ([#11707](https://github.com/earendil-works/pi/pull/11707), [#11711](https://github.com/earendil-works/pi/pull/11711))
|
||||
|
|
@ -14,6 +18,7 @@
|
|||
- Fixed RPC mode ignoring an extension's `ctx.shutdown()` while idle, for example from a timer: the process kept running until the client sent another command. pi now shuts down right away when idle, and a shutdown requested by an extension command waits until the command's `prompt` response is written ([#11599](https://github.com/earendil-works/pi/issues/11599))
|
||||
- Fixed changelog notes repeating on every fresh launch when the installed package version has a prerelease suffix, such as `0.1.5-20261020.7`: the saved version was read as `0.1.0`, so the `0.1.1` to `0.1.5` notes counted as new each time. A saved version without `major.minor.patch` now shows no notes and is replaced by the installed version, instead of showing the whole changelog ([#11703](https://github.com/earendil-works/pi/issues/11703))
|
||||
- Fixed RPC mode cutting off shutdown cleanup, such as a pending MCP token refresh, when stdin closed and SIGTERM arrived together, which process managers like Paseo and systemd do. Pi lost the refreshed token and the MCP server revoked the sign-in. Once a shutdown has started, a stdin EOF or a single signal no longer interrupts it; a second signal still exits at once. Print mode likewise no longer dies from a signal that arrives during its final cleanup ([#11599](https://github.com/earendil-works/pi/issues/11599))
|
||||
- Fixed the grep tool reading every matched file into memory when `context` was set, which ran out of memory on large files; context now comes from ripgrep, which also fixes UTF-16 files and lone CRs in matched lines, and the match limit notice no longer appears when exactly `limit` matches exist ([#9276](https://github.com/earendil-works/pi/issues/9276), [#11423](https://github.com/earendil-works/pi/issues/11423))
|
||||
|
||||
|
||||
## [1.5.0] - 2026-11-02
|
||||
|
|
|
|||
|
|
@ -2418,6 +2418,8 @@ The `create*ToolDefinition()` factories, such as `createReadToolDefinition()`, r
|
|||
|
||||
**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `PowerShellOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
|
||||
|
||||
`GrepOperations` only covers the directory check: grep always runs the local `rg`. To search a remote system, override grep's `execute()` instead, as the [Gondolin example](../examples/extensions/gondolin/index.ts) does.
|
||||
|
||||
For `user_bash`, extensions can reuse Pi's local shell backend via `createLocalBashOperations()` (or `createLocalPowerShellOperations()`) instead of reimplementing local process spawning, shell resolution, and process-tree termination.
|
||||
|
||||
The `bash` and `powershell` tools also support a spawn hook to adjust the command, cwd, or env before execution:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
||||
import { stat as fsStat } from "node:fs/promises";
|
||||
import { createInterface } from "node:readline";
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
|
|
@ -47,20 +47,24 @@ export interface GrepToolDetails {
|
|||
|
||||
/**
|
||||
* Pluggable operations for the grep tool.
|
||||
* Override these to delegate search to remote systems (for example SSH).
|
||||
* The search itself always runs the local ripgrep; to search a remote system, override the tool's execute().
|
||||
*/
|
||||
export interface GrepOperations {
|
||||
/** Check if path is a directory. Throws if path does not exist. */
|
||||
isDirectory: (absolutePath: string) => Promise<boolean> | boolean;
|
||||
/** Read file contents for context lines */
|
||||
readFile: (absolutePath: string) => Promise<string> | string;
|
||||
}
|
||||
|
||||
const defaultGrepOperations: GrepOperations = {
|
||||
isDirectory: async (p) => (await fsStat(p)).isDirectory(),
|
||||
readFile: (p) => fsReadFile(p, "utf-8"),
|
||||
};
|
||||
|
||||
/** Decode a ripgrep JSON text field (path or line). Non-UTF-8 data arrives base64-encoded in `bytes`. */
|
||||
function decodeRgText(data: { text?: string; bytes?: string } | undefined): string {
|
||||
if (typeof data?.text === "string") return data.text;
|
||||
if (typeof data?.bytes === "string") return Buffer.from(data.bytes, "base64").toString("utf-8");
|
||||
return "";
|
||||
}
|
||||
|
||||
export interface GrepToolOptions {
|
||||
/** Custom operations for grep. Default: local filesystem plus ripgrep */
|
||||
operations?: GrepOperations;
|
||||
|
|
@ -131,8 +135,10 @@ export function createGrepToolDefinition(
|
|||
return;
|
||||
}
|
||||
|
||||
const contextValue = context && context > 0 ? context : 0;
|
||||
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
|
||||
// Clamp so String() never yields exponent notation, which rg rejects.
|
||||
const contextValue =
|
||||
context && context > 0 ? Math.min(Math.floor(context), Number.MAX_SAFE_INTEGER) : 0;
|
||||
const effectiveLimit = Math.max(1, Math.floor(limit ?? DEFAULT_LIMIT));
|
||||
const formatPath = (filePath: string): string => {
|
||||
if (isDirectory) {
|
||||
const relative = path.relative(searchPath, filePath);
|
||||
|
|
@ -143,27 +149,14 @@ export function createGrepToolDefinition(
|
|||
return path.basename(filePath);
|
||||
};
|
||||
|
||||
const fileCache = new Map<string, string[]>();
|
||||
const getFileLines = async (filePath: string): Promise<string[]> => {
|
||||
let lines = fileCache.get(filePath);
|
||||
if (!lines) {
|
||||
try {
|
||||
const content = await ops.readFile(filePath);
|
||||
lines = content.replace(/\r\n/g, "\n").split("\n");
|
||||
} catch {
|
||||
lines = [];
|
||||
}
|
||||
fileCache.set(filePath, lines);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
// --no-config: the tool contract (literal, glob, limit, ...) must not depend on
|
||||
// ambient user config like RIPGREP_CONFIG_PATH. Users can customize via bash or extensions.
|
||||
const args: string[] = ["--no-config", "--json", "--line-number", "--color=never", "--hidden"];
|
||||
if (ignoreCase) args.push("--ignore-case");
|
||||
if (literal) args.push("--fixed-strings");
|
||||
if (glob) args.push("--glob", glob);
|
||||
// rg emits the surrounding lines as `context` events, so no matched file is ever read here (#9276).
|
||||
if (contextValue > 0) args.push("--context", String(contextValue));
|
||||
args.push("--", pattern, searchPath);
|
||||
|
||||
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
|
@ -195,55 +188,74 @@ export function createGrepToolDefinition(
|
|||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const formatBlock = async (filePath: string, lineNumber: number): Promise<string[]> => {
|
||||
// Size of the full output, including lines past the byte cap that are counted but not stored.
|
||||
let outputBytes = 0;
|
||||
let outputLineCount = 0;
|
||||
let lastFile: string | undefined;
|
||||
// Set at the last allowed match. rg still sends that match's trailing context, kept up to
|
||||
// trailingContextEnd, and the limit notice waits for proof of one more match.
|
||||
let limitHit = false;
|
||||
let trailingContextEnd = 0;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
done = true;
|
||||
stopChild(true);
|
||||
};
|
||||
const pushLine = (filePath: string, lineNumber: number, lineText: string, isMatch: boolean) => {
|
||||
lastFile = filePath;
|
||||
const relativePath = formatPath(filePath);
|
||||
const lines = await getFileLines(filePath);
|
||||
if (!lines.length) return [`${relativePath}:${lineNumber}: (unable to read file)`];
|
||||
const block: string[] = [];
|
||||
const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
|
||||
const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
|
||||
for (let current = start; current <= end; current++) {
|
||||
const lineText = lines[current - 1] ?? "";
|
||||
const sanitized = lineText.replace(/\r/g, "");
|
||||
const isMatchLine = current === lineNumber;
|
||||
// Truncate long lines so grep output stays compact.
|
||||
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
|
||||
if (wasTruncated) linesTruncated = true;
|
||||
if (isMatchLine) block.push(`${relativePath}:${current}: ${truncatedText}`);
|
||||
else block.push(`${relativePath}-${current}- ${truncatedText}`);
|
||||
}
|
||||
return block;
|
||||
// Strip only the line terminator: a lone CR inside a line is content, as in the read tool.
|
||||
const sanitized = lineText.replace(/\r?\n?$/, "");
|
||||
// Truncate long lines so grep output stays compact.
|
||||
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
|
||||
if (wasTruncated) linesTruncated = true;
|
||||
const line = isMatch
|
||||
? `${relativePath}:${lineNumber}: ${truncatedText}`
|
||||
: `${relativePath}-${lineNumber}- ${truncatedText}`;
|
||||
// Byte truncation below drops everything past the cap, so keep counting but stop storing (#9276).
|
||||
// outputBytes counts a newline after every line; the joined output has one fewer.
|
||||
if (outputBytes - 1 <= DEFAULT_MAX_BYTES) outputLines.push(line);
|
||||
outputBytes += Buffer.byteLength(line, "utf-8") + 1;
|
||||
outputLineCount++;
|
||||
};
|
||||
|
||||
// Collect matches during streaming, then format them after rg exits.
|
||||
const matches: Array<{ filePath: string; lineNumber: number; lineText?: string }> = [];
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim() || matchCount >= effectiveLimit) return;
|
||||
if (done || !line.trim()) return;
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (event.type === "match") {
|
||||
const isMatch = event.type === "match";
|
||||
if (!isMatch && event.type !== "context") return;
|
||||
const filePath = decodeRgText(event.data?.path);
|
||||
const lineNumber = event.data?.line_number;
|
||||
if (!filePath || typeof lineNumber !== "number") return;
|
||||
const lineText = decodeRgText(event.data?.lines);
|
||||
if (!limitHit) {
|
||||
pushLine(filePath, lineNumber, lineText, isMatch);
|
||||
if (!isMatch) return;
|
||||
matchCount++;
|
||||
const filePath = event.data?.path?.text;
|
||||
const lineNumber = event.data?.line_number;
|
||||
const lineText = event.data?.lines?.text;
|
||||
if (filePath && typeof lineNumber === "number")
|
||||
matches.push({ filePath, lineNumber, lineText });
|
||||
if (matchCount >= effectiveLimit) {
|
||||
matchLimitReached = true;
|
||||
stopChild(true);
|
||||
limitHit = true;
|
||||
trailingContextEnd = lineNumber + contextValue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const inWindow = filePath === lastFile && lineNumber <= trailingContextEnd;
|
||||
// A match inside the last match's trailing context is shown as context.
|
||||
if (inWindow) pushLine(filePath, lineNumber, lineText, false);
|
||||
// Only another match proves that the limit cut results off.
|
||||
if (isMatch) matchLimitReached = true;
|
||||
if (matchLimitReached && !(inWindow && lineNumber < trailingContextEnd)) finish();
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
cleanup();
|
||||
settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
|
||||
});
|
||||
child.on("close", async (code) => {
|
||||
child.on("close", (code) => {
|
||||
cleanup();
|
||||
if (aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
|
|
@ -261,26 +273,11 @@ export function createGrepToolDefinition(
|
|||
return;
|
||||
}
|
||||
|
||||
// Format matches after streaming finishes so custom readFile() backends can be async.
|
||||
for (const match of matches) {
|
||||
if (contextValue === 0 && match.lineText !== undefined) {
|
||||
const relativePath = formatPath(match.filePath);
|
||||
const sanitized = match.lineText
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "")
|
||||
.replace(/\n$/, "");
|
||||
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
|
||||
if (wasTruncated) linesTruncated = true;
|
||||
outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`);
|
||||
} else {
|
||||
const block = await formatBlock(match.filePath, match.lineNumber);
|
||||
outputLines.push(...block);
|
||||
}
|
||||
}
|
||||
|
||||
const rawOutput = outputLines.join("\n");
|
||||
// Apply byte truncation. There is no line limit here because the match limit already capped rows.
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
truncation.totalLines = outputLineCount;
|
||||
truncation.totalBytes = outputBytes - 1;
|
||||
let output = truncation.content;
|
||||
const details: GrepToolDetails = {};
|
||||
// Build actionable notices for truncation and match limits.
|
||||
|
|
|
|||
|
|
@ -920,11 +920,153 @@ describe("Coding Agent Tools", () => {
|
|||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("lone-cr.txt-1- beforeprogress");
|
||||
// A lone CR inside a line is kept, as the read tool shows it, so the text can be copied into edit.
|
||||
expect(output).toContain("lone-cr.txt-1- before\rprogress");
|
||||
expect(output).toContain("lone-cr.txt:2: needle");
|
||||
expect(output).toContain("lone-cr.txt-3- after");
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/9276
|
||||
it("should print overlapping context once and keep the last match's trailing context", async () => {
|
||||
const testFile = join(testDir, "rg-context.txt");
|
||||
writeFileSync(testFile, ["a", "match one", "b", "match two", "match three", "c", "match four"].join("\n"));
|
||||
|
||||
const result = await grepTool.execute("test-call-rg-context", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
context: 1,
|
||||
limit: 2,
|
||||
});
|
||||
expect(getTextOutput(result).split("\n\n")[0]).toBe(
|
||||
[
|
||||
"rg-context.txt-1- a",
|
||||
"rg-context.txt:2: match one",
|
||||
"rg-context.txt-3- b",
|
||||
"rg-context.txt:4: match two",
|
||||
// A match inside the last allowed match's trailing context is shown as context.
|
||||
"rg-context.txt-5- match three",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/9276
|
||||
it("should keep counting matches past the byte cap", async () => {
|
||||
const testFile = join(testDir, "byte-cap.txt");
|
||||
const line = `match ${"x".repeat(400)}`;
|
||||
writeFileSync(testFile, Array.from({ length: 200 }, () => line).join("\n"));
|
||||
|
||||
const result = await grepTool.execute("test-call-byte-cap", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
limit: 150,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("150 matches limit reached");
|
||||
expect(output).toContain("50.0KB limit reached");
|
||||
expect(result.details?.matchLimitReached).toBe(150);
|
||||
expect(result.details?.truncation?.totalLines).toBe(150);
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/9276
|
||||
it("should report truncation when stored output ends exactly at the byte cap", async () => {
|
||||
const testFile = join(testDir, "x");
|
||||
// 103 rows of 500 bytes plus one of 12 bytes, with newlines, join to exactly 50KB; one more match follows.
|
||||
writeFileSync(testFile, [...Array(103).fill("x".repeat(490)), "x".repeat(7), "extra x"].join("\n"));
|
||||
|
||||
const result = await grepTool.execute("test-call-byte-cap-boundary", {
|
||||
pattern: "x",
|
||||
path: testFile,
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("[50.0KB limit reached]");
|
||||
expect(result.details?.truncation?.totalLines).toBe(105);
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/9276
|
||||
it("should accept a huge context value", async () => {
|
||||
const testFile = join(testDir, "huge-context.txt");
|
||||
writeFileSync(testFile, "before\nneedle\nafter");
|
||||
|
||||
const result = await grepTool.execute("test-call-huge-context", {
|
||||
pattern: "needle",
|
||||
path: testFile,
|
||||
context: 1e30,
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toBe(
|
||||
["huge-context.txt-1- before", "huge-context.txt:2: needle", "huge-context.txt-3- after"].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
// Context lines come from ripgrep, which decodes UTF-16; rereading the file as UTF-8 corrupted them (#11423).
|
||||
it("should decode UTF-16 files with context", async () => {
|
||||
const testFile = join(testDir, "utf16.txt");
|
||||
writeFileSync(testFile, Buffer.from("\uFEFFbefore\nneedle 中文\nafter\n", "utf16le"));
|
||||
|
||||
const result = await grepTool.execute("test-call-utf16-context", {
|
||||
pattern: "needle",
|
||||
path: testFile,
|
||||
context: 1,
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toBe(
|
||||
["utf16.txt-1- before", "utf16.txt:2: needle 中文", "utf16.txt-3- after"].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep a lone CR inside a line and strip CRLF terminators", async () => {
|
||||
const testFile = join(testDir, "mixed-cr.txt");
|
||||
writeFileSync(testFile, "one\rtwo needle\r\nnext\r\n");
|
||||
|
||||
const plain = await grepTool.execute("test-call-cr-plain", { pattern: "needle", path: testFile });
|
||||
expect(getTextOutput(plain)).toBe("mixed-cr.txt:1: one\rtwo needle");
|
||||
|
||||
const withContext = await grepTool.execute("test-call-cr-context", {
|
||||
pattern: "needle",
|
||||
path: testFile,
|
||||
context: 1,
|
||||
});
|
||||
expect(getTextOutput(withContext)).toBe(
|
||||
["mixed-cr.txt:1: one\rtwo needle", "mixed-cr.txt-2- next"].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not report the match limit when exactly limit matches exist", async () => {
|
||||
const testFile = join(testDir, "exact-limit.txt");
|
||||
writeFileSync(testFile, ["match one", "x", "match two", "y"].join("\n"));
|
||||
|
||||
for (const context of [0, 1]) {
|
||||
const exact = await grepTool.execute("test-call-exact-limit", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
limit: 2,
|
||||
context,
|
||||
});
|
||||
expect(getTextOutput(exact)).not.toContain("limit reached");
|
||||
expect(exact.details?.matchLimitReached).toBeUndefined();
|
||||
|
||||
const over = await grepTool.execute("test-call-over-limit", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
limit: 1,
|
||||
context,
|
||||
});
|
||||
expect(getTextOutput(over)).toContain("[1 matches limit reached. Use limit=2 for more, or refine pattern]");
|
||||
expect(over.details?.matchLimitReached).toBe(1);
|
||||
}
|
||||
|
||||
const fractional = await grepTool.execute("test-call-fractional-limit", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
limit: 1.5,
|
||||
});
|
||||
expect(getTextOutput(fractional)).toContain(
|
||||
"[1 matches limit reached. Use limit=2 for more, or refine pattern]",
|
||||
);
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/11203
|
||||
it("should ignore RIPGREP_CONFIG_PATH", async () => {
|
||||
const configFile = join(testDir, "rg-config");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue