tool-read.test.ts 19 KB

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