tool-search.test.ts 15 KB

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