tool-read.test.ts 23 KB

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