tool-read.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { FileSystem } from "@opencode-ai/core/filesystem"
  4. import { PermissionV2 } from "@opencode-ai/core/permission"
  5. import { SessionV2 } from "@opencode-ai/core/session"
  6. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  7. import { ReadTool } from "@opencode-ai/core/tool/read"
  8. import { RelativePath } from "@opencode-ai/core/schema"
  9. import { testEffect } from "./lib/effect"
  10. const assertions: PermissionV2.AssertInput[] = []
  11. const reads: FileSystem.ReadInput[] = []
  12. const textPageInputs: FileSystem.TextPageInput[] = []
  13. const pages: FileSystem.ListTarget[] = []
  14. const pageInputs: Pick<FileSystem.ListPageInput, "offset" | "limit">[] = []
  15. let resolvedInput: FileSystem.ReadInput | undefined
  16. let resolveFailure: unknown
  17. let listResolveFailure: unknown = new Error("not a directory")
  18. let listReal = "/project/src"
  19. let size = 5
  20. let real = "/project/README.md"
  21. let afterApproval = () => {}
  22. const filesystem = Layer.succeed(
  23. FileSystem.Service,
  24. FileSystem.Service.of({
  25. read: () => Effect.die("unused"),
  26. resolveReadPath: (input) =>
  27. resolveFailure === undefined
  28. ? Effect.succeed({
  29. type: "file" as const,
  30. target: new FileSystem.ReadTarget({
  31. real,
  32. resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
  33. size,
  34. dev: 1,
  35. }),
  36. })
  37. : listResolveFailure === undefined
  38. ? Effect.succeed({
  39. type: "directory" as const,
  40. target: new FileSystem.ListTarget({
  41. absolute: `/project/${input.path ?? "."}`,
  42. real: listReal,
  43. directory: "/project",
  44. root: "/project",
  45. resource: input.path ?? ".",
  46. }),
  47. })
  48. : Effect.die(resolveFailure),
  49. resolveRead: (input) =>
  50. Effect.sync(() => {
  51. resolvedInput = input
  52. }).pipe(
  53. Effect.andThen(
  54. resolveFailure === undefined
  55. ? Effect.succeed(
  56. new FileSystem.ReadTarget({
  57. real,
  58. resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
  59. size,
  60. dev: 1,
  61. }),
  62. )
  63. : Effect.die(resolveFailure),
  64. ),
  65. ),
  66. readResolved: () =>
  67. Effect.sync(() => {
  68. reads.push({ path: RelativePath.make("README.md") })
  69. return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
  70. }),
  71. readTextPageResolved: (_target, page = {}) =>
  72. Effect.sync(() => {
  73. textPageInputs.push(page)
  74. return new FileSystem.TextPage({
  75. type: "text-page",
  76. content: "hello",
  77. mime: "text/plain",
  78. offset: page.offset ?? 1,
  79. truncated: true,
  80. next: (page.offset ?? 1) + 1,
  81. })
  82. }),
  83. resolveRoot: () => Effect.die("unused"),
  84. revalidateRoot: Effect.succeed,
  85. list: () => Effect.die("unused"),
  86. resolveList: (input = {}) =>
  87. listResolveFailure === undefined
  88. ? Effect.succeed(
  89. new FileSystem.ListTarget({
  90. absolute: `/project/${input.path ?? "."}`,
  91. real: listReal,
  92. directory: "/project",
  93. root: "/project",
  94. resource: input.path ?? ".",
  95. }),
  96. )
  97. : Effect.die(listResolveFailure),
  98. listResolved: () => Effect.die("unused"),
  99. listPage: () => Effect.die("unused"),
  100. listPageResolved: (target, page = {}) =>
  101. Effect.sync(() => {
  102. pages.push(target)
  103. pageInputs.push(page)
  104. return new FileSystem.ListPage({ entries: [], truncated: false })
  105. }),
  106. find: () => Effect.die("unused"),
  107. grep: () => Effect.die("unused"),
  108. isIgnored: () => false,
  109. }),
  110. )
  111. let allow = true
  112. const permission = Layer.succeed(
  113. PermissionV2.Service,
  114. PermissionV2.Service.of({
  115. assert: (input) =>
  116. Effect.sync(() => {
  117. assertions.push(input)
  118. if (allow) afterApproval()
  119. }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
  120. ask: () => Effect.die("unused"),
  121. reply: () => Effect.die("unused"),
  122. get: () => Effect.die("unused"),
  123. forSession: () => Effect.die("unused"),
  124. list: () => Effect.die("unused"),
  125. }),
  126. )
  127. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  128. const read = ReadTool.layer.pipe(Layer.provide(registry), Layer.provide(filesystem), Layer.provide(permission))
  129. const it = testEffect(Layer.mergeAll(registry, filesystem, permission, read))
  130. const sessionID = SessionV2.ID.make("ses_read_tool_test")
  131. describe("ReadTool", () => {
  132. it.effect("registers, authorizes, and reads through the location filesystem", () =>
  133. Effect.gen(function* () {
  134. assertions.length = 0
  135. reads.length = 0
  136. allow = true
  137. resolveFailure = undefined
  138. listResolveFailure = new Error("not a directory")
  139. size = 5
  140. real = "/project/README.md"
  141. afterApproval = () => {}
  142. resolvedInput = undefined
  143. const registry = yield* ToolRegistry.Service
  144. expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
  145. expect(
  146. yield* registry.execute({
  147. sessionID,
  148. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
  149. }),
  150. ).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
  151. expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
  152. expect(reads).toEqual([{ path: RelativePath.make("README.md") }])
  153. }),
  154. )
  155. it.effect("does not read when permission is denied", () =>
  156. Effect.gen(function* () {
  157. assertions.length = 0
  158. reads.length = 0
  159. allow = false
  160. resolveFailure = undefined
  161. listResolveFailure = new Error("not a directory")
  162. size = 5
  163. real = "/project/README.md"
  164. afterApproval = () => {}
  165. resolvedInput = undefined
  166. const registry = yield* ToolRegistry.Service
  167. expect(
  168. yield* registry.execute({
  169. sessionID,
  170. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
  171. }),
  172. ).toEqual({ type: "error", value: "Unable to read README.md" })
  173. expect(reads).toEqual([])
  174. }),
  175. )
  176. it.effect("lists a bounded directory page through read", () =>
  177. Effect.gen(function* () {
  178. assertions.length = 0
  179. pages.length = 0
  180. pageInputs.length = 0
  181. allow = true
  182. resolveFailure = new Error("Path is not a file")
  183. listResolveFailure = undefined
  184. listReal = "/project/src"
  185. afterApproval = () => {}
  186. const registry = yield* ToolRegistry.Service
  187. expect(
  188. yield* registry.execute({
  189. sessionID,
  190. call: {
  191. type: "tool-call",
  192. id: "call-read-directory",
  193. name: "read",
  194. input: { path: "src", offset: 2, limit: 10 },
  195. },
  196. }),
  197. ).toEqual({ type: "json", value: { entries: [], truncated: false } })
  198. expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
  199. expect(pageInputs).toEqual([{ offset: 2, limit: 10 }])
  200. }),
  201. )
  202. it.effect("does not list a directory when permission is denied", () =>
  203. Effect.gen(function* () {
  204. pages.length = 0
  205. allow = false
  206. resolveFailure = new Error("Path is not a file")
  207. listResolveFailure = undefined
  208. listReal = "/project/src"
  209. afterApproval = () => {}
  210. const registry = yield* ToolRegistry.Service
  211. expect(
  212. yield* registry.execute({
  213. sessionID,
  214. call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
  215. }),
  216. ).toEqual({ type: "error", value: "Unable to read src" })
  217. expect(pages).toEqual([])
  218. }),
  219. )
  220. it.effect("does not list when the directory changes after permission approval", () =>
  221. Effect.gen(function* () {
  222. pages.length = 0
  223. allow = true
  224. resolveFailure = new Error("Path is not a file")
  225. listResolveFailure = undefined
  226. listReal = "/project/src"
  227. afterApproval = () => {
  228. listReal = "/outside/src"
  229. }
  230. const registry = yield* ToolRegistry.Service
  231. expect(
  232. yield* registry.execute({
  233. sessionID,
  234. call: { type: "tool-call", id: "call-read-directory-swapped", name: "read", input: { path: "src" } },
  235. }),
  236. ).toEqual({ type: "error", value: "Unable to read src" })
  237. expect(pages).toEqual([])
  238. }),
  239. )
  240. it.effect("authorizes project references with their canonical identity", () =>
  241. Effect.gen(function* () {
  242. assertions.length = 0
  243. reads.length = 0
  244. allow = true
  245. resolveFailure = undefined
  246. listResolveFailure = new Error("not a directory")
  247. size = 5
  248. real = "/project/README.md"
  249. afterApproval = () => {}
  250. resolvedInput = undefined
  251. const registry = yield* ToolRegistry.Service
  252. yield* registry.execute({
  253. sessionID,
  254. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
  255. })
  256. expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
  257. }),
  258. )
  259. it.effect("settles missing files as typed tool errors", () =>
  260. Effect.gen(function* () {
  261. allow = true
  262. reads.length = 0
  263. real = "/project/README.md"
  264. afterApproval = () => {}
  265. const registry = yield* ToolRegistry.Service
  266. resolveFailure = new Error("missing")
  267. listResolveFailure = new Error("missing")
  268. expect(
  269. yield* registry.execute({
  270. sessionID,
  271. call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
  272. }),
  273. ).toEqual({ type: "error", value: "Unable to read missing.txt" })
  274. expect(reads).toEqual([])
  275. }),
  276. )
  277. it.effect("reads large UTF-8 text files as bounded pages with continuation", () =>
  278. Effect.gen(function* () {
  279. textPageInputs.length = 0
  280. allow = true
  281. resolveFailure = undefined
  282. listResolveFailure = new Error("not a directory")
  283. size = FileSystem.MAX_READ_BYTES + 1
  284. real = "/project/large.txt"
  285. afterApproval = () => {}
  286. const registry = yield* ToolRegistry.Service
  287. expect(
  288. yield* registry.execute({
  289. sessionID,
  290. call: {
  291. type: "tool-call",
  292. id: "call-large",
  293. name: "read",
  294. input: { path: "large.txt", offset: 2, limit: 1 },
  295. },
  296. }),
  297. ).toEqual({
  298. type: "json",
  299. value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
  300. })
  301. expect(textPageInputs).toEqual([{ offset: 2, limit: 1 }])
  302. }),
  303. )
  304. it.effect("does not read when the file changes after permission approval", () =>
  305. Effect.gen(function* () {
  306. assertions.length = 0
  307. reads.length = 0
  308. allow = true
  309. resolveFailure = undefined
  310. listResolveFailure = new Error("not a directory")
  311. size = 5
  312. real = "/project/README.md"
  313. afterApproval = () => {
  314. real = "/outside/README.md"
  315. }
  316. const registry = yield* ToolRegistry.Service
  317. expect(
  318. yield* registry.execute({
  319. sessionID,
  320. call: { type: "tool-call", id: "call-swapped", name: "read", input: { path: "README.md" } },
  321. }),
  322. ).toEqual({ type: "error", value: "Unable to read README.md" })
  323. expect(reads).toEqual([])
  324. }),
  325. )
  326. })