tool-read.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  8. import { FSUtil } from "@opencode-ai/util/fs-util"
  9. import { Location } from "@opencode-ai/core/location"
  10. import { Image } from "@opencode-ai/core/image"
  11. import { Permission } from "@opencode-ai/core/permission"
  12. import { Session } from "@opencode-ai/core/session"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { Global } from "@opencode-ai/util/global"
  15. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  16. import { location } from "./fixture/location"
  17. import { Tool } from "@opencode-ai/core/tool"
  18. import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
  19. import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
  20. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  21. import { SessionInstructions } from "@opencode-ai/core/session/instructions"
  22. import { testEffect } from "./lib/effect"
  23. import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
  24. const readToolNode = makeLocationNode({
  25. name: "test/read-tool-plugin",
  26. layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
  27. deps: [
  28. Tool.node,
  29. ReadToolFileSystem.node,
  30. LocationMutation.node,
  31. Image.node,
  32. Permission.node,
  33. SessionInstructions.node,
  34. FSUtil.node,
  35. Location.node,
  36. ],
  37. })
  38. const assertions: Permission.AssertInput[] = []
  39. const missingPath = "__missing_read_target__.txt"
  40. const missingAbsolutePath = path.join(process.cwd(), missingPath)
  41. const readCalls: {
  42. input: AbsolutePath
  43. page: ReadToolFileSystem.PageInput
  44. }[] = []
  45. const listCalls: ReadToolFileSystem.PageInput[] = []
  46. let resolvedType: "file" | "directory" = "file"
  47. let resolveFailure: unknown
  48. let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
  49. type: "file",
  50. uri: "file:///README.md",
  51. name: "README.md",
  52. content: "hello",
  53. encoding: "utf8",
  54. mime: "text/plain",
  55. }
  56. let readFailure: ReadToolFileSystem.ReadError | undefined
  57. let configEntries: Config.Entry[] = []
  58. const reader = Layer.succeed(
  59. ReadToolFileSystem.Service,
  60. ReadToolFileSystem.Service.of({
  61. inspect: () => (resolveFailure === undefined ? Effect.succeed(resolvedType) : Effect.die(resolveFailure)),
  62. read: (input, _resource, page = {}) => {
  63. readCalls.push({ input, page })
  64. if (readFailure !== undefined) return Effect.fail(readFailure)
  65. return Effect.succeed(readResult)
  66. },
  67. list: (_path, input = {}) =>
  68. Effect.sync(() => {
  69. listCalls.push(input)
  70. return new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
  71. }),
  72. }),
  73. )
  74. let allow = true
  75. const permission = Layer.succeed(
  76. Permission.Service,
  77. Permission.Service.of({
  78. assert: (input) =>
  79. Effect.sync(() => {
  80. assertions.push(input)
  81. }).pipe(
  82. Effect.andThen(
  83. allow
  84. ? Effect.void
  85. : Effect.fail(
  86. new Permission.BlockedError({
  87. rules: [],
  88. permission: input.action,
  89. resources: input.resources,
  90. }),
  91. ),
  92. ),
  93. ),
  94. ask: () => Effect.die("unused"),
  95. reply: () => Effect.die("unused"),
  96. get: () => Effect.die("unused"),
  97. forSession: () => Effect.die("unused"),
  98. list: () => Effect.die("unused"),
  99. }),
  100. )
  101. const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
  102. const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
  103. const testFileSystem = Layer.effect(
  104. FSUtil.Service,
  105. FSUtil.Service.use((fs) =>
  106. Effect.succeed(
  107. FSUtil.Service.of({
  108. ...fs,
  109. realPath: (path) =>
  110. path === missingAbsolutePath
  111. ? Effect.fail(
  112. PlatformError.systemError({
  113. _tag: "NotFound",
  114. module: "FileSystem",
  115. method: "realPath",
  116. pathOrDescriptor: path,
  117. }),
  118. )
  119. : Effect.succeed(path),
  120. }),
  121. ),
  122. ),
  123. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  124. const locationLayer = Layer.succeed(
  125. Location.Service,
  126. Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
  127. )
  128. const mutation = Layer.succeed(
  129. LocationMutation.Service,
  130. LocationMutation.Service.of({
  131. resolve: (input) => {
  132. if (input.path === missingPath)
  133. return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" }))
  134. const canonical = path.resolve(process.cwd(), input.path)
  135. const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
  136. const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
  137. const directory = path.dirname(canonical)
  138. const externalResource = path.join(directory, "*").replaceAll("\\", "/")
  139. return Effect.succeed({
  140. canonical,
  141. resource,
  142. externalDirectory: external
  143. ? {
  144. action: "external_directory" as const,
  145. directory,
  146. resource: externalResource,
  147. save: externalResource,
  148. }
  149. : undefined,
  150. })
  151. },
  152. }),
  153. )
  154. const unavailableImage = Layer.succeed(
  155. Image.Service,
  156. Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
  157. )
  158. const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
  159. AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [
  160. [ReadToolFileSystem.node, reader],
  161. [Permission.node, permission],
  162. [Config.node, config],
  163. [Image.node, imageLayer],
  164. [LocationMutation.node, mutation],
  165. [FSUtil.node, testFileSystem],
  166. [Location.node, locationLayer],
  167. [Global.node, Global.layerWith({ data: Global.Path.data })],
  168. ])
  169. const it = testEffect(readLayer(imageLayer))
  170. const itWithoutResizer = testEffect(readLayer(unavailableImage))
  171. const sessionID = Session.ID.make("ses_read_tool_test")
  172. describe("ReadTool", () => {
  173. beforeEach(() => {
  174. assertions.length = 0
  175. readCalls.length = 0
  176. listCalls.length = 0
  177. allow = true
  178. resolvedType = "file"
  179. resolveFailure = undefined
  180. readResult = {
  181. type: "file",
  182. uri: "file:///README.md",
  183. name: "README.md",
  184. content: "hello",
  185. encoding: "utf8",
  186. mime: "text/plain",
  187. }
  188. readFailure = undefined
  189. configEntries = []
  190. })
  191. it.effect("registers, authorizes, and reads through the location filesystem", () =>
  192. Effect.gen(function* () {
  193. const registry = yield* Tool.Service
  194. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["read", "execute"])
  195. expect(
  196. (yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).map(
  197. (tool) => tool.name,
  198. ),
  199. ).toEqual(["execute"])
  200. const execution = yield* executeTool(registry, {
  201. sessionID,
  202. ...toolIdentity,
  203. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
  204. })
  205. expect(execution.status).toBe("completed")
  206. if (execution.status !== "completed") return
  207. expect(execution.output).toEqual({
  208. type: "file",
  209. uri: "file:///README.md",
  210. name: "README.md",
  211. content: "hello",
  212. encoding: "utf8",
  213. mime: "text/plain",
  214. })
  215. expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
  216. expect(readCalls).toEqual([
  217. {
  218. input: AbsolutePath.make(path.join(process.cwd(), "README.md")),
  219. page: { offset: undefined, limit: undefined },
  220. },
  221. ])
  222. }),
  223. )
  224. it.effect("asks for external_directory approval before reading an external absolute path", () =>
  225. Effect.gen(function* () {
  226. const registry = yield* Tool.Service
  227. const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt")
  228. expect(
  229. yield* executeTool(registry, {
  230. sessionID,
  231. ...toolIdentity,
  232. call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
  233. }),
  234. ).toMatchObject({ status: "completed" })
  235. expect(assertions).toMatchObject([
  236. {
  237. sessionID,
  238. action: "external_directory",
  239. resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")],
  240. },
  241. { sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] },
  242. ])
  243. expect(readCalls).toEqual([{ input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } }])
  244. }),
  245. )
  246. it.effect("returns a small PNG as native media instead of durable base64 text", () =>
  247. Effect.gen(function* () {
  248. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  249. readResult = {
  250. type: "file",
  251. uri: "file:///pixel.png",
  252. name: "pixel.png",
  253. content: png,
  254. encoding: "base64",
  255. mime: "image/png",
  256. }
  257. const registry = yield* Tool.Service
  258. const execution = yield* executeTool(registry, {
  259. sessionID,
  260. ...toolIdentity,
  261. call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
  262. })
  263. expect(execution.status).toBe("completed")
  264. if (execution.status !== "completed") return
  265. expect(execution.content).toEqual([
  266. { type: "text", text: "Image read successfully" },
  267. { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
  268. ])
  269. expect(readCalls).toEqual([
  270. {
  271. input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")),
  272. page: { offset: undefined, limit: undefined },
  273. },
  274. ])
  275. const settled = yield* executeTool(registry, {
  276. sessionID,
  277. ...toolIdentity,
  278. call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
  279. })
  280. expect(settled.status).toBe("completed")
  281. if (settled.status !== "completed") return
  282. // Image base64 is carried by the content file item only; read produces no
  283. // metadata, so the original bytes are never persisted twice.
  284. expect(settled.metadata).toBeUndefined()
  285. expect(settled.content).toMatchObject([
  286. { type: "text", text: "Image read successfully" },
  287. { type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
  288. ])
  289. }),
  290. )
  291. it.effect("preserves a PNG above the generic text limit as native media", () =>
  292. Effect.gen(function* () {
  293. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  294. const pixels = Uint8Array.from({ length: 256 * 256 * 4 }, (_, index) => (index * 73 + (index >> 3)) % 256)
  295. const source = new photon.PhotonImage(pixels, 256, 256)
  296. const png = Buffer.from(source.get_bytes()).toString("base64")
  297. source.free()
  298. expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024)
  299. readResult = {
  300. type: "file",
  301. uri: "file:///large.png",
  302. name: "large.png",
  303. content: png,
  304. encoding: "base64",
  305. mime: "image/png",
  306. }
  307. const registry = yield* Tool.Service
  308. const settled = yield* executeTool(registry, {
  309. sessionID,
  310. ...toolIdentity,
  311. call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } },
  312. })
  313. expect(settled.status).toBe("completed")
  314. if (settled.status !== "completed") return
  315. expect(settled.output).toMatchObject({
  316. uri: "file:///large.png",
  317. name: "large.png",
  318. mime: "image/png",
  319. encoding: "base64",
  320. })
  321. expect(settled.content).toEqual([
  322. { type: "text", text: "Image read successfully" },
  323. { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
  324. ])
  325. }),
  326. )
  327. itWithoutResizer.effect("returns the original image when the resizer is unavailable", () =>
  328. Effect.gen(function* () {
  329. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  330. readResult = {
  331. type: "file",
  332. uri: "file:///pixel.png",
  333. name: "pixel.png",
  334. content: png,
  335. encoding: "base64",
  336. mime: "image/png",
  337. }
  338. const registry = yield* Tool.Service
  339. expect(
  340. yield* executeTool(registry, {
  341. sessionID,
  342. ...toolIdentity,
  343. call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } },
  344. }),
  345. ).toMatchObject({
  346. status: "completed",
  347. content: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }],
  348. })
  349. }),
  350. )
  351. it.effect("drops undecodable image data from the outcome", () =>
  352. Effect.gen(function* () {
  353. readResult = {
  354. type: "file",
  355. uri: "file:///truncated.png",
  356. name: "truncated.png",
  357. content: "iVBORw0KGgo=",
  358. encoding: "base64",
  359. mime: "image/png",
  360. }
  361. const registry = yield* Tool.Service
  362. expect(
  363. yield* executeTool(registry, {
  364. sessionID,
  365. ...toolIdentity,
  366. call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
  367. }),
  368. ).toMatchObject({
  369. status: "completed",
  370. content: [
  371. { type: "text", text: "Image read successfully" },
  372. { type: "text", text: "[1 image omitted: could not be decoded.]" },
  373. ],
  374. })
  375. }),
  376. )
  377. it.effect("drops oversized images from the outcome when resizing is disabled", () =>
  378. Effect.gen(function* () {
  379. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  380. const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
  381. const base64 = Buffer.from(source.get_bytes()).toString("base64")
  382. source.free()
  383. readResult = {
  384. type: "file",
  385. uri: "file:///wide.png",
  386. name: "wide.png",
  387. content: base64,
  388. encoding: "base64",
  389. mime: "image/png",
  390. }
  391. configEntries = [
  392. new Config.Document({
  393. type: "document",
  394. info: new Config.Info({
  395. attachments: new ConfigAttachments.Info({
  396. image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
  397. }),
  398. }),
  399. }),
  400. ]
  401. const registry = yield* Tool.Service
  402. expect(
  403. yield* executeTool(registry, {
  404. sessionID,
  405. ...toolIdentity,
  406. call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
  407. }),
  408. ).toMatchObject({
  409. status: "completed",
  410. content: [
  411. { type: "text", text: "Image read successfully" },
  412. { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
  413. ],
  414. })
  415. }),
  416. )
  417. it.effect("resizes images to configured dimensions before returning media", () =>
  418. Effect.gen(function* () {
  419. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  420. const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
  421. const base64 = Buffer.from(source.get_bytes()).toString("base64")
  422. source.free()
  423. readResult = {
  424. type: "file",
  425. uri: "file:///wide.png",
  426. name: "wide.png",
  427. content: base64,
  428. encoding: "base64",
  429. mime: "image/png",
  430. }
  431. configEntries = [
  432. new Config.Document({
  433. type: "document",
  434. info: new Config.Info({
  435. attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
  436. }),
  437. }),
  438. ]
  439. const registry = yield* Tool.Service
  440. const result = yield* executeTool(registry, {
  441. sessionID,
  442. ...toolIdentity,
  443. call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
  444. })
  445. expect(result.status).toBe("completed")
  446. if (result.status !== "completed") return
  447. const media = result.content?.[1]
  448. expect(media?.type).toBe("file")
  449. if (media?.type !== "file") return
  450. const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.uri.split(",")[1] ?? "", "base64"))
  451. expect(resized.get_width()).toBeLessThanOrEqual(4)
  452. expect(resized.get_height()).toBeLessThanOrEqual(2_000)
  453. resized.free()
  454. }),
  455. )
  456. it.effect("drops images that cannot fit max base64 bytes after resize attempts", () =>
  457. Effect.gen(function* () {
  458. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  459. readResult = {
  460. type: "file",
  461. uri: "file:///pixel.png",
  462. name: "pixel.png",
  463. content: png,
  464. encoding: "base64",
  465. mime: "image/png",
  466. }
  467. configEntries = [
  468. new Config.Document({
  469. type: "document",
  470. info: new Config.Info({
  471. attachments: new ConfigAttachments.Info({
  472. image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
  473. }),
  474. }),
  475. }),
  476. ]
  477. const registry = yield* Tool.Service
  478. expect(
  479. yield* executeTool(registry, {
  480. sessionID,
  481. ...toolIdentity,
  482. call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
  483. }),
  484. ).toMatchObject({
  485. status: "completed",
  486. content: [
  487. { type: "text", text: "Image read successfully" },
  488. { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
  489. ],
  490. })
  491. }),
  492. )
  493. it.effect("returns supported image contents despite a misleading binary extension", () =>
  494. Effect.gen(function* () {
  495. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  496. readResult = {
  497. type: "file",
  498. uri: "file:///pixel.bin",
  499. name: "pixel.bin",
  500. content: png,
  501. encoding: "base64",
  502. mime: "image/png",
  503. }
  504. const registry = yield* Tool.Service
  505. expect(
  506. yield* executeTool(registry, {
  507. sessionID,
  508. ...toolIdentity,
  509. call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
  510. }),
  511. ).toMatchObject({
  512. status: "completed",
  513. content: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }],
  514. })
  515. }),
  516. )
  517. it.effect("returns PDFs as native media", () =>
  518. Effect.gen(function* () {
  519. const pdf = "JVBERi0xLjcK"
  520. readResult = {
  521. type: "file",
  522. uri: "file:///document.pdf",
  523. name: "document.pdf",
  524. content: pdf,
  525. encoding: "base64",
  526. mime: "application/pdf",
  527. }
  528. const registry = yield* Tool.Service
  529. expect(
  530. yield* executeTool(registry, {
  531. sessionID,
  532. ...toolIdentity,
  533. call: { type: "tool-call", id: "call-pdf", name: "read", input: { path: "document.pdf" } },
  534. }),
  535. ).toMatchObject({
  536. status: "completed",
  537. content: [
  538. { type: "text", text: "PDF read successfully" },
  539. { type: "file", uri: `data:application/pdf;base64,${pdf}`, mime: "application/pdf", name: "document.pdf" },
  540. ],
  541. })
  542. }),
  543. )
  544. it.effect("returns expected filesystem failures to the model", () =>
  545. Effect.gen(function* () {
  546. readFailure = new ReadToolFileSystem.BinaryFileError({ resource: "archive.dat" })
  547. const registry = yield* Tool.Service
  548. expect(
  549. yield* executeTool(registry, {
  550. sessionID,
  551. ...toolIdentity,
  552. call: {
  553. type: "tool-call",
  554. id: "call-binary",
  555. name: "read",
  556. input: { path: "archive.dat", offset: 2, limit: 1 },
  557. },
  558. }),
  559. ).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: archive.dat" } })
  560. expect(readCalls).toEqual([
  561. { input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } },
  562. ])
  563. }),
  564. )
  565. it.effect("preserves actionable read failure messages", () =>
  566. Effect.gen(function* () {
  567. const registry = yield* Tool.Service
  568. for (const [error, message] of [
  569. [
  570. new ReadToolFileSystem.MalformedUtf8Error({ resource: "invalid.txt" }),
  571. "File is not valid UTF-8: invalid.txt",
  572. ],
  573. [new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
  574. [
  575. new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
  576. "Path is not a file: socket",
  577. ],
  578. ] as const) {
  579. readFailure = error
  580. expect(
  581. yield* executeTool(registry, {
  582. sessionID,
  583. ...toolIdentity,
  584. call: { type: "tool-call", id: `call-${error._tag}`, name: "read", input: { path: "target" } },
  585. }),
  586. ).toEqual({ status: "error", error: { type: "unknown", message } })
  587. }
  588. }),
  589. )
  590. it.effect("preserves unexpected filesystem defects", () =>
  591. Effect.gen(function* () {
  592. resolveFailure = new Error("unexpected")
  593. const registry = yield* Tool.Service
  594. expect(
  595. Exit.isFailure(
  596. yield* executeTool(registry, {
  597. sessionID,
  598. ...toolIdentity,
  599. call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
  600. }).pipe(Effect.exit),
  601. ),
  602. ).toBe(true)
  603. }),
  604. )
  605. it.effect("does not read when permission is denied", () =>
  606. Effect.gen(function* () {
  607. allow = false
  608. const registry = yield* Tool.Service
  609. expect(
  610. yield* executeTool(registry, {
  611. sessionID,
  612. ...toolIdentity,
  613. call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
  614. }),
  615. ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
  616. expect(readCalls).toEqual([])
  617. }),
  618. )
  619. it.effect("returns missing paths as model-visible tool failures", () =>
  620. Effect.gen(function* () {
  621. const registry = yield* Tool.Service
  622. expect(
  623. yield* executeTool(registry, {
  624. sessionID,
  625. ...toolIdentity,
  626. call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } },
  627. }),
  628. // The message-less PathError cause must not erase the tool's curated
  629. // failure message; the canonical error is the sole authority.
  630. ).toEqual({ status: "error", error: { type: "tool.execution", message: `Unable to read ${missingPath}` } })
  631. expect(assertions).toEqual([])
  632. expect(readCalls).toEqual([])
  633. }),
  634. )
  635. it.effect("lists a bounded directory page through read", () =>
  636. Effect.gen(function* () {
  637. resolvedType = "directory"
  638. const registry = yield* Tool.Service
  639. expect(
  640. yield* executeTool(registry, {
  641. sessionID,
  642. ...toolIdentity,
  643. call: {
  644. type: "tool-call",
  645. id: "call-read-directory",
  646. name: "read",
  647. input: { path: "src", offset: 2, limit: 10 },
  648. },
  649. }),
  650. ).toMatchObject({ status: "completed", output: { entries: [], truncated: false } })
  651. expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
  652. expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
  653. }),
  654. )
  655. it.effect("does not list a directory when permission is denied", () =>
  656. Effect.gen(function* () {
  657. allow = false
  658. resolvedType = "directory"
  659. const registry = yield* Tool.Service
  660. expect(
  661. yield* executeTool(registry, {
  662. sessionID,
  663. ...toolIdentity,
  664. call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
  665. }),
  666. ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
  667. expect(listCalls).toEqual([])
  668. }),
  669. )
  670. it.effect("preserves unexpected resolution defects", () =>
  671. Effect.gen(function* () {
  672. const registry = yield* Tool.Service
  673. resolveFailure = new Error("missing")
  674. expect(
  675. Exit.isFailure(
  676. yield* executeTool(registry, {
  677. sessionID,
  678. ...toolIdentity,
  679. call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
  680. }).pipe(Effect.exit),
  681. ),
  682. ).toBe(true)
  683. expect(readCalls).toEqual([])
  684. }),
  685. )
  686. it.effect("forwards pagination and returns bounded text pages with continuation", () =>
  687. Effect.gen(function* () {
  688. readResult = new ReadToolFileSystem.TextPage({
  689. type: "text-page",
  690. content: "hello",
  691. mime: "text/plain",
  692. offset: 2,
  693. truncated: true,
  694. next: 3,
  695. })
  696. const registry = yield* Tool.Service
  697. expect(
  698. yield* executeTool(registry, {
  699. sessionID,
  700. ...toolIdentity,
  701. call: {
  702. type: "tool-call",
  703. id: "call-large",
  704. name: "read",
  705. input: { path: "large.txt", offset: 2, limit: 1 },
  706. },
  707. }),
  708. ).toMatchObject({
  709. status: "completed",
  710. output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
  711. })
  712. expect(readCalls).toEqual([
  713. { input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
  714. ])
  715. }),
  716. )
  717. it.effect("rejects unsupported binary discovered by a direct read", () =>
  718. Effect.gen(function* () {
  719. readResult = {
  720. type: "file",
  721. uri: "file:///late-binary",
  722. name: "late-binary",
  723. content: "AAECAw==",
  724. encoding: "base64",
  725. mime: "application/octet-stream",
  726. }
  727. const registry = yield* Tool.Service
  728. expect(
  729. yield* executeTool(registry, {
  730. sessionID,
  731. ...toolIdentity,
  732. call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
  733. }),
  734. ).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: late-binary" } })
  735. }),
  736. )
  737. })