read.test.ts 21 KB

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