tool-read.test.ts 13 KB

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