tool-search.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import { describe, expect } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { Effect, Layer } from "effect"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  7. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  8. import { Environment } from "@opencode-ai/core/environment/index"
  9. import { FileSystem } from "@opencode-ai/core/filesystem"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  12. import { Permission } from "@opencode-ai/core/permission"
  13. import { Ripgrep } from "@opencode-ai/core/ripgrep"
  14. import { AbsolutePath } from "@opencode-ai/core/schema"
  15. import { Session } from "@opencode-ai/core/session"
  16. import { GlobTool } from "@opencode-ai/core/tool/plugin/glob"
  17. import { GrepTool } from "@opencode-ai/core/tool/plugin/grep"
  18. import { Tool } from "@opencode-ai/core/tool"
  19. import { location } from "./fixture/location"
  20. import { tmpdir } from "./fixture/tmpdir"
  21. import { testEffect } from "./lib/effect"
  22. import { permissionLayer } from "./lib/permission"
  23. import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
  24. const globToolNode = makeLocationNode({
  25. name: "test/glob-tool-plugin",
  26. layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
  27. deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
  28. })
  29. const grepToolNode = makeLocationNode({
  30. name: "test/grep-tool-plugin",
  31. layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
  32. deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
  33. })
  34. const sessionID = Session.ID.make("ses_search_tool_test")
  35. const withTools = <A, E, R>(
  36. directory: string,
  37. body: (registry: Tool.Interface) => Effect.Effect<A, E, R>,
  38. assertions?: Permission.AssertInput[],
  39. ) =>
  40. Effect.gen(function* () {
  41. return yield* body(yield* Tool.Service)
  42. }).pipe(
  43. Effect.provide(
  44. AppNodeBuilder.build(LayerNode.group([Tool.node, globToolNode, grepToolNode]), [
  45. [
  46. Location.node,
  47. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  48. ],
  49. [
  50. Permission.node,
  51. permissionLayer({
  52. assert: (input) =>
  53. Effect.sync(() => {
  54. assertions?.push(input)
  55. }),
  56. }),
  57. ],
  58. ]),
  59. ),
  60. )
  61. const call = (name: "glob" | "grep", input: unknown) => ({
  62. sessionID,
  63. ...toolIdentity,
  64. call: { type: "tool-call" as const, id: `call-${name}`, name, input },
  65. })
  66. const it = testEffect(Layer.empty)
  67. describe("search tools", () => {
  68. it.live("bounds omitted glob and grep limits", () =>
  69. Effect.acquireUseRelease(
  70. Effect.promise(() => tmpdir()),
  71. (tmp) =>
  72. Effect.gen(function* () {
  73. yield* Effect.promise(() =>
  74. Promise.all(
  75. Array.from({ length: FileSystem.DEFAULT_SEARCH_LIMIT + 1 }, (_, index) =>
  76. fs.writeFile(path.join(tmp.path, `${index}.txt`), "needle\n"),
  77. ),
  78. ),
  79. )
  80. yield* withTools(tmp.path, (registry) =>
  81. Effect.gen(function* () {
  82. const glob = yield* executeTool(registry, call("glob", { pattern: "*" }))
  83. const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
  84. expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
  85. expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
  86. expect(glob.content).toHaveLength(1)
  87. expect(grep.content).toHaveLength(1)
  88. const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
  89. const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : ""
  90. expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT + 2)
  91. expect(globText).toEndWith(
  92. `(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
  93. )
  94. expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
  95. expect(grepText).toEndWith(
  96. `(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
  97. )
  98. }),
  99. )
  100. }),
  101. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  102. ),
  103. )
  104. it.live("rejects an empty grep pattern", () =>
  105. Effect.acquireUseRelease(
  106. Effect.promise(() => tmpdir()),
  107. (tmp) =>
  108. withTools(tmp.path, (registry) =>
  109. Effect.gen(function* () {
  110. expect(yield* executeTool(registry, call("grep", { pattern: "" }))).toEqual({
  111. status: "error",
  112. error: {
  113. type: "tool.execution",
  114. message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]',
  115. },
  116. })
  117. }),
  118. ),
  119. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  120. ),
  121. )
  122. it.live("handles explicit grep file and directory paths", () =>
  123. Effect.acquireUseRelease(
  124. Effect.promise(() => tmpdir()),
  125. (tmp) =>
  126. Effect.promise(() =>
  127. Promise.all([
  128. fs.writeFile(path.join(tmp.path, "target.txt"), "needle\n"),
  129. fs.writeFile(path.join(tmp.path, "other.txt"), "needle\n"),
  130. ]),
  131. ).pipe(
  132. Effect.andThen(
  133. withTools(tmp.path, (registry) =>
  134. Effect.gen(function* () {
  135. const file = yield* executeTool(registry, call("grep", { path: "target.txt", pattern: "needle" }))
  136. expect(file).toMatchObject({
  137. status: "completed",
  138. output: [{ entry: { path: "target.txt" }, line: 1, text: "needle\n" }],
  139. metadata: { matches: 1, truncated: false },
  140. })
  141. const directory = yield* executeTool(registry, call("grep", { path: ".", pattern: "needle" }))
  142. expect(directory).toMatchObject({
  143. status: "completed",
  144. metadata: { matches: 2, truncated: false },
  145. })
  146. if (directory.status !== "completed") return
  147. expect(directory.output).toEqual(
  148. expect.arrayContaining([
  149. expect.objectContaining({ entry: expect.objectContaining({ path: "target.txt" }) }),
  150. expect.objectContaining({ entry: expect.objectContaining({ path: "other.txt" }) }),
  151. ]),
  152. )
  153. }),
  154. ),
  155. ),
  156. ),
  157. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  158. ),
  159. )
  160. it.live("reports no grep matches", () =>
  161. Effect.acquireUseRelease(
  162. Effect.promise(() => tmpdir()),
  163. (tmp) =>
  164. Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
  165. Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
  166. Effect.tap((result) =>
  167. Effect.sync(() => {
  168. expect(result).toMatchObject({
  169. status: "completed",
  170. content: [{ type: "text", text: "No matches found" }],
  171. metadata: { matches: 0, truncated: false },
  172. })
  173. }),
  174. ),
  175. ),
  176. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  177. ),
  178. )
  179. it.live("reports invalid grep regex details", () =>
  180. Effect.acquireUseRelease(
  181. Effect.promise(() => tmpdir()),
  182. (tmp) =>
  183. withTools(tmp.path, (registry) =>
  184. Effect.gen(function* () {
  185. const result = yield* executeTool(registry, call("grep", { pattern: "[" }))
  186. expect(result).toMatchObject({
  187. status: "error",
  188. error: { type: "tool.execution" },
  189. })
  190. if (result.status !== "error" || !result.error) return
  191. expect(result.error.message).toStartWith("Invalid regex pattern:")
  192. expect(result.error.message).toContain("unclosed character class")
  193. }),
  194. ),
  195. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  196. ),
  197. )
  198. it.live("requires external_directory approval for external grep files and directories", () =>
  199. Effect.acquireUseRelease(
  200. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  201. ([active, outside]) => {
  202. const assertions: Permission.AssertInput[] = []
  203. return Effect.promise(() => fs.writeFile(path.join(outside.path, "outside.txt"), "needle\n")).pipe(
  204. Effect.andThen(
  205. withTools(
  206. active.path,
  207. (registry) =>
  208. Effect.gen(function* () {
  209. const directory = yield* executeTool(
  210. registry,
  211. call("grep", { path: outside.path, pattern: "needle" }),
  212. )
  213. const file = yield* executeTool(
  214. registry,
  215. call("grep", { path: path.join(outside.path, "outside.txt"), pattern: "needle" }),
  216. )
  217. expect(directory.status).toBe("completed")
  218. expect(file.status).toBe("completed")
  219. }),
  220. assertions,
  221. ),
  222. ),
  223. Effect.tap(() =>
  224. Effect.sync(() => {
  225. expect(assertions.map((input) => input.action)).toEqual([
  226. "external_directory",
  227. "grep",
  228. "external_directory",
  229. "grep",
  230. ])
  231. expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
  232. expect(assertions[2]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
  233. }),
  234. ),
  235. )
  236. },
  237. ([active, outside]) =>
  238. Effect.promise(() =>
  239. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  240. ),
  241. ),
  242. )
  243. for (const name of ["glob", "grep"] as const) {
  244. it.live(`${name} reports a missing search path`, () =>
  245. Effect.acquireUseRelease(
  246. Effect.promise(() => tmpdir()),
  247. (tmp) =>
  248. withTools(tmp.path, (registry) =>
  249. Effect.gen(function* () {
  250. const result = yield* executeTool(
  251. registry,
  252. call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
  253. )
  254. expect(result).toEqual({
  255. status: "error",
  256. error: { type: "tool.execution", message: "Search path does not exist: missing" },
  257. })
  258. }),
  259. ),
  260. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  261. ),
  262. )
  263. }
  264. it.live("reports a file used as the glob search path", () =>
  265. Effect.acquireUseRelease(
  266. Effect.promise(() => tmpdir()),
  267. (tmp) =>
  268. Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
  269. Effect.andThen(
  270. withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
  271. ),
  272. Effect.tap((result) =>
  273. Effect.sync(() => {
  274. expect(result).toEqual({
  275. status: "error",
  276. error: { type: "tool.execution", message: "Search path is not a directory: file.txt" },
  277. })
  278. }),
  279. ),
  280. ),
  281. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  282. ),
  283. )
  284. it.live("requires external_directory approval for an explicit external glob path", () =>
  285. Effect.acquireUseRelease(
  286. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  287. ([active, outside]) => {
  288. const assertions: Permission.AssertInput[] = []
  289. return Effect.promise(() => fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")).pipe(
  290. Effect.andThen(
  291. withTools(
  292. active.path,
  293. (registry) => executeTool(registry, call("glob", { path: outside.path, pattern: "*.txt" })),
  294. assertions,
  295. ),
  296. ),
  297. Effect.tap((result) =>
  298. Effect.sync(() => {
  299. expect(result.status).toBe("completed")
  300. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
  301. expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
  302. }),
  303. ),
  304. )
  305. },
  306. ([active, outside]) =>
  307. Effect.promise(() =>
  308. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  309. ),
  310. ),
  311. )
  312. it.live("globs through an in-location external symlink without external approval", () =>
  313. Effect.acquireUseRelease(
  314. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  315. ([active, outside]) => {
  316. if (process.platform === "win32") return Effect.void
  317. const assertions: Permission.AssertInput[] = []
  318. return Effect.promise(async () => {
  319. await fs.writeFile(path.join(outside.path, "outside.txt"), "outside\n")
  320. await fs.symlink(outside.path, path.join(active.path, "linked"))
  321. }).pipe(
  322. Effect.andThen(
  323. withTools(
  324. active.path,
  325. (registry) => executeTool(registry, call("glob", { path: "linked", pattern: "*.txt" })),
  326. assertions,
  327. ),
  328. ),
  329. Effect.tap((result) =>
  330. Effect.sync(() => {
  331. expect(result.status).toBe("completed")
  332. expect(assertions.map((input) => input.action)).toEqual(["glob"])
  333. expect(result).toMatchObject({
  334. output: [{ path: path.join("linked", "outside.txt"), type: "file" }],
  335. content: [{ type: "text", text: path.join(active.path, "linked", "outside.txt") }],
  336. })
  337. }),
  338. ),
  339. )
  340. },
  341. ([active, outside]) =>
  342. Effect.promise(() =>
  343. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  344. ),
  345. ),
  346. )
  347. })