Files
kilocode/packages/opencode/test/tool/grep.test.ts
T
Marius 989f7f06a0 feat: add signal-to-noise controls to grep tool (#12811)
* feat: add signal-to-noise controls to grep tool

Add configurable options to reduce noise in grep results:
- context: show N lines before/after each match
- limit: bound maximum matches (default 100) with early termination
- literal: treat pattern as plain text instead of regex
- ignoreCase: case-insensitive matching

These controls help models avoid overwhelming context windows with too
many matches and provide clear guidance when results are truncated.

Implementation preserves upstream ripgrep structure with minimal Kilo
hooks for additive behavior only. Shared-file changes reduced from 183
to 60 lines compared to initial implementation.

* test(core): tolerate PTY event publication race

* fix(cli): count grep matches independently of context
2026-08-03 10:32:40 -04:00

247 lines
8.0 KiB
TypeScript

import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect, Layer } from "effect"
import { GrepTool } from "../../src/tool/grep"
import { provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect"
import { Permission } from "../../src/permission"
import type * as Tool from "../../src/tool/tool"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { Filesystem } from "@/util/filesystem"
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
FSUtil.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
Git.defaultLayer,
)
const it = testEffect(toolLayer())
const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer))
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "code", // kilocode_change
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const root = path.join(__dirname, "../..")
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.KILO_REPO_CLONE_GITHUB_BASE_URL
process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = url
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous) process.env.KILO_REPO_CLONE_GITHUB_BASE_URL = previous
else delete process.env.KILO_REPO_CLONE_GITHUB_BASE_URL
}),
)
const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[]) {
return yield* Effect.promise(async () => {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
return stdout.trim()
})
})
describe("tool.grep", () => {
rooted.live("basic search", () =>
Effect.gen(function* () {
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* provideInstance(root)(
grep.execute(
{
pattern: "export",
path: path.join(root, "src/tool"),
include: "*.ts",
},
ctx,
),
)
expect(result.metadata.matches).toBeGreaterThan(0)
expect(result.output).toContain("Found")
}),
)
it.instance("no matches returns correct output", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
path: test.directory,
},
ctx,
)
expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found")
}),
)
it.instance("finds matches in tmp instance", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line",
path: test.directory,
},
ctx,
)
expect(result.metadata.matches).toBeGreaterThan(0)
}),
)
it.instance("does not report an unknown total when results are truncated", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Promise.all(
Array.from({ length: 101 }, (_, index) =>
Bun.write(path.join(test.directory, `match-${index}.txt`), "needle"),
),
),
)
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.txt" }, ctx)
expect(result.output).toContain("100 matches limit reached. Use limit=200 for more, or refine pattern.") // kilocode_change
expect(result.output).not.toMatch(/showing \d+ of \d+ matches/)
}),
)
it.instance("supports exact file paths", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "test.txt")
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line2",
path: file,
},
ctx,
)
expect(result.metadata.matches).toBe(1)
expect(result.output).toContain(file)
expect(result.output).toContain("Line 2: line2")
}),
)
// kilocode_change start - exact-file searches must not widen to siblings
it.instance("returns no matches for a missing exact file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "sibling.txt"), "needle"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "needle",
path: path.join(test.directory, "missing.txt"),
},
ctx,
)
expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found")
}),
)
// kilocode_change end
it.instance("does not ask for external_directory when alias path is allowed", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
yield* TestInstance
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-grep-alias-"))),
(dir) => Effect.promise(() => fs.rm(dir, { recursive: true, force: true })),
)
const real = path.join(tmp, "real")
const alias = path.join(tmp, "alias")
yield* Effect.promise(() => fs.mkdir(real))
yield* Effect.promise(() => fs.symlink(real, alias, "dir"))
yield* Effect.promise(() => Bun.write(path.join(real, "test.txt"), "needle"))
const ruleset = Permission.fromConfig({
grep: "allow",
external_directory: {
[path.join(alias, "*")]: "allow",
},
})
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const next: Tool.Context = {
...ctx,
ask: (req) =>
Effect.sync(() => {
const needsAsk = req.patterns.some(
(pattern) => Permission.evaluate(req.permission, pattern, ruleset).action !== "allow",
)
if (needsAsk) requests.push(req)
}),
}
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "needle",
path: alias,
include: "*.txt",
},
next,
)
expect(result.metadata.matches).toBe(1)
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
}),
)
})