tool-glob.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { FileSystem } from "@opencode-ai/core/filesystem"
  4. import { LocationSearch } from "@opencode-ai/core/location-search"
  5. import { PermissionV2 } from "@opencode-ai/core/permission"
  6. import { RelativePath } from "@opencode-ai/core/schema"
  7. import { SessionV2 } from "@opencode-ai/core/session"
  8. import { GlobTool } from "@opencode-ai/core/tool/glob"
  9. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  10. import { testEffect } from "./lib/effect"
  11. const sessionID = SessionV2.ID.make("ses_glob_tool_test")
  12. const assertions: PermissionV2.AssertInput[] = []
  13. const resolutions: FileSystem.ListInput[] = []
  14. const searches: LocationSearch.FilesInput[] = []
  15. const roots: FileSystem.RootTarget[] = []
  16. let allow = true
  17. let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
  18. const permission = Layer.succeed(
  19. PermissionV2.Service,
  20. PermissionV2.Service.of({
  21. assert: (input) =>
  22. Effect.sync(() => assertions.push(input)).pipe(
  23. Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] }))),
  24. ),
  25. ask: () => Effect.die("unused"),
  26. reply: () => Effect.die("unused"),
  27. get: () => Effect.die("unused"),
  28. forSession: () => Effect.die("unused"),
  29. list: () => Effect.die("unused"),
  30. }),
  31. )
  32. const filesystem = Layer.succeed(
  33. FileSystem.Service,
  34. FileSystem.Service.of({
  35. read: () => Effect.die("unused"),
  36. resolveReadPath: () => Effect.die("unused"),
  37. resolveRead: () => Effect.die("unused"),
  38. readResolved: () => Effect.die("unused"),
  39. readSampleResolved: () => Effect.die("unused"),
  40. readTextPageResolved: () => Effect.die("unused"),
  41. readToolResolved: () => Effect.die("unused"),
  42. list: () => Effect.die("unused"),
  43. resolveRoot: (input = {}) =>
  44. Effect.sync(() => {
  45. resolutions.push(input)
  46. const relative = input.path ?? RelativePath.make(".")
  47. const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
  48. return new FileSystem.RootTarget({
  49. absolute: `/project/${relative}`,
  50. real: `/project/${relative}`,
  51. directory: "/project",
  52. root: "/project",
  53. resource,
  54. reference: input.reference,
  55. type: "directory",
  56. dev: 1,
  57. })
  58. }),
  59. revalidateRoot: Effect.succeed,
  60. resolveList: () => Effect.die("unused"),
  61. listResolved: () => Effect.die("unused"),
  62. listPage: () => Effect.die("unused"),
  63. listPageResolved: () => Effect.die("unused"),
  64. find: () => Effect.die("unused"),
  65. grep: () => Effect.die("unused"),
  66. isIgnored: () => false,
  67. }),
  68. )
  69. const search = Layer.succeed(
  70. LocationSearch.Service,
  71. LocationSearch.Service.of({
  72. files: (input, root) =>
  73. Effect.sync(() => {
  74. searches.push(input)
  75. if (root) roots.push(root)
  76. return result
  77. }),
  78. grep: () => Effect.die("unused"),
  79. }),
  80. )
  81. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  82. const glob = GlobTool.layer.pipe(
  83. Layer.provide(registry),
  84. Layer.provide(permission),
  85. Layer.provide(filesystem),
  86. Layer.provide(search),
  87. )
  88. const it = testEffect(Layer.mergeAll(registry, permission, filesystem, search, glob))
  89. const reset = () => {
  90. assertions.length = 0
  91. resolutions.length = 0
  92. searches.length = 0
  93. roots.length = 0
  94. allow = true
  95. result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
  96. }
  97. const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({
  98. sessionID,
  99. call: { type: "tool-call" as const, id, name: "glob", input },
  100. })
  101. describe("GlobTool", () => {
  102. it.effect("registers the glob definition", () =>
  103. Effect.gen(function* () {
  104. reset()
  105. expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"])
  106. }),
  107. )
  108. it.effect("authorizes the active Location pattern and delegates traversal only to LocationSearch.files", () =>
  109. Effect.gen(function* () {
  110. reset()
  111. const registry = yield* ToolRegistry.Service
  112. expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({
  113. type: "text",
  114. value: "No files found",
  115. })
  116. expect(assertions).toEqual([
  117. {
  118. sessionID,
  119. action: "glob",
  120. resources: ["**/*.ts"],
  121. save: ["*"],
  122. metadata: { root: "src", reference: undefined, path: "src", limit: 12 },
  123. },
  124. ])
  125. expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
  126. expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
  127. expect(roots).toMatchObject([{ resource: "src" }])
  128. }),
  129. )
  130. it.effect("prevents Location search when permission is denied", () =>
  131. Effect.gen(function* () {
  132. reset()
  133. allow = false
  134. expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({
  135. type: "error",
  136. value: "Unable to find files matching *.secret",
  137. })
  138. expect(searches).toEqual([])
  139. }),
  140. )
  141. it.effect("returns active Location glob resources", () =>
  142. Effect.gen(function* () {
  143. reset()
  144. result = new LocationSearch.FilesResult({
  145. items: [
  146. new LocationSearch.File({
  147. path: RelativePath.make("src/index.ts"),
  148. canonical: "/project/src/index.ts",
  149. resource: "src/index.ts",
  150. mtime: 1,
  151. }),
  152. ],
  153. truncated: false,
  154. partial: false,
  155. })
  156. expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({
  157. result: { type: "text", value: "src/index.ts" },
  158. output: {
  159. structured: result,
  160. content: [{ type: "text", text: "src/index.ts" }],
  161. },
  162. })
  163. }),
  164. )
  165. it.effect("searches named references with root and reference metadata", () =>
  166. Effect.gen(function* () {
  167. reset()
  168. result = new LocationSearch.FilesResult({
  169. items: [
  170. new LocationSearch.File({
  171. path: RelativePath.make("guide.md"),
  172. canonical: "/project/docs/guide.md",
  173. resource: "docs:guide.md",
  174. mtime: 1,
  175. }),
  176. ],
  177. truncated: false,
  178. partial: false,
  179. })
  180. expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({
  181. type: "text",
  182. value: "docs:guide.md",
  183. })
  184. expect(assertions).toEqual([
  185. {
  186. sessionID,
  187. action: "glob",
  188. resources: ["*.md"],
  189. save: ["*"],
  190. metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined },
  191. },
  192. ])
  193. expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }])
  194. }),
  195. )
  196. it.effect("formats bounded and partial results without discarding structured output", () =>
  197. Effect.sync(() => {
  198. const output = new LocationSearch.FilesResult({
  199. items: [
  200. new LocationSearch.File({
  201. path: RelativePath.make("one.ts"),
  202. canonical: "/project/one.ts",
  203. resource: "one.ts",
  204. mtime: 1,
  205. }),
  206. ],
  207. truncated: true,
  208. partial: true,
  209. })
  210. expect(GlobTool.toModelOutput(output)).toBe(
  211. "one.ts\n\n(Results are truncated: showing first 1 results. Consider using a more specific path or pattern.)\n\n(Results may be incomplete because some discovered files could not be read.)",
  212. )
  213. }),
  214. )
  215. })