tool-glob.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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. readTextPageResolved: () => Effect.die("unused"),
  40. list: () => Effect.die("unused"),
  41. resolveRoot: (input = {}) =>
  42. Effect.sync(() => {
  43. resolutions.push(input)
  44. const relative = input.path ?? RelativePath.make(".")
  45. const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
  46. return new FileSystem.RootTarget({
  47. absolute: `/project/${relative}`,
  48. real: `/project/${relative}`,
  49. directory: "/project",
  50. root: "/project",
  51. resource,
  52. reference: input.reference,
  53. type: "directory",
  54. dev: 1,
  55. })
  56. }),
  57. revalidateRoot: Effect.succeed,
  58. resolveList: () => Effect.die("unused"),
  59. listResolved: () => Effect.die("unused"),
  60. listPage: () => Effect.die("unused"),
  61. listPageResolved: () => Effect.die("unused"),
  62. find: () => Effect.die("unused"),
  63. grep: () => Effect.die("unused"),
  64. isIgnored: () => false,
  65. }),
  66. )
  67. const search = Layer.succeed(
  68. LocationSearch.Service,
  69. LocationSearch.Service.of({
  70. files: (input, root) =>
  71. Effect.sync(() => {
  72. searches.push(input)
  73. if (root) roots.push(root)
  74. return result
  75. }),
  76. grep: () => Effect.die("unused"),
  77. }),
  78. )
  79. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  80. const glob = GlobTool.layer.pipe(
  81. Layer.provide(registry),
  82. Layer.provide(permission),
  83. Layer.provide(filesystem),
  84. Layer.provide(search),
  85. )
  86. const it = testEffect(Layer.mergeAll(registry, permission, filesystem, search, glob))
  87. const reset = () => {
  88. assertions.length = 0
  89. resolutions.length = 0
  90. searches.length = 0
  91. roots.length = 0
  92. allow = true
  93. result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
  94. }
  95. const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({
  96. sessionID,
  97. call: { type: "tool-call" as const, id, name: "glob", input },
  98. })
  99. describe("GlobTool", () => {
  100. it.effect("registers the glob definition", () =>
  101. Effect.gen(function* () {
  102. reset()
  103. expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"])
  104. }),
  105. )
  106. it.effect("authorizes the active Location pattern and delegates traversal only to LocationSearch.files", () =>
  107. Effect.gen(function* () {
  108. reset()
  109. const registry = yield* ToolRegistry.Service
  110. expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({
  111. type: "text",
  112. value: "No files found",
  113. })
  114. expect(assertions).toEqual([
  115. {
  116. sessionID,
  117. action: "glob",
  118. resources: ["**/*.ts"],
  119. save: ["*"],
  120. metadata: { root: "src", reference: undefined, path: "src", limit: 12 },
  121. },
  122. ])
  123. expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
  124. expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
  125. expect(roots).toMatchObject([{ resource: "src" }])
  126. }),
  127. )
  128. it.effect("prevents Location search when permission is denied", () =>
  129. Effect.gen(function* () {
  130. reset()
  131. allow = false
  132. expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({
  133. type: "error",
  134. value: "Unable to find files matching *.secret",
  135. })
  136. expect(searches).toEqual([])
  137. }),
  138. )
  139. it.effect("returns active Location glob resources", () =>
  140. Effect.gen(function* () {
  141. reset()
  142. result = new LocationSearch.FilesResult({
  143. items: [
  144. new LocationSearch.File({
  145. path: RelativePath.make("src/index.ts"),
  146. canonical: "/project/src/index.ts",
  147. resource: "src/index.ts",
  148. mtime: 1,
  149. }),
  150. ],
  151. truncated: false,
  152. partial: false,
  153. })
  154. expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({
  155. result: { type: "text", value: "src/index.ts" },
  156. output: {
  157. structured: result,
  158. content: [{ type: "text", text: "src/index.ts" }],
  159. },
  160. })
  161. }),
  162. )
  163. it.effect("searches named references with root and reference metadata", () =>
  164. Effect.gen(function* () {
  165. reset()
  166. result = new LocationSearch.FilesResult({
  167. items: [
  168. new LocationSearch.File({
  169. path: RelativePath.make("guide.md"),
  170. canonical: "/project/docs/guide.md",
  171. resource: "docs:guide.md",
  172. mtime: 1,
  173. }),
  174. ],
  175. truncated: false,
  176. partial: false,
  177. })
  178. expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({
  179. type: "text",
  180. value: "docs:guide.md",
  181. })
  182. expect(assertions).toEqual([
  183. {
  184. sessionID,
  185. action: "glob",
  186. resources: ["*.md"],
  187. save: ["*"],
  188. metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined },
  189. },
  190. ])
  191. expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }])
  192. }),
  193. )
  194. it.effect("formats bounded and partial results without discarding structured output", () =>
  195. Effect.sync(() => {
  196. const output = new LocationSearch.FilesResult({
  197. items: [
  198. new LocationSearch.File({
  199. path: RelativePath.make("one.ts"),
  200. canonical: "/project/one.ts",
  201. resource: "one.ts",
  202. mtime: 1,
  203. }),
  204. ],
  205. truncated: true,
  206. partial: true,
  207. })
  208. expect(GlobTool.toModelOutput(output)).toBe(
  209. "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.)",
  210. )
  211. }),
  212. )
  213. })