tool-read.test.ts 21 KB

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