tool-read.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  1. import { 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 { RelativePath } from "@opencode-ai/core/schema"
  11. import { testEffect } from "./lib/effect"
  12. const assertions: PermissionV2.AssertInput[] = []
  13. const reads: FileSystem.ReadInput[] = []
  14. const samples: number[] = []
  15. const textPageInputs: FileSystem.TextPageInput[] = []
  16. const pages: FileSystem.ListTarget[] = []
  17. const pageInputs: Pick<FileSystem.ListPageInput, "offset" | "limit">[] = []
  18. let resolvedInput: FileSystem.ReadInput | undefined
  19. let resolveFailure: unknown
  20. let listResolveFailure: unknown = new Error("not a directory")
  21. let listReal = "/project/src"
  22. let size = 5
  23. let real = "/project/README.md"
  24. let afterApproval = () => {}
  25. let readContent: FileSystem.Content = new FileSystem.TextContent({
  26. type: "text",
  27. content: "hello",
  28. mime: "text/plain",
  29. })
  30. let sample = new TextEncoder().encode("hello")
  31. let readFailure: unknown
  32. let configEntries: Config.Entry[] = []
  33. const filesystem = Layer.succeed(
  34. FileSystem.Service,
  35. FileSystem.Service.of({
  36. read: () => Effect.die("unused"),
  37. resolveReadPath: (input) =>
  38. resolveFailure === undefined
  39. ? Effect.succeed({
  40. type: "file" as const,
  41. target: new FileSystem.ReadTarget({
  42. real,
  43. resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
  44. size,
  45. dev: 1,
  46. }),
  47. })
  48. : listResolveFailure === undefined
  49. ? Effect.succeed({
  50. type: "directory" as const,
  51. target: new FileSystem.ListTarget({
  52. absolute: `/project/${input.path ?? "."}`,
  53. real: listReal,
  54. directory: "/project",
  55. root: "/project",
  56. resource: input.path ?? ".",
  57. }),
  58. })
  59. : Effect.die(resolveFailure),
  60. resolveRead: (input) =>
  61. Effect.sync(() => {
  62. resolvedInput = input
  63. }).pipe(
  64. Effect.andThen(
  65. resolveFailure === undefined
  66. ? Effect.succeed(
  67. new FileSystem.ReadTarget({
  68. real,
  69. resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
  70. size,
  71. dev: 1,
  72. }),
  73. )
  74. : Effect.die(resolveFailure),
  75. ),
  76. ),
  77. readResolved: () =>
  78. readFailure === undefined
  79. ? Effect.sync(() => {
  80. reads.push({ path: RelativePath.make("README.md") })
  81. return readContent
  82. })
  83. : Effect.die(readFailure),
  84. readSampleResolved: (_target, maximumBytes) =>
  85. Effect.sync(() => {
  86. samples.push(maximumBytes)
  87. return sample.slice(0, maximumBytes)
  88. }),
  89. readTextPageResolved: (_target, page = {}) =>
  90. readFailure === undefined
  91. ? Effect.sync(() => {
  92. textPageInputs.push(page)
  93. return new FileSystem.TextPage({
  94. type: "text-page",
  95. content: "hello",
  96. mime: "text/plain",
  97. offset: page.offset ?? 1,
  98. truncated: true,
  99. next: (page.offset ?? 1) + 1,
  100. })
  101. })
  102. : Effect.die(readFailure),
  103. readToolResolved: (_target, page = {}) => {
  104. samples.push(FileSystem.READ_SAMPLE_BYTES)
  105. if (readFailure !== undefined) return Effect.die(readFailure)
  106. if (sample[0] === 0x89 && sample[1] === 0x50 && sample[2] === 0x4e && sample[3] === 0x47)
  107. return Effect.succeed(
  108. readContent.type === "binary"
  109. ? new FileSystem.BinaryContent({ ...readContent, mime: "image/png" })
  110. : readContent,
  111. )
  112. if (FileSystem.isBinary(real.split("/").at(-1) ?? real, sample))
  113. return Effect.die(new FileSystem.BinaryFileError(real.split("/").at(-1) ?? real))
  114. if (size > FileSystem.MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined)
  115. return Effect.sync(() => {
  116. textPageInputs.push(page)
  117. return new FileSystem.TextPage({
  118. type: "text-page",
  119. content: "hello",
  120. mime: "text/plain",
  121. offset: page.offset ?? 1,
  122. truncated: true,
  123. next: (page.offset ?? 1) + 1,
  124. })
  125. })
  126. return Effect.sync(() => {
  127. reads.push({ path: RelativePath.make("README.md") })
  128. return readContent
  129. })
  130. },
  131. resolveRoot: () => Effect.die("unused"),
  132. revalidateRoot: Effect.succeed,
  133. list: () => Effect.die("unused"),
  134. resolveList: (input = {}) =>
  135. listResolveFailure === undefined
  136. ? Effect.succeed(
  137. new FileSystem.ListTarget({
  138. absolute: `/project/${input.path ?? "."}`,
  139. real: listReal,
  140. directory: "/project",
  141. root: "/project",
  142. resource: input.path ?? ".",
  143. }),
  144. )
  145. : Effect.die(listResolveFailure),
  146. listResolved: () => Effect.die("unused"),
  147. listPage: () => Effect.die("unused"),
  148. listPageResolved: (target, page = {}) =>
  149. Effect.sync(() => {
  150. pages.push(target)
  151. pageInputs.push(page)
  152. return new FileSystem.ListPage({ entries: [], truncated: false })
  153. }),
  154. find: () => Effect.die("unused"),
  155. grep: () => Effect.die("unused"),
  156. isIgnored: () => false,
  157. }),
  158. )
  159. let allow = true
  160. const permission = Layer.succeed(
  161. PermissionV2.Service,
  162. PermissionV2.Service.of({
  163. assert: (input) =>
  164. Effect.sync(() => {
  165. assertions.push(input)
  166. if (allow) afterApproval()
  167. }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
  168. ask: () => Effect.die("unused"),
  169. reply: () => Effect.die("unused"),
  170. get: () => Effect.die("unused"),
  171. forSession: () => Effect.die("unused"),
  172. list: () => Effect.die("unused"),
  173. }),
  174. )
  175. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  176. const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
  177. const read = ReadTool.layer.pipe(
  178. Layer.provide(registry),
  179. Layer.provide(filesystem),
  180. Layer.provide(permission),
  181. Layer.provide(config),
  182. )
  183. const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, read))
  184. const sessionID = SessionV2.ID.make("ses_read_tool_test")
  185. describe("ReadTool", () => {
  186. it.effect("registers, authorizes, and reads through the location filesystem", () =>
  187. Effect.gen(function* () {
  188. assertions.length = 0
  189. reads.length = 0
  190. allow = true
  191. resolveFailure = undefined
  192. listResolveFailure = new Error("not a directory")
  193. size = 5
  194. real = "/project/README.md"
  195. afterApproval = () => {}
  196. readContent = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
  197. sample = new TextEncoder().encode("hello")
  198. readFailure = undefined
  199. configEntries = []
  200. resolvedInput = undefined
  201. const registry = yield* ToolRegistry.Service
  202. expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
  203. expect(
  204. yield* registry.execute({
  205. sessionID,
  206. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
  207. }),
  208. ).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
  209. expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
  210. expect(reads).toEqual([{ path: RelativePath.make("README.md") }])
  211. }),
  212. )
  213. it.effect("returns a small PNG as native media instead of durable base64 text", () =>
  214. Effect.gen(function* () {
  215. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  216. reads.length = 0
  217. samples.length = 0
  218. allow = true
  219. resolveFailure = undefined
  220. listResolveFailure = new Error("not a directory")
  221. size = Buffer.from(png, "base64").length
  222. real = "/project/pixel.png"
  223. afterApproval = () => {}
  224. sample = Buffer.from(png, "base64")
  225. readContent = new FileSystem.BinaryContent({
  226. type: "binary",
  227. content: png,
  228. encoding: "base64",
  229. mime: "image/png",
  230. })
  231. readFailure = undefined
  232. configEntries = []
  233. const registry = yield* ToolRegistry.Service
  234. expect(
  235. yield* registry.execute({
  236. sessionID,
  237. call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
  238. }),
  239. ).toEqual({
  240. type: "content",
  241. value: [
  242. { type: "text", text: "Image read successfully" },
  243. { type: "media", mediaType: "image/png", data: png, filename: "pixel.png" },
  244. ],
  245. })
  246. expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
  247. expect(reads).toHaveLength(0)
  248. const settled = yield* registry.settle({
  249. sessionID,
  250. call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
  251. })
  252. expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" })
  253. expect(JSON.stringify(settled.output?.structured)).not.toContain(png)
  254. expect(settled.output?.content).toMatchObject([
  255. { type: "text", text: "Image read successfully" },
  256. { type: "file", mime: "image/png", source: { type: "data", data: png } },
  257. ])
  258. }),
  259. )
  260. it.effect("rejects invalid or truncated image data after signature classification", () =>
  261. Effect.gen(function* () {
  262. allow = true
  263. resolveFailure = undefined
  264. listResolveFailure = new Error("not a directory")
  265. size = 8
  266. real = "/project/truncated.png"
  267. afterApproval = () => {}
  268. sample = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
  269. readContent = new FileSystem.BinaryContent({
  270. type: "binary",
  271. content: Buffer.from(sample).toString("base64"),
  272. encoding: "base64",
  273. mime: "image/png",
  274. })
  275. readFailure = undefined
  276. configEntries = []
  277. const registry = yield* ToolRegistry.Service
  278. expect(
  279. yield* registry.execute({
  280. sessionID,
  281. call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
  282. }),
  283. ).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
  284. }),
  285. )
  286. it.effect("rejects oversized images when resizing is disabled", () =>
  287. Effect.gen(function* () {
  288. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  289. const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
  290. const base64 = Buffer.from(source.get_bytes()).toString("base64")
  291. source.free()
  292. allow = true
  293. resolveFailure = undefined
  294. listResolveFailure = new Error("not a directory")
  295. size = Buffer.from(base64, "base64").length
  296. real = "/project/wide.png"
  297. afterApproval = () => {}
  298. sample = Buffer.from(base64, "base64")
  299. readContent = new FileSystem.BinaryContent({
  300. type: "binary",
  301. content: base64,
  302. encoding: "base64",
  303. mime: "image/png",
  304. })
  305. readFailure = undefined
  306. configEntries = [
  307. new Config.Document({
  308. type: "document",
  309. info: new Config.Info({
  310. attachments: new ConfigAttachments.Info({
  311. image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
  312. }),
  313. }),
  314. }),
  315. ]
  316. const registry = yield* ToolRegistry.Service
  317. const result = yield* registry.execute({
  318. sessionID,
  319. call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
  320. })
  321. expect(result.type).toBe("error")
  322. if (result.type === "error") expect(result.value).toContain("exceeding configured limits 4x2000")
  323. }),
  324. )
  325. it.effect("resizes images to configured dimensions before returning media", () =>
  326. Effect.gen(function* () {
  327. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  328. const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
  329. const base64 = Buffer.from(source.get_bytes()).toString("base64")
  330. source.free()
  331. allow = true
  332. resolveFailure = undefined
  333. listResolveFailure = new Error("not a directory")
  334. size = Buffer.from(base64, "base64").length
  335. real = "/project/wide.png"
  336. afterApproval = () => {}
  337. sample = Buffer.from(base64, "base64")
  338. readContent = new FileSystem.BinaryContent({
  339. type: "binary",
  340. content: base64,
  341. encoding: "base64",
  342. mime: "image/png",
  343. })
  344. readFailure = undefined
  345. configEntries = [
  346. new Config.Document({
  347. type: "document",
  348. info: new Config.Info({
  349. attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
  350. }),
  351. }),
  352. ]
  353. const registry = yield* ToolRegistry.Service
  354. const result = yield* registry.execute({
  355. sessionID,
  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("media")
  362. if (media?.type !== "media") return
  363. const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.data, "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. allow = true
  373. resolveFailure = undefined
  374. listResolveFailure = new Error("not a directory")
  375. size = Buffer.from(png, "base64").length
  376. real = "/project/pixel.png"
  377. afterApproval = () => {}
  378. sample = Buffer.from(png, "base64")
  379. readContent = new FileSystem.BinaryContent({
  380. type: "binary",
  381. content: png,
  382. encoding: "base64",
  383. mime: "image/png",
  384. })
  385. readFailure = undefined
  386. configEntries = [
  387. new Config.Document({
  388. type: "document",
  389. info: new Config.Info({
  390. attachments: new ConfigAttachments.Info({
  391. image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
  392. }),
  393. }),
  394. }),
  395. ]
  396. const registry = yield* ToolRegistry.Service
  397. const result = yield* registry.execute({
  398. sessionID,
  399. call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
  400. })
  401. expect(result.type).toBe("error")
  402. if (result.type === "error") expect(result.value).toContain("/1 bytes")
  403. }),
  404. )
  405. it.effect("classifies supported image contents before a misleading binary extension", () =>
  406. Effect.gen(function* () {
  407. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  408. allow = true
  409. resolveFailure = undefined
  410. listResolveFailure = new Error("not a directory")
  411. size = Buffer.from(png, "base64").length
  412. real = "/project/pixel.bin"
  413. afterApproval = () => {}
  414. sample = Buffer.from(png, "base64")
  415. readContent = new FileSystem.BinaryContent({
  416. type: "binary",
  417. content: png,
  418. encoding: "base64",
  419. mime: "application/octet-stream",
  420. })
  421. readFailure = undefined
  422. configEntries = []
  423. const registry = yield* ToolRegistry.Service
  424. expect(
  425. yield* registry.execute({
  426. sessionID,
  427. call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
  428. }),
  429. ).toMatchObject({
  430. type: "content",
  431. value: [{ type: "text" }, { type: "media", mediaType: "image/png", filename: "pixel.bin" }],
  432. })
  433. }),
  434. )
  435. it.effect("rejects unsupported binary before direct reads or paging", () =>
  436. Effect.gen(function* () {
  437. reads.length = 0
  438. textPageInputs.length = 0
  439. samples.length = 0
  440. allow = true
  441. resolveFailure = undefined
  442. listResolveFailure = new Error("not a directory")
  443. size = FileSystem.MAX_READ_BYTES + 1
  444. real = "/project/archive.dat"
  445. afterApproval = () => {}
  446. sample = new Uint8Array([0, 1, 2, 3])
  447. readFailure = undefined
  448. const registry = yield* ToolRegistry.Service
  449. expect(
  450. yield* registry.execute({
  451. sessionID,
  452. call: {
  453. type: "tool-call",
  454. id: "call-binary",
  455. name: "read",
  456. input: { path: "archive.dat", offset: 2, limit: 1 },
  457. },
  458. }),
  459. ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
  460. expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
  461. expect(reads).toEqual([])
  462. expect(textPageInputs).toEqual([])
  463. }),
  464. )
  465. it.effect("does not read when permission is denied", () =>
  466. Effect.gen(function* () {
  467. assertions.length = 0
  468. reads.length = 0
  469. allow = false
  470. resolveFailure = undefined
  471. listResolveFailure = new Error("not a directory")
  472. size = 5
  473. real = "/project/README.md"
  474. afterApproval = () => {}
  475. resolvedInput = undefined
  476. const registry = yield* ToolRegistry.Service
  477. expect(
  478. yield* registry.execute({
  479. sessionID,
  480. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
  481. }),
  482. ).toEqual({ type: "error", value: "Unable to read README.md" })
  483. expect(reads).toEqual([])
  484. }),
  485. )
  486. it.effect("lists a bounded directory page through read", () =>
  487. Effect.gen(function* () {
  488. assertions.length = 0
  489. pages.length = 0
  490. pageInputs.length = 0
  491. allow = true
  492. resolveFailure = new Error("Path is not a file")
  493. listResolveFailure = undefined
  494. listReal = "/project/src"
  495. afterApproval = () => {}
  496. const registry = yield* ToolRegistry.Service
  497. expect(
  498. yield* registry.execute({
  499. sessionID,
  500. call: {
  501. type: "tool-call",
  502. id: "call-read-directory",
  503. name: "read",
  504. input: { path: "src", offset: 2, limit: 10 },
  505. },
  506. }),
  507. ).toEqual({ type: "json", value: { entries: [], truncated: false } })
  508. expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
  509. expect(pageInputs).toEqual([{ offset: 2, limit: 10 }])
  510. }),
  511. )
  512. it.effect("does not list a directory when permission is denied", () =>
  513. Effect.gen(function* () {
  514. pages.length = 0
  515. allow = false
  516. resolveFailure = new Error("Path is not a file")
  517. listResolveFailure = undefined
  518. listReal = "/project/src"
  519. afterApproval = () => {}
  520. const registry = yield* ToolRegistry.Service
  521. expect(
  522. yield* registry.execute({
  523. sessionID,
  524. call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
  525. }),
  526. ).toEqual({ type: "error", value: "Unable to read src" })
  527. expect(pages).toEqual([])
  528. }),
  529. )
  530. it.effect("does not list when the directory changes after permission approval", () =>
  531. Effect.gen(function* () {
  532. pages.length = 0
  533. allow = true
  534. resolveFailure = new Error("Path is not a file")
  535. listResolveFailure = undefined
  536. listReal = "/project/src"
  537. afterApproval = () => {
  538. listReal = "/outside/src"
  539. }
  540. const registry = yield* ToolRegistry.Service
  541. expect(
  542. yield* registry.execute({
  543. sessionID,
  544. call: { type: "tool-call", id: "call-read-directory-swapped", name: "read", input: { path: "src" } },
  545. }),
  546. ).toEqual({ type: "error", value: "Unable to read src" })
  547. expect(pages).toEqual([])
  548. }),
  549. )
  550. it.effect("authorizes project references with their canonical identity", () =>
  551. Effect.gen(function* () {
  552. assertions.length = 0
  553. reads.length = 0
  554. allow = true
  555. resolveFailure = undefined
  556. listResolveFailure = new Error("not a directory")
  557. size = 5
  558. real = "/project/README.md"
  559. afterApproval = () => {}
  560. resolvedInput = undefined
  561. const registry = yield* ToolRegistry.Service
  562. yield* registry.execute({
  563. sessionID,
  564. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
  565. })
  566. expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
  567. }),
  568. )
  569. it.effect("settles missing files as typed tool errors", () =>
  570. Effect.gen(function* () {
  571. allow = true
  572. reads.length = 0
  573. real = "/project/README.md"
  574. afterApproval = () => {}
  575. const registry = yield* ToolRegistry.Service
  576. resolveFailure = new Error("missing")
  577. listResolveFailure = new Error("missing")
  578. expect(
  579. yield* registry.execute({
  580. sessionID,
  581. call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
  582. }),
  583. ).toEqual({ type: "error", value: "Unable to read missing.txt" })
  584. expect(reads).toEqual([])
  585. }),
  586. )
  587. it.effect("reads large UTF-8 text files as bounded pages with continuation", () =>
  588. Effect.gen(function* () {
  589. textPageInputs.length = 0
  590. allow = true
  591. resolveFailure = undefined
  592. listResolveFailure = new Error("not a directory")
  593. size = FileSystem.MAX_READ_BYTES + 1
  594. real = "/project/large.txt"
  595. afterApproval = () => {}
  596. sample = new TextEncoder().encode("hello")
  597. readFailure = undefined
  598. const registry = yield* ToolRegistry.Service
  599. expect(
  600. yield* registry.execute({
  601. sessionID,
  602. call: {
  603. type: "tool-call",
  604. id: "call-large",
  605. name: "read",
  606. input: { path: "large.txt", offset: 2, limit: 1 },
  607. },
  608. }),
  609. ).toEqual({
  610. type: "json",
  611. value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
  612. })
  613. expect(textPageInputs).toEqual([{ offset: 2, limit: 1 }])
  614. }),
  615. )
  616. it.effect("preserves safe read limit errors", () =>
  617. Effect.gen(function* () {
  618. allow = true
  619. resolveFailure = undefined
  620. listResolveFailure = new Error("not a directory")
  621. size = 5
  622. real = "/project/changed.txt"
  623. afterApproval = () => {}
  624. sample = new TextEncoder().encode("hello")
  625. readFailure = new FileSystem.ReadLimitError("changed.txt", FileSystem.MAX_READ_BYTES)
  626. const registry = yield* ToolRegistry.Service
  627. expect(
  628. yield* registry.execute({
  629. sessionID,
  630. call: { type: "tool-call", id: "call-read-limit", name: "read", input: { path: "changed.txt" } },
  631. }),
  632. ).toEqual({
  633. type: "error",
  634. value: `File exceeds ${FileSystem.MAX_READ_BYTES} byte read limit: changed.txt`,
  635. })
  636. }),
  637. )
  638. it.effect("preserves binary errors discovered after the sample", () =>
  639. Effect.gen(function* () {
  640. allow = true
  641. resolveFailure = undefined
  642. listResolveFailure = new Error("not a directory")
  643. size = FileSystem.MAX_READ_BYTES + 1
  644. real = "/project/late-binary"
  645. afterApproval = () => {}
  646. sample = new TextEncoder().encode("text prefix")
  647. readFailure = new FileSystem.BinaryFileError("late-binary")
  648. const registry = yield* ToolRegistry.Service
  649. expect(
  650. yield* registry.execute({
  651. sessionID,
  652. call: { type: "tool-call", id: "call-late-binary", name: "read", input: { path: "late-binary" } },
  653. }),
  654. ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
  655. }),
  656. )
  657. it.effect("rejects unsupported binary discovered by a direct read", () =>
  658. Effect.gen(function* () {
  659. allow = true
  660. resolveFailure = undefined
  661. listResolveFailure = new Error("not a directory")
  662. size = 5
  663. real = "/project/late-binary"
  664. afterApproval = () => {}
  665. sample = new TextEncoder().encode("text prefix")
  666. readFailure = undefined
  667. readContent = new FileSystem.BinaryContent({
  668. type: "binary",
  669. content: "AAECAw==",
  670. encoding: "base64",
  671. mime: "application/octet-stream",
  672. })
  673. const registry = yield* ToolRegistry.Service
  674. expect(
  675. yield* registry.execute({
  676. sessionID,
  677. call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
  678. }),
  679. ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
  680. }),
  681. )
  682. it.effect("does not read when the file changes after permission approval", () =>
  683. Effect.gen(function* () {
  684. assertions.length = 0
  685. reads.length = 0
  686. allow = true
  687. resolveFailure = undefined
  688. listResolveFailure = new Error("not a directory")
  689. size = 5
  690. real = "/project/README.md"
  691. sample = new TextEncoder().encode("hello")
  692. readFailure = undefined
  693. afterApproval = () => {
  694. real = "/outside/README.md"
  695. }
  696. const registry = yield* ToolRegistry.Service
  697. expect(
  698. yield* registry.execute({
  699. sessionID,
  700. call: { type: "tool-call", id: "call-swapped", name: "read", input: { path: "README.md" } },
  701. }),
  702. ).toEqual({ type: "error", value: "Unable to read README.md" })
  703. expect(reads).toEqual([])
  704. }),
  705. )
  706. })