mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-11-03 22:58:59 +08:00
* 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
247 lines
8.0 KiB
TypeScript
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()
|
|
}),
|
|
)
|
|
})
|