read.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { Cause, Effect, Exit, Layer, Stream } from "effect"
  3. import path from "path"
  4. import { Agent } from "../../src/agent/agent"
  5. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  6. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  7. import { Global } from "@opencode-ai/core/global"
  8. import { Config } from "@/config/config"
  9. import { RuntimeFlags } from "@/effect/runtime-flags"
  10. import { LSP } from "@/lsp/lsp"
  11. import { Permission } from "../../src/permission"
  12. import { SessionID, MessageID } from "../../src/session/schema"
  13. import { Instruction } from "../../src/session/instruction"
  14. import { ReadTool } from "../../src/tool/read"
  15. import { Truncate } from "@/tool/truncate"
  16. import { Tool } from "@/tool/tool"
  17. import { Filesystem } from "@/util/filesystem"
  18. import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
  19. import { testEffect } from "../lib/effect"
  20. import { Reference } from "@/reference/reference"
  21. import { RepositoryCache } from "@/reference/repository-cache"
  22. const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
  23. afterEach(async () => {
  24. await disposeAllInstances()
  25. })
  26. const ctx = {
  27. sessionID: SessionID.make("ses_test"),
  28. messageID: MessageID.make("msg_test"),
  29. callID: "",
  30. agent: "build",
  31. abort: AbortSignal.any([]),
  32. messages: [],
  33. metadata: () => Effect.void,
  34. ask: () => Effect.void,
  35. }
  36. const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
  37. Reference.layer.pipe(
  38. Layer.provide(Config.defaultLayer),
  39. Layer.provide(RepositoryCache.defaultLayer),
  40. Layer.provide(RuntimeFlags.layer(flags)),
  41. )
  42. const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
  43. Layer.mergeAll(
  44. Agent.defaultLayer,
  45. AppFileSystem.defaultLayer,
  46. CrossSpawnSpawner.defaultLayer,
  47. Instruction.defaultLayer,
  48. LSP.defaultLayer,
  49. referenceLayer(flags),
  50. Truncate.defaultLayer,
  51. )
  52. const it = testEffect(readLayer())
  53. const scout = testEffect(readLayer({ experimentalScout: true }))
  54. const init = Effect.fn("ReadToolTest.init")(function* () {
  55. const info = yield* ReadTool
  56. return yield* info.init()
  57. })
  58. const run = Effect.fn("ReadToolTest.run")(function* (
  59. args: Tool.InferParameters<typeof ReadTool>,
  60. next: Tool.Context = ctx,
  61. ) {
  62. const tool = yield* init()
  63. return yield* tool.execute(args, next)
  64. })
  65. const exec = Effect.fn("ReadToolTest.exec")(function* (
  66. dir: string,
  67. args: Tool.InferParameters<typeof ReadTool>,
  68. next: Tool.Context = ctx,
  69. ) {
  70. return yield* provideInstance(dir)(run(args, next))
  71. })
  72. const fail = Effect.fn("ReadToolTest.fail")(function* (
  73. dir: string,
  74. args: Tool.InferParameters<typeof ReadTool>,
  75. next: Tool.Context = ctx,
  76. ) {
  77. const exit = yield* exec(dir, args, next).pipe(Effect.exit)
  78. if (Exit.isFailure(exit)) {
  79. const err = Cause.squash(exit.cause)
  80. return err instanceof Error ? err : new Error(String(err))
  81. }
  82. throw new Error("expected read to fail")
  83. })
  84. const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
  85. const glob = (p: string) =>
  86. process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
  87. const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
  88. Effect.acquireUseRelease(
  89. Effect.sync(() => {
  90. const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  91. process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
  92. return previous
  93. }),
  94. () => self,
  95. (previous) =>
  96. Effect.sync(() => {
  97. if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
  98. else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  99. }),
  100. )
  101. const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) {
  102. return yield* Effect.promise(async () => {
  103. const proc = Bun.spawn(["git", ...args], {
  104. cwd,
  105. stdout: "pipe",
  106. stderr: "pipe",
  107. })
  108. const [stdout, stderr, code] = await Promise.all([
  109. new Response(proc.stdout).text(),
  110. new Response(proc.stderr).text(),
  111. proc.exited,
  112. ])
  113. if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
  114. return stdout.trim()
  115. })
  116. })
  117. const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
  118. const fs = yield* AppFileSystem.Service
  119. yield* fs.writeWithDirs(p, content)
  120. })
  121. const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
  122. const fs = yield* AppFileSystem.Service
  123. return yield* fs.readFileString(p)
  124. })
  125. const asks = () => {
  126. const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  127. return {
  128. items,
  129. next: {
  130. ...ctx,
  131. ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
  132. Effect.sync(() => {
  133. items.push(req)
  134. }),
  135. },
  136. }
  137. }
  138. describe("tool.read external_directory permission", () => {
  139. it.live("allows reading absolute path inside project directory", () =>
  140. Effect.gen(function* () {
  141. const dir = yield* tmpdirScoped()
  142. yield* put(path.join(dir, "test.txt"), "hello world")
  143. const result = yield* exec(dir, { filePath: path.join(dir, "test.txt") })
  144. expect(result.output).toContain("hello world")
  145. }),
  146. )
  147. it.live("allows reading file in subdirectory inside project directory", () =>
  148. Effect.gen(function* () {
  149. const dir = yield* tmpdirScoped()
  150. yield* put(path.join(dir, "subdir", "test.txt"), "nested content")
  151. const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "test.txt") })
  152. expect(result.output).toContain("nested content")
  153. }),
  154. )
  155. it.live("asks for external_directory permission when reading absolute path outside project", () =>
  156. Effect.gen(function* () {
  157. const outer = yield* tmpdirScoped()
  158. const dir = yield* tmpdirScoped({ git: true })
  159. yield* put(path.join(outer, "secret.txt"), "secret data")
  160. const { items, next } = asks()
  161. yield* exec(dir, { filePath: path.join(outer, "secret.txt") }, next)
  162. const ext = items.find((item) => item.permission === "external_directory")
  163. expect(ext).toBeDefined()
  164. expect(ext!.patterns).toContain(glob(path.join(outer, "*")))
  165. }),
  166. )
  167. if (process.platform === "win32") {
  168. it.live("normalizes read permission paths on Windows", () =>
  169. Effect.gen(function* () {
  170. const dir = yield* tmpdirScoped({ git: true })
  171. yield* put(path.join(dir, "test.txt"), "hello world")
  172. const { items, next } = asks()
  173. const target = path.join(dir, "test.txt")
  174. const alt = target
  175. .replace(/^[A-Za-z]:/, "")
  176. .replaceAll("\\", "/")
  177. .toLowerCase()
  178. yield* exec(dir, { filePath: alt }, next)
  179. const read = items.find((item) => item.permission === "read")
  180. expect(read).toBeDefined()
  181. expect(read!.patterns).toEqual([path.relative(dir, full(target))])
  182. }),
  183. )
  184. }
  185. it.live("uses worktree-relative path for read permission so user rules match like edit/write", () =>
  186. Effect.gen(function* () {
  187. const dir = yield* tmpdirScoped({ git: true })
  188. yield* put(path.join(dir, "src", "secret.ts"), "shh")
  189. const { items, next } = asks()
  190. yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next)
  191. const read = items.find((item) => item.permission === "read")
  192. expect(read).toBeDefined()
  193. expect(read!.patterns).toEqual([path.join("src", "secret.ts")])
  194. }),
  195. )
  196. it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
  197. Effect.gen(function* () {
  198. const outer = yield* tmpdirScoped()
  199. const dir = yield* tmpdirScoped({ git: true })
  200. yield* put(path.join(outer, "external", "a.txt"), "a")
  201. const { items, next } = asks()
  202. yield* exec(dir, { filePath: path.join(outer, "external") }, next)
  203. const ext = items.find((item) => item.permission === "external_directory")
  204. expect(ext).toBeDefined()
  205. expect(ext!.patterns).toContain(glob(path.join(outer, "external", "*")))
  206. }),
  207. )
  208. it.live("asks for external_directory permission when reading relative path outside project", () =>
  209. Effect.gen(function* () {
  210. const dir = yield* tmpdirScoped({ git: true })
  211. const { items, next } = asks()
  212. yield* fail(dir, { filePath: "../outside.txt" }, next)
  213. const ext = items.find((item) => item.permission === "external_directory")
  214. expect(ext).toBeDefined()
  215. }),
  216. )
  217. it.live("does not ask for external_directory permission when reading inside project", () =>
  218. Effect.gen(function* () {
  219. const dir = yield* tmpdirScoped({ git: true })
  220. yield* put(path.join(dir, "internal.txt"), "internal content")
  221. const { items, next } = asks()
  222. yield* exec(dir, { filePath: path.join(dir, "internal.txt") }, next)
  223. const ext = items.find((item) => item.permission === "external_directory")
  224. expect(ext).toBeUndefined()
  225. }),
  226. )
  227. scout.live("does not ask for external_directory permission when reading configured references", () =>
  228. Effect.gen(function* () {
  229. const fs = yield* AppFileSystem.Service
  230. const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo")
  231. yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
  232. yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
  233. const source = yield* tmpdirScoped({ git: true })
  234. const remoteRoot = yield* tmpdirScoped()
  235. const remoteDir = path.join(remoteRoot, "opencode-read-reference")
  236. const remoteRepo = path.join(remoteDir, "repo.git")
  237. yield* put(path.join(source, "notes.md"), "reference notes")
  238. yield* git(source, ["add", "."])
  239. yield* git(source, ["commit", "-m", "add notes"])
  240. yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
  241. yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
  242. const dir = yield* tmpdirScoped({
  243. git: true,
  244. config: {
  245. reference: {
  246. docs: "opencode-read-reference/repo",
  247. },
  248. },
  249. })
  250. const { items, next } = asks()
  251. const result = yield* githubBase(
  252. `file://${remoteRoot}/`,
  253. exec(dir, { filePath: path.join(cache, "notes.md") }, next),
  254. )
  255. const ext = items.find((item) => item.permission === "external_directory")
  256. expect(result.output).toContain("reference notes")
  257. expect(ext).toBeUndefined()
  258. }),
  259. )
  260. })
  261. describe("tool.read env file permissions", () => {
  262. const cases: [string, boolean][] = [
  263. [".env", true],
  264. [".env.local", true],
  265. [".env.production", true],
  266. [".env.development.local", true],
  267. [".env.example", false],
  268. [".envrc", false],
  269. ["environment.ts", false],
  270. ]
  271. for (const agentName of ["build", "plan"] as const) {
  272. describe(`agent=${agentName}`, () => {
  273. for (const [filename, shouldAsk] of cases) {
  274. it.live(`${filename} asks=${shouldAsk}`, () =>
  275. Effect.gen(function* () {
  276. const dir = yield* tmpdirScoped()
  277. yield* put(path.join(dir, filename), "content")
  278. const asked = yield* provideInstance(dir)(
  279. Effect.gen(function* () {
  280. const agent = yield* Agent.Service
  281. const info = yield* agent.get(agentName)
  282. let asked = false
  283. const next = {
  284. ...ctx,
  285. ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
  286. Effect.sync(() => {
  287. for (const pattern of req.patterns) {
  288. const rule = Permission.evaluate(req.permission, pattern, info.permission)
  289. if (rule.action === "ask" && req.permission === "read") {
  290. asked = true
  291. }
  292. if (rule.action === "deny") {
  293. throw new Permission.DeniedError({ ruleset: info.permission })
  294. }
  295. }
  296. }),
  297. }
  298. yield* run({ filePath: path.join(dir, filename) }, next)
  299. return asked
  300. }),
  301. )
  302. expect(asked).toBe(shouldAsk)
  303. }),
  304. )
  305. }
  306. })
  307. }
  308. })
  309. describe("tool.read truncation", () => {
  310. it.instance("truncates large file by bytes and sets truncated metadata", () =>
  311. Effect.gen(function* () {
  312. const test = yield* TestInstance
  313. const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
  314. const target = 60 * 1024
  315. const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
  316. yield* put(path.join(test.directory, "large.json"), content)
  317. const result = yield* run({ filePath: path.join(test.directory, "large.json") })
  318. expect(result.metadata.truncated).toBe(true)
  319. expect(result.output).toContain("Output capped at")
  320. expect(result.output).toContain("Use offset=")
  321. }),
  322. )
  323. it.instance("stops streaming after the byte cap", () =>
  324. Effect.gen(function* () {
  325. const test = yield* TestInstance
  326. const filepath = path.join(test.directory, "huge.txt")
  327. const content = `${"x".repeat(80)}\n`.repeat(50_000)
  328. yield* put(filepath, content)
  329. const fs = yield* AppFileSystem.Service
  330. const counter = { bytes: 0 }
  331. const result = yield* run({ filePath: filepath }).pipe(
  332. Effect.provideService(
  333. AppFileSystem.Service,
  334. AppFileSystem.Service.of({
  335. ...fs,
  336. stream: (file, options) =>
  337. fs.stream(file, options).pipe(
  338. Stream.tap((chunk) =>
  339. Effect.sync(() => {
  340. counter.bytes += chunk.length
  341. }),
  342. ),
  343. ),
  344. }),
  345. ),
  346. )
  347. expect(result.metadata.truncated).toBe(true)
  348. expect(result.output).toContain("Output capped at")
  349. expect(counter.bytes).toBeLessThan(Buffer.byteLength(content, "utf-8") / 2)
  350. }),
  351. )
  352. it.instance("truncates by line count when limit is specified", () =>
  353. Effect.gen(function* () {
  354. const test = yield* TestInstance
  355. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  356. yield* put(path.join(test.directory, "many-lines.txt"), lines)
  357. const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 })
  358. expect(result.metadata.truncated).toBe(true)
  359. expect(result.output).toContain("Showing lines 1-10 of 100")
  360. expect(result.output).toContain("Use offset=11")
  361. expect(result.output).toContain("line0")
  362. expect(result.output).toContain("line9")
  363. expect(result.output).not.toContain("line10")
  364. }),
  365. )
  366. it.instance("does not truncate small file", () =>
  367. Effect.gen(function* () {
  368. const test = yield* TestInstance
  369. yield* put(path.join(test.directory, "small.txt"), "hello world")
  370. const result = yield* run({ filePath: path.join(test.directory, "small.txt") })
  371. expect(result.metadata.truncated).toBe(false)
  372. expect(result.output).toContain("End of file")
  373. }),
  374. )
  375. it.live("respects offset parameter", () =>
  376. Effect.gen(function* () {
  377. const dir = yield* tmpdirScoped()
  378. const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
  379. yield* put(path.join(dir, "offset.txt"), lines)
  380. const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
  381. expect(result.output).toContain("10: line10")
  382. expect(result.output).toContain("14: line14")
  383. expect(result.output).not.toContain("9: line10")
  384. expect(result.output).not.toContain("15: line15")
  385. expect(result.output).toContain("line10")
  386. expect(result.output).toContain("line14")
  387. expect(result.output).not.toContain("line0")
  388. expect(result.output).not.toContain("line15")
  389. }),
  390. )
  391. it.live("throws when offset is beyond end of file", () =>
  392. Effect.gen(function* () {
  393. const dir = yield* tmpdirScoped()
  394. const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
  395. yield* put(path.join(dir, "short.txt"), lines)
  396. const err = yield* fail(dir, { filePath: path.join(dir, "short.txt"), offset: 4, limit: 5 })
  397. expect(err.message).toContain("Offset 4 is out of range for this file (3 lines)")
  398. }),
  399. )
  400. it.live("allows reading empty file at default offset", () =>
  401. Effect.gen(function* () {
  402. const dir = yield* tmpdirScoped()
  403. yield* put(path.join(dir, "empty.txt"), "")
  404. const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
  405. expect(result.metadata.truncated).toBe(false)
  406. expect(result.output).toContain("End of file - total 0 lines")
  407. }),
  408. )
  409. it.live("throws when offset > 1 for empty file", () =>
  410. Effect.gen(function* () {
  411. const dir = yield* tmpdirScoped()
  412. yield* put(path.join(dir, "empty.txt"), "")
  413. const err = yield* fail(dir, { filePath: path.join(dir, "empty.txt"), offset: 2 })
  414. expect(err.message).toContain("Offset 2 is out of range for this file (0 lines)")
  415. }),
  416. )
  417. it.live("does not mark final directory page as truncated", () =>
  418. Effect.gen(function* () {
  419. const dir = yield* tmpdirScoped()
  420. yield* Effect.forEach(
  421. Array.from({ length: 10 }, (_, i) => i),
  422. (i) => put(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`),
  423. {
  424. concurrency: "unbounded",
  425. },
  426. )
  427. const result = yield* exec(dir, { filePath: path.join(dir, "dir"), offset: 6, limit: 5 })
  428. expect(result.metadata.truncated).toBe(false)
  429. expect(result.output).not.toContain("Showing 5 of 10 entries")
  430. }),
  431. )
  432. it.live("truncates long lines", () =>
  433. Effect.gen(function* () {
  434. const dir = yield* tmpdirScoped()
  435. yield* put(path.join(dir, "long-line.txt"), "x".repeat(3000))
  436. const result = yield* exec(dir, { filePath: path.join(dir, "long-line.txt") })
  437. expect(result.output).toContain("(line truncated to 2000 chars)")
  438. expect(result.output.length).toBeLessThan(3000)
  439. }),
  440. )
  441. it.live("image files set truncated to false", () =>
  442. Effect.gen(function* () {
  443. const dir = yield* tmpdirScoped()
  444. const png = Buffer.from(
  445. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
  446. "base64",
  447. )
  448. yield* put(path.join(dir, "image.png"), png)
  449. const result = yield* exec(dir, { filePath: path.join(dir, "image.png") })
  450. expect(result.metadata.truncated).toBe(false)
  451. expect(result.attachments).toBeDefined()
  452. expect(result.attachments?.length).toBe(1)
  453. expect(result.attachments?.[0]).not.toHaveProperty("id")
  454. expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
  455. expect(result.attachments?.[0]).not.toHaveProperty("messageID")
  456. }),
  457. )
  458. it.live("detects attachment media from file contents", () =>
  459. Effect.gen(function* () {
  460. const dir = yield* tmpdirScoped()
  461. const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01])
  462. yield* put(path.join(dir, "image.bin"), jpeg)
  463. const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") })
  464. expect(result.output).toBe("Image read successfully")
  465. expect(result.attachments?.[0].mime).toBe("image/jpeg")
  466. expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true)
  467. }),
  468. )
  469. it.live("large image files are properly attached without error", () =>
  470. Effect.gen(function* () {
  471. const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
  472. expect(result.metadata.truncated).toBe(false)
  473. expect(result.attachments).toBeDefined()
  474. expect(result.attachments?.length).toBe(1)
  475. expect(result.attachments?.[0].type).toBe("file")
  476. expect(result.attachments?.[0]).not.toHaveProperty("id")
  477. expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
  478. expect(result.attachments?.[0]).not.toHaveProperty("messageID")
  479. }),
  480. )
  481. it.live(".fbs files (FlatBuffers schema) are read as text, not images", () =>
  482. Effect.gen(function* () {
  483. const dir = yield* tmpdirScoped()
  484. const fbs = `namespace MyGame;
  485. table Monster {
  486. pos:Vec3;
  487. name:string;
  488. inventory:[ubyte];
  489. }
  490. root_type Monster;`
  491. yield* put(path.join(dir, "schema.fbs"), fbs)
  492. const result = yield* exec(dir, { filePath: path.join(dir, "schema.fbs") })
  493. expect(result.attachments).toBeUndefined()
  494. expect(result.output).toContain("namespace MyGame")
  495. expect(result.output).toContain("table Monster")
  496. }),
  497. )
  498. it.live("falls through unsupported image mime types to text", () =>
  499. Effect.gen(function* () {
  500. const dir = yield* tmpdirScoped()
  501. const cases = [
  502. ["image.bmp", "BM text content"],
  503. ["photo.tiff", "II text content"],
  504. ["photo.avif", "avif text content"],
  505. ] as const
  506. for (const item of cases) {
  507. yield* put(path.join(dir, item[0]), item[1])
  508. const result = yield* exec(dir, { filePath: path.join(dir, item[0]) })
  509. expect(result.attachments).toBeUndefined()
  510. expect(result.output).toContain(item[1])
  511. }
  512. }),
  513. )
  514. })
  515. describe("tool.read loaded instructions", () => {
  516. it.live("loads AGENTS.md from parent directory and includes in metadata", () =>
  517. Effect.gen(function* () {
  518. const dir = yield* tmpdirScoped()
  519. yield* put(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
  520. yield* put(path.join(dir, "subdir", "nested", "test.txt"), "test content")
  521. const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "test.txt") })
  522. expect(result.output).toContain("test content")
  523. expect(result.output).toContain("system-reminder")
  524. expect(result.output).toContain("Test Instructions")
  525. expect(result.metadata.loaded).toBeDefined()
  526. expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
  527. }),
  528. )
  529. })
  530. describe("tool.read binary detection", () => {
  531. it.live("rejects text extension files with null bytes", () =>
  532. Effect.gen(function* () {
  533. const dir = yield* tmpdirScoped()
  534. const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
  535. yield* put(path.join(dir, "null-byte.txt"), bytes)
  536. const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") })
  537. expect(err.message).toContain("Cannot read binary file")
  538. }),
  539. )
  540. it.live("rejects known binary extensions", () =>
  541. Effect.gen(function* () {
  542. const dir = yield* tmpdirScoped()
  543. yield* put(path.join(dir, "module.wasm"), "not really wasm")
  544. const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") })
  545. expect(err.message).toContain("Cannot read binary file")
  546. }),
  547. )
  548. })