tool-read.test.ts 24 KB

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