tool-read.test.ts 19 KB

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