tool-read.test.ts 29 KB

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