tool-read.test.ts 28 KB

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