tool-read.test.ts 23 KB

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