tool-read.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import { beforeEach, describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { Config } from "@opencode-ai/core/config"
  4. import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
  5. import { FileSystem } from "@opencode-ai/core/filesystem"
  6. import { PermissionV2 } from "@opencode-ai/core/permission"
  7. import { SessionV2 } from "@opencode-ai/core/session"
  8. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  9. import { ReadTool } from "@opencode-ai/core/tool/read"
  10. import { testEffect } from "./lib/effect"
  11. const assertions: PermissionV2.AssertInput[] = []
  12. const readCalls: {
  13. input: FileSystem.ReadInput & FileSystem.TextPageInput
  14. page: FileSystem.TextPageInput
  15. }[] = []
  16. const listCalls: FileSystem.ListPageInput[] = []
  17. let resolvedType: "file" | "directory" = "file"
  18. let resolveFailure: unknown
  19. let readResult: FileSystem.Content | FileSystem.TextPage = new FileSystem.TextContent({
  20. type: "text",
  21. content: "hello",
  22. mime: "text/plain",
  23. })
  24. let readFailure: unknown
  25. let configEntries: Config.Entry[] = []
  26. const filesystem = Layer.succeed(
  27. FileSystem.Service,
  28. FileSystem.Service.of({
  29. read: () => Effect.die("unused"),
  30. resolveReadPath: (input) =>
  31. resolveFailure === undefined
  32. ? Effect.succeed(
  33. new FileSystem.ReadPath({
  34. type: resolvedType,
  35. resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
  36. }),
  37. )
  38. : Effect.die(resolveFailure),
  39. readTool: (input, page = {}) => {
  40. readCalls.push({ input, page })
  41. if (readFailure !== undefined) return Effect.die(readFailure)
  42. return Effect.succeed(readResult)
  43. },
  44. resolveRoot: () => Effect.die("unused"),
  45. list: () => Effect.die("unused"),
  46. resolveList: () => Effect.die("unused"),
  47. listResolved: () => Effect.die("unused"),
  48. listPage: (input = {}) =>
  49. Effect.sync(() => {
  50. listCalls.push(input)
  51. return new FileSystem.ListPage({ entries: [], truncated: false })
  52. }),
  53. listPageResolved: () => Effect.die("unused"),
  54. find: () => Effect.die("unused"),
  55. grep: () => Effect.die("unused"),
  56. isIgnored: () => false,
  57. }),
  58. )
  59. let allow = true
  60. const permission = Layer.succeed(
  61. PermissionV2.Service,
  62. PermissionV2.Service.of({
  63. assert: (input) =>
  64. Effect.sync(() => {
  65. assertions.push(input)
  66. }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
  67. ask: () => Effect.die("unused"),
  68. reply: () => Effect.die("unused"),
  69. get: () => Effect.die("unused"),
  70. forSession: () => Effect.die("unused"),
  71. list: () => Effect.die("unused"),
  72. }),
  73. )
  74. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  75. const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
  76. const read = ReadTool.layer.pipe(
  77. Layer.provide(registry),
  78. Layer.provide(filesystem),
  79. Layer.provide(permission),
  80. Layer.provide(config),
  81. )
  82. const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, read))
  83. const sessionID = SessionV2.ID.make("ses_read_tool_test")
  84. describe("ReadTool", () => {
  85. beforeEach(() => {
  86. assertions.length = 0
  87. readCalls.length = 0
  88. listCalls.length = 0
  89. allow = true
  90. resolvedType = "file"
  91. resolveFailure = undefined
  92. readResult = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
  93. readFailure = undefined
  94. configEntries = []
  95. })
  96. it.effect("registers, authorizes, and reads through the location filesystem", () =>
  97. Effect.gen(function* () {
  98. const registry = yield* ToolRegistry.Service
  99. expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
  100. expect(yield* registry.definitions([{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
  101. expect(
  102. yield* registry.execute({
  103. sessionID,
  104. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
  105. }),
  106. ).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
  107. expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
  108. expect(readCalls).toEqual([{ input: { path: "README.md" }, page: {} }])
  109. }),
  110. )
  111. it.effect("returns a small PNG as native media instead of durable base64 text", () =>
  112. Effect.gen(function* () {
  113. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  114. readResult = new FileSystem.BinaryContent({
  115. type: "binary",
  116. content: png,
  117. encoding: "base64",
  118. mime: "image/png",
  119. })
  120. const registry = yield* ToolRegistry.Service
  121. expect(
  122. yield* registry.execute({
  123. sessionID,
  124. call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
  125. }),
  126. ).toEqual({
  127. type: "content",
  128. value: [
  129. { type: "text", text: "Image read successfully" },
  130. { type: "media", mediaType: "image/png", data: png, filename: "pixel.png" },
  131. ],
  132. })
  133. expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }])
  134. const settled = yield* registry.settle({
  135. sessionID,
  136. call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
  137. })
  138. expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" })
  139. expect(JSON.stringify(settled.output?.structured)).not.toContain(png)
  140. expect(settled.output?.content).toMatchObject([
  141. { type: "text", text: "Image read successfully" },
  142. { type: "file", mime: "image/png", source: { type: "data", data: png } },
  143. ])
  144. }),
  145. )
  146. it.effect("rejects invalid image data returned by the filesystem", () =>
  147. Effect.gen(function* () {
  148. readResult = new FileSystem.BinaryContent({
  149. type: "binary",
  150. content: "iVBORw0KGgo=",
  151. encoding: "base64",
  152. mime: "image/png",
  153. })
  154. const registry = yield* ToolRegistry.Service
  155. expect(
  156. yield* registry.execute({
  157. sessionID,
  158. call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
  159. }),
  160. ).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
  161. }),
  162. )
  163. it.effect("rejects oversized images when resizing is disabled", () =>
  164. Effect.gen(function* () {
  165. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  166. const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
  167. const base64 = Buffer.from(source.get_bytes()).toString("base64")
  168. source.free()
  169. readResult = new FileSystem.BinaryContent({
  170. type: "binary",
  171. content: base64,
  172. encoding: "base64",
  173. mime: "image/png",
  174. })
  175. configEntries = [
  176. new Config.Document({
  177. type: "document",
  178. info: new Config.Info({
  179. attachments: new ConfigAttachments.Info({
  180. image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
  181. }),
  182. }),
  183. }),
  184. ]
  185. const registry = yield* ToolRegistry.Service
  186. const result = yield* registry.execute({
  187. sessionID,
  188. call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
  189. })
  190. expect(result.type).toBe("error")
  191. if (result.type === "error") expect(result.value).toContain("exceeding configured limits 4x2000")
  192. }),
  193. )
  194. it.effect("resizes images to configured dimensions before returning media", () =>
  195. Effect.gen(function* () {
  196. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  197. const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
  198. const base64 = Buffer.from(source.get_bytes()).toString("base64")
  199. source.free()
  200. readResult = new FileSystem.BinaryContent({
  201. type: "binary",
  202. content: base64,
  203. encoding: "base64",
  204. mime: "image/png",
  205. })
  206. configEntries = [
  207. new Config.Document({
  208. type: "document",
  209. info: new Config.Info({
  210. attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
  211. }),
  212. }),
  213. ]
  214. const registry = yield* ToolRegistry.Service
  215. const result = yield* registry.execute({
  216. sessionID,
  217. call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
  218. })
  219. expect(result.type).toBe("content")
  220. if (result.type !== "content") return
  221. const media = result.value[1]
  222. expect(media?.type).toBe("media")
  223. if (media?.type !== "media") return
  224. const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.data, "base64"))
  225. expect(resized.get_width()).toBeLessThanOrEqual(4)
  226. expect(resized.get_height()).toBeLessThanOrEqual(2_000)
  227. resized.free()
  228. }),
  229. )
  230. it.effect("enforces max base64 bytes after resize attempts", () =>
  231. Effect.gen(function* () {
  232. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  233. readResult = new FileSystem.BinaryContent({
  234. type: "binary",
  235. content: png,
  236. encoding: "base64",
  237. mime: "image/png",
  238. })
  239. configEntries = [
  240. new Config.Document({
  241. type: "document",
  242. info: new Config.Info({
  243. attachments: new ConfigAttachments.Info({
  244. image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
  245. }),
  246. }),
  247. }),
  248. ]
  249. const registry = yield* ToolRegistry.Service
  250. const result = yield* registry.execute({
  251. sessionID,
  252. call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
  253. })
  254. expect(result.type).toBe("error")
  255. if (result.type === "error") expect(result.value).toContain("/1 bytes")
  256. }),
  257. )
  258. it.effect("returns supported image contents despite a misleading binary extension", () =>
  259. Effect.gen(function* () {
  260. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  261. readResult = new FileSystem.BinaryContent({
  262. type: "binary",
  263. content: png,
  264. encoding: "base64",
  265. mime: "image/png",
  266. })
  267. const registry = yield* ToolRegistry.Service
  268. expect(
  269. yield* registry.execute({
  270. sessionID,
  271. call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
  272. }),
  273. ).toMatchObject({
  274. type: "content",
  275. value: [{ type: "text" }, { type: "media", mediaType: "image/png", filename: "pixel.bin" }],
  276. })
  277. }),
  278. )
  279. it.effect("preserves unsupported binary errors from the filesystem", () =>
  280. Effect.gen(function* () {
  281. readFailure = new FileSystem.BinaryFileError("archive.dat")
  282. const registry = yield* ToolRegistry.Service
  283. expect(
  284. yield* registry.execute({
  285. sessionID,
  286. call: {
  287. type: "tool-call",
  288. id: "call-binary",
  289. name: "read",
  290. input: { path: "archive.dat", offset: 2, limit: 1 },
  291. },
  292. }),
  293. ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
  294. expect(readCalls).toEqual([
  295. { input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } },
  296. ])
  297. }),
  298. )
  299. it.effect("does not read when permission is denied", () =>
  300. Effect.gen(function* () {
  301. allow = false
  302. const registry = yield* ToolRegistry.Service
  303. expect(
  304. yield* registry.execute({
  305. sessionID,
  306. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
  307. }),
  308. ).toEqual({ type: "error", value: "Unable to read README.md" })
  309. expect(readCalls).toEqual([])
  310. }),
  311. )
  312. it.effect("lists a bounded directory page through read", () =>
  313. Effect.gen(function* () {
  314. resolvedType = "directory"
  315. const registry = yield* ToolRegistry.Service
  316. expect(
  317. yield* registry.execute({
  318. sessionID,
  319. call: {
  320. type: "tool-call",
  321. id: "call-read-directory",
  322. name: "read",
  323. input: { path: "src", offset: 2, limit: 10 },
  324. },
  325. }),
  326. ).toEqual({ type: "json", value: { entries: [], truncated: false } })
  327. expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
  328. expect(listCalls).toEqual([{ path: "src", offset: 2, limit: 10 }])
  329. }),
  330. )
  331. it.effect("does not list a directory when permission is denied", () =>
  332. Effect.gen(function* () {
  333. allow = false
  334. resolvedType = "directory"
  335. const registry = yield* ToolRegistry.Service
  336. expect(
  337. yield* registry.execute({
  338. sessionID,
  339. call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
  340. }),
  341. ).toEqual({ type: "error", value: "Unable to read src" })
  342. expect(listCalls).toEqual([])
  343. }),
  344. )
  345. it.effect("authorizes project references with their canonical identity", () =>
  346. Effect.gen(function* () {
  347. const registry = yield* ToolRegistry.Service
  348. yield* registry.execute({
  349. sessionID,
  350. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
  351. })
  352. expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
  353. }),
  354. )
  355. it.effect("settles missing files as typed tool errors", () =>
  356. Effect.gen(function* () {
  357. const registry = yield* ToolRegistry.Service
  358. resolveFailure = new Error("missing")
  359. expect(
  360. yield* registry.execute({
  361. sessionID,
  362. call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
  363. }),
  364. ).toEqual({ type: "error", value: "Unable to read missing.txt" })
  365. expect(readCalls).toEqual([])
  366. }),
  367. )
  368. it.effect("forwards pagination and returns bounded text pages with continuation", () =>
  369. Effect.gen(function* () {
  370. readResult = new FileSystem.TextPage({
  371. type: "text-page",
  372. content: "hello",
  373. mime: "text/plain",
  374. offset: 2,
  375. truncated: true,
  376. next: 3,
  377. })
  378. const registry = yield* ToolRegistry.Service
  379. expect(
  380. yield* registry.execute({
  381. sessionID,
  382. call: {
  383. type: "tool-call",
  384. id: "call-large",
  385. name: "read",
  386. input: { path: "large.txt", offset: 2, limit: 1 },
  387. },
  388. }),
  389. ).toEqual({
  390. type: "json",
  391. value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
  392. })
  393. expect(readCalls).toEqual([{ input: { path: "large.txt", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }])
  394. }),
  395. )
  396. it.effect("rejects unsupported binary discovered by a direct read", () =>
  397. Effect.gen(function* () {
  398. readResult = new FileSystem.BinaryContent({
  399. type: "binary",
  400. content: "AAECAw==",
  401. encoding: "base64",
  402. mime: "application/octet-stream",
  403. })
  404. const registry = yield* ToolRegistry.Service
  405. expect(
  406. yield* registry.execute({
  407. sessionID,
  408. call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
  409. }),
  410. ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
  411. }),
  412. )
  413. })