tool-read.test.ts 29 KB

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