| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- import { describe, expect } from "bun:test"
- import fs from "fs/promises"
- import path from "path"
- import { Effect, Layer } from "effect"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
- import { LayerNode } from "@opencode-ai/util/effect/layer-node"
- import { FileSystem } from "@opencode-ai/core/filesystem"
- import { FSUtil } from "@opencode-ai/util/fs-util"
- import { Location } from "@opencode-ai/core/location"
- import { LocationMutation } from "@opencode-ai/core/location-mutation"
- import { Permission } from "@opencode-ai/core/permission"
- import { Ripgrep } from "@opencode-ai/core/ripgrep"
- import { AbsolutePath } from "@opencode-ai/core/schema"
- import { Session } from "@opencode-ai/core/session"
- import { GlobTool } from "@opencode-ai/core/tool/plugin/glob"
- import { GrepTool } from "@opencode-ai/core/tool/plugin/grep"
- import { Tool } from "@opencode-ai/core/tool"
- import { location } from "./fixture/location"
- import { tmpdir } from "./fixture/tmpdir"
- import { testEffect } from "./lib/effect"
- import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
- const globToolNode = makeLocationNode({
- name: "test/glob-tool-plugin",
- layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
- deps: [
- Tool.node,
- FSUtil.node,
- Ripgrep.node,
- Location.node,
- LocationMutation.node,
- Permission.node,
- ],
- })
- const grepToolNode = makeLocationNode({
- name: "test/grep-tool-plugin",
- layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
- deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
- })
- const sessionID = Session.ID.make("ses_search_tool_test")
- const withTools = <A, E, R>(
- directory: string,
- body: (registry: Tool.Interface) => Effect.Effect<A, E, R>,
- assertions?: Permission.AssertInput[],
- ) =>
- Effect.gen(function* () {
- return yield* body(yield* Tool.Service)
- }).pipe(
- Effect.provide(
- AppNodeBuilder.build(LayerNode.group([Tool.node, globToolNode, grepToolNode]), [
- [
- Location.node,
- Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
- ],
- [
- Permission.node,
- Layer.succeed(
- Permission.Service,
- Permission.Service.of({
- assert: (input) =>
- Effect.sync(() => {
- assertions?.push(input)
- }),
- ask: () => Effect.die("unused"),
- reply: () => Effect.die("unused"),
- get: () => Effect.die("unused"),
- forSession: () => Effect.die("unused"),
- list: () => Effect.die("unused"),
- }),
- ),
- ],
- ]),
- ),
- )
- const call = (name: "glob" | "grep", input: unknown) => ({
- sessionID,
- ...toolIdentity,
- call: { type: "tool-call" as const, id: `call-${name}`, name, input },
- })
- const it = testEffect(Layer.empty)
- describe("search tools", () => {
- it.live("bounds omitted glob and grep limits", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.gen(function* () {
- yield* Effect.promise(() =>
- Promise.all(
- Array.from({ length: FileSystem.DEFAULT_SEARCH_LIMIT + 1 }, (_, index) =>
- fs.writeFile(path.join(tmp.path, `${index}.txt`), "needle\n"),
- ),
- ),
- )
- yield* withTools(tmp.path, (registry) =>
- Effect.gen(function* () {
- const glob = yield* executeTool(registry, call("glob", { pattern: "*" }))
- const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
- expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
- expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
- expect(glob.content).toHaveLength(1)
- expect(grep.content).toHaveLength(1)
- const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
- const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : ""
- expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT + 2)
- expect(globText).toEndWith(
- `(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
- )
- expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
- expect(grepText).toEndWith(
- `(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
- )
- }),
- )
- }),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- it.live("rejects an empty grep pattern", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- withTools(tmp.path, (registry) =>
- Effect.gen(function* () {
- expect(yield* executeTool(registry, call("grep", { pattern: "" }))).toEqual({
- status: "error",
- error: {
- type: "tool.execution",
- message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]',
- },
- })
- }),
- ),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- it.live("handles explicit grep file and directory paths", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.promise(() =>
- Promise.all([
- fs.writeFile(path.join(tmp.path, "target.txt"), "needle\n"),
- fs.writeFile(path.join(tmp.path, "other.txt"), "needle\n"),
- ]),
- ).pipe(
- Effect.andThen(
- withTools(tmp.path, (registry) =>
- Effect.gen(function* () {
- const file = yield* executeTool(registry, call("grep", { path: "target.txt", pattern: "needle" }))
- expect(file).toMatchObject({
- status: "completed",
- output: [{ entry: { path: "target.txt" }, line: 1, text: "needle\n" }],
- metadata: { matches: 1, truncated: false },
- })
- const directory = yield* executeTool(registry, call("grep", { path: ".", pattern: "needle" }))
- expect(directory).toMatchObject({
- status: "completed",
- metadata: { matches: 2, truncated: false },
- })
- if (directory.status !== "completed") return
- expect(directory.output).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ entry: expect.objectContaining({ path: "target.txt" }) }),
- expect.objectContaining({ entry: expect.objectContaining({ path: "other.txt" }) }),
- ]),
- )
- }),
- ),
- ),
- ),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- it.live("reports no grep matches", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
- Effect.andThen(
- withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
- ),
- Effect.tap((result) =>
- Effect.sync(() => {
- expect(result).toMatchObject({
- status: "completed",
- content: [{ type: "text", text: "No matches found" }],
- metadata: { matches: 0, truncated: false },
- })
- }),
- ),
- ),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- it.live("reports invalid grep regex details", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- withTools(tmp.path, (registry) =>
- Effect.gen(function* () {
- const result = yield* executeTool(registry, call("grep", { pattern: "[" }))
- expect(result).toMatchObject({
- status: "error",
- error: { type: "tool.execution" },
- })
- if (result.status !== "error" || !result.error) return
- expect(result.error.message).toStartWith("Invalid regex pattern:")
- expect(result.error.message).toContain("unclosed character class")
- }),
- ),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- it.live("requires external_directory approval for external grep files and directories", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
- ([active, outside]) => {
- const assertions: Permission.AssertInput[] = []
- return Effect.promise(() => fs.writeFile(path.join(outside.path, "outside.txt"), "needle\n")).pipe(
- Effect.andThen(
- withTools(
- active.path,
- (registry) =>
- Effect.gen(function* () {
- const directory = yield* executeTool(
- registry,
- call("grep", { path: outside.path, pattern: "needle" }),
- )
- const file = yield* executeTool(
- registry,
- call("grep", { path: path.join(outside.path, "outside.txt"), pattern: "needle" }),
- )
- expect(directory.status).toBe("completed")
- expect(file.status).toBe("completed")
- }),
- assertions,
- ),
- ),
- Effect.tap(() =>
- Effect.sync(() => {
- expect(assertions.map((input) => input.action)).toEqual([
- "external_directory",
- "grep",
- "external_directory",
- "grep",
- ])
- expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
- expect(assertions[2]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
- }),
- ),
- )
- },
- ([active, outside]) =>
- Effect.promise(() =>
- Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
- ),
- ),
- )
- for (const name of ["glob", "grep"] as const) {
- it.live(`${name} reports a missing search path`, () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- withTools(tmp.path, (registry) =>
- Effect.gen(function* () {
- const result = yield* executeTool(
- registry,
- call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
- )
- expect(result).toEqual({
- status: "error",
- error: { type: "tool.execution", message: "Search path does not exist: missing" },
- })
- }),
- ),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- }
- it.live("reports a file used as the glob search path", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
- Effect.andThen(
- withTools(tmp.path, (registry) =>
- executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
- ),
- ),
- Effect.tap((result) =>
- Effect.sync(() => {
- expect(result).toEqual({
- status: "error",
- error: { type: "tool.execution", message: "Search path is not a directory: file.txt" },
- })
- }),
- ),
- ),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- it.live("requires external_directory approval for an explicit external glob path", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
- ([active, outside]) => {
- const assertions: Permission.AssertInput[] = []
- return Effect.promise(() => fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")).pipe(
- Effect.andThen(
- withTools(
- active.path,
- (registry) => executeTool(registry, call("glob", { path: outside.path, pattern: "*.txt" })),
- assertions,
- ),
- ),
- Effect.tap((result) =>
- Effect.sync(() => {
- expect(result.status).toBe("completed")
- expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
- expect(assertions[0]?.resources).toEqual([
- path.join(outside.path, "*").replaceAll("\\", "/"),
- ])
- }),
- ),
- )
- },
- ([active, outside]) =>
- Effect.promise(() =>
- Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
- ),
- ),
- )
- it.live("globs through an in-location external symlink without external approval", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
- ([active, outside]) => {
- if (process.platform === "win32") return Effect.void
- const assertions: Permission.AssertInput[] = []
- return Effect.promise(async () => {
- await fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")
- await fs.symlink(outside.path, path.join(active.path, "linked"))
- }).pipe(
- Effect.andThen(
- withTools(
- active.path,
- (registry) => executeTool(registry, call("glob", { path: "linked", pattern: "*.txt" })),
- assertions,
- ),
- ),
- Effect.tap((result) =>
- Effect.sync(() => {
- expect(result.status).toBe("completed")
- expect(assertions.map((input) => input.action)).toEqual(["glob"])
- expect(result).toMatchObject({
- output: [{ path: path.join("linked", "outside.txt"), type: "file" }],
- content: [{ type: "text", text: path.join(active.path, "linked", "outside.txt") }],
- })
- }),
- ),
- )
- },
- ([active, outside]) =>
- Effect.promise(() =>
- Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
- ),
- ),
- )
- })
|