tool-glob.test.ts 7.0 KB

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