mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 18:43:39 +00:00
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
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 { PermissionV2 } from "@opencode-ai/core/permission"
|
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { SessionV2 } from "@opencode-ai/core/session"
|
|
import { GlobTool } from "@opencode-ai/core/tool/glob"
|
|
import { GrepTool } from "@opencode-ai/core/tool/grep"
|
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
|
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: [
|
|
ToolRegistry.toolsNode,
|
|
FSUtil.node,
|
|
Ripgrep.node,
|
|
Location.node,
|
|
LocationMutation.node,
|
|
PermissionV2.node,
|
|
],
|
|
})
|
|
const grepToolNode = makeLocationNode({
|
|
name: "test/grep-tool-plugin",
|
|
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
|
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
|
|
})
|
|
const sessionID = SessionV2.ID.make("ses_search_tool_test")
|
|
|
|
const withTools = <A, E, R>(
|
|
directory: string,
|
|
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
|
|
assertions?: PermissionV2.AssertInput[],
|
|
) =>
|
|
Effect.gen(function* () {
|
|
return yield* body(yield* ToolRegistry.Service)
|
|
}).pipe(
|
|
Effect.provide(
|
|
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, globToolNode, grepToolNode]), [
|
|
[
|
|
Location.node,
|
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
|
],
|
|
[
|
|
PermissionV2.node,
|
|
Layer.succeed(
|
|
PermissionV2.Service,
|
|
PermissionV2.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"),
|
|
}),
|
|
),
|
|
],
|
|
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
|
]),
|
|
),
|
|
)
|
|
|
|
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]()),
|
|
),
|
|
)
|
|
|
|
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: PermissionV2.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: PermissionV2.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),
|
|
),
|
|
),
|
|
)
|
|
})
|