| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608 |
- import { PermissionV1 } from "@kirincode-ai/core/v1/permission"
- import { afterEach, describe, expect } from "bun:test"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { Cause, Effect, Exit, Layer, Stream } from "effect"
- import path from "path"
- import { Agent } from "../../src/agent/agent"
- import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
- import { FSUtil } from "@kirincode-ai/core/fs-util"
- import { Global } from "@kirincode-ai/core/global"
- import { Config } from "@/config/config"
- import { RuntimeFlags } from "@/effect/runtime-flags"
- import { Ripgrep } from "@kirincode-ai/core/ripgrep"
- import { LSP } from "@/lsp/lsp"
- import { Permission } from "../../src/permission"
- import { SessionID, MessageID } from "../../src/session/schema"
- import { Instruction } from "../../src/session/instruction"
- import { ReadTool } from "../../src/tool/read"
- import { Truncate } from "@/tool/truncate"
- import { Tool } from "@/tool/tool"
- import { Filesystem } from "@/util/filesystem"
- import {
- disposeAllInstances,
- provideInstance,
- testInstanceStoreLayer,
- TestInstance,
- tmpdirScoped,
- } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
- afterEach(async () => {
- await disposeAllInstances()
- })
- const ctx = {
- sessionID: SessionID.make("ses_test"),
- messageID: MessageID.make("msg_test"),
- callID: "",
- agent: "build",
- abort: AbortSignal.any([]),
- messages: [],
- metadata: () => Effect.void,
- ask: () => Effect.void,
- }
- const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
- LayerNode.compile(
- LayerNode.group([
- Agent.node,
- FSUtil.node,
- CrossSpawnSpawner.node,
- Instruction.node,
- LSP.node,
- Ripgrep.node,
- Truncate.node,
- ]),
- )
- const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer))
- const init = Effect.fn("ReadToolTest.init")(function* () {
- const info = yield* ReadTool
- return yield* info.init()
- })
- const run = Effect.fn("ReadToolTest.run")(function* (
- args: Tool.InferParameters<typeof ReadTool>,
- next: Tool.Context = ctx,
- ) {
- const tool = yield* init()
- return yield* tool.execute(args, next)
- })
- const exec = Effect.fn("ReadToolTest.exec")(function* (
- dir: string,
- args: Tool.InferParameters<typeof ReadTool>,
- next: Tool.Context = ctx,
- ) {
- return yield* provideInstance(dir)(run(args, next))
- })
- const fail = Effect.fn("ReadToolTest.fail")(function* (
- dir: string,
- args: Tool.InferParameters<typeof ReadTool>,
- next: Tool.Context = ctx,
- ) {
- const exit = yield* exec(dir, args, next).pipe(Effect.exit)
- if (Exit.isFailure(exit)) {
- const err = Cause.squash(exit.cause)
- return err instanceof Error ? err : new Error(String(err))
- }
- throw new Error("expected read to fail")
- })
- const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
- const glob = (p: string) =>
- process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
- const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
- Effect.acquireUseRelease(
- Effect.sync(() => {
- const previous = process.env.KIRINCODE_REPO_CLONE_GITHUB_BASE_URL
- process.env.KIRINCODE_REPO_CLONE_GITHUB_BASE_URL = url
- return previous
- }),
- () => self,
- (previous) =>
- Effect.sync(() => {
- if (previous) process.env.KIRINCODE_REPO_CLONE_GITHUB_BASE_URL = previous
- else delete process.env.KIRINCODE_REPO_CLONE_GITHUB_BASE_URL
- }),
- )
- const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) {
- return yield* Effect.promise(async () => {
- const proc = Bun.spawn(["git", ...args], {
- cwd,
- stdout: "pipe",
- stderr: "pipe",
- })
- const [stdout, stderr, code] = await Promise.all([
- new Response(proc.stdout).text(),
- new Response(proc.stderr).text(),
- proc.exited,
- ])
- if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
- return stdout.trim()
- })
- })
- const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
- const fs = yield* FSUtil.Service
- yield* fs.writeWithDirs(p, content)
- })
- const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
- const fs = yield* FSUtil.Service
- return yield* fs.readFileString(p)
- })
- const asks = () => {
- const items: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
- return {
- items,
- next: {
- ...ctx,
- ask: (req: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) =>
- Effect.sync(() => {
- items.push(req)
- }),
- },
- }
- }
- describe("tool.read external_directory permission", () => {
- it.live("allows reading absolute path inside project directory", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* put(path.join(dir, "test.txt"), "hello world")
- const result = yield* exec(dir, { filePath: path.join(dir, "test.txt") })
- expect(result.output).toContain("hello world")
- }),
- )
- it.live("allows reading file in subdirectory inside project directory", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* put(path.join(dir, "subdir", "test.txt"), "nested content")
- const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "test.txt") })
- expect(result.output).toContain("nested content")
- }),
- )
- it.live("asks for external_directory permission when reading absolute path outside project", () =>
- Effect.gen(function* () {
- const outer = yield* tmpdirScoped()
- const dir = yield* tmpdirScoped({ git: true })
- yield* put(path.join(outer, "secret.txt"), "secret data")
- const { items, next } = asks()
- yield* exec(dir, { filePath: path.join(outer, "secret.txt") }, next)
- const ext = items.find((item) => item.permission === "external_directory")
- expect(ext).toBeDefined()
- expect(ext!.patterns).toContain(glob(path.join(outer, "*")))
- }),
- )
- if (process.platform === "win32") {
- it.live("normalizes read permission paths on Windows", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped({ git: true })
- yield* put(path.join(dir, "test.txt"), "hello world")
- const { items, next } = asks()
- const target = path.join(dir, "test.txt")
- const alt = target
- .replace(/^[A-Za-z]:/, "")
- .replaceAll("\\", "/")
- .toLowerCase()
- yield* exec(dir, { filePath: alt }, next)
- const read = items.find((item) => item.permission === "read")
- expect(read).toBeDefined()
- expect(read!.patterns).toEqual([path.relative(dir, full(target))])
- }),
- )
- }
- it.live("uses worktree-relative path for read permission so user rules match like edit/write", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped({ git: true })
- yield* put(path.join(dir, "src", "secret.ts"), "shh")
- const { items, next } = asks()
- yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next)
- const read = items.find((item) => item.permission === "read")
- expect(read).toBeDefined()
- expect(read!.patterns).toEqual([path.join("src", "secret.ts")])
- }),
- )
- it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
- Effect.gen(function* () {
- const outer = yield* tmpdirScoped()
- const dir = yield* tmpdirScoped({ git: true })
- yield* put(path.join(outer, "external", "a.txt"), "a")
- const { items, next } = asks()
- yield* exec(dir, { filePath: path.join(outer, "external") }, next)
- const ext = items.find((item) => item.permission === "external_directory")
- expect(ext).toBeDefined()
- expect(ext!.patterns).toContain(glob(path.join(outer, "external", "*")))
- }),
- )
- it.live("asks for external_directory permission when reading relative path outside project", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped({ git: true })
- const { items, next } = asks()
- yield* fail(dir, { filePath: "../outside.txt" }, next)
- const ext = items.find((item) => item.permission === "external_directory")
- expect(ext).toBeDefined()
- }),
- )
- it.live("does not ask for external_directory permission when reading inside project", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped({ git: true })
- yield* put(path.join(dir, "internal.txt"), "internal content")
- const { items, next } = asks()
- yield* exec(dir, { filePath: path.join(dir, "internal.txt") }, next)
- const ext = items.find((item) => item.permission === "external_directory")
- expect(ext).toBeUndefined()
- }),
- )
- })
- describe("tool.read env file permissions", () => {
- const cases: [string, boolean][] = [
- [".env", true],
- [".env.local", true],
- [".env.production", true],
- [".env.development.local", true],
- [".env.example", false],
- [".envrc", false],
- ["environment.ts", false],
- ]
- for (const agentName of ["build", "plan"] as const) {
- describe(`agent=${agentName}`, () => {
- for (const [filename, shouldAsk] of cases) {
- it.live(`${filename} asks=${shouldAsk}`, () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* put(path.join(dir, filename), "content")
- const asked = yield* provideInstance(dir)(
- Effect.gen(function* () {
- const agent = yield* Agent.Service
- const info = yield* agent.get(agentName)
- let asked = false
- const next = {
- ...ctx,
- ask: (req: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) =>
- Effect.sync(() => {
- for (const pattern of req.patterns) {
- const rule = Permission.evaluate(req.permission, pattern, info.permission)
- if (rule.action === "ask" && req.permission === "read") {
- asked = true
- }
- if (rule.action === "deny") {
- throw new PermissionV1.DeniedError({ ruleset: info.permission })
- }
- }
- }),
- }
- yield* run({ filePath: path.join(dir, filename) }, next)
- return asked
- }),
- )
- expect(asked).toBe(shouldAsk)
- }),
- )
- }
- })
- }
- })
- describe("tool.read truncation", () => {
- it.instance("truncates large file by bytes and sets truncated metadata", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
- const target = 60 * 1024
- const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
- yield* put(path.join(test.directory, "large.json"), content)
- const result = yield* run({ filePath: path.join(test.directory, "large.json") })
- expect(result.metadata.truncated).toBe(true)
- expect(result.output).toContain("Output capped at")
- expect(result.output).toContain("Use offset=")
- }),
- )
- it.instance("stops streaming after the byte cap", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "huge.txt")
- const content = `${"x".repeat(80)}\n`.repeat(50_000)
- yield* put(filepath, content)
- const fs = yield* FSUtil.Service
- const counter = { bytes: 0 }
- const result = yield* run({ filePath: filepath }).pipe(
- Effect.provideService(
- FSUtil.Service,
- FSUtil.Service.of({
- ...fs,
- stream: (file, options) =>
- fs.stream(file, options).pipe(
- Stream.tap((chunk) =>
- Effect.sync(() => {
- counter.bytes += chunk.length
- }),
- ),
- ),
- }),
- ),
- )
- expect(result.metadata.truncated).toBe(true)
- expect(result.output).toContain("Output capped at")
- expect(counter.bytes).toBeLessThan(Buffer.byteLength(content, "utf-8") / 2)
- }),
- )
- it.instance("truncates by line count when limit is specified", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
- yield* put(path.join(test.directory, "many-lines.txt"), lines)
- const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 })
- expect(result.metadata.truncated).toBe(true)
- expect(result.output).toContain("Showing lines 1-10 of 100")
- expect(result.output).toContain("Use offset=11")
- expect(result.output).toContain("line0")
- expect(result.output).toContain("line9")
- expect(result.output).not.toContain("line10")
- }),
- )
- it.instance("does not truncate small file", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- yield* put(path.join(test.directory, "small.txt"), "hello world")
- const result = yield* run({ filePath: path.join(test.directory, "small.txt") })
- expect(result.metadata.truncated).toBe(false)
- expect(result.output).toContain("End of file")
- expect(result.metadata.display).toMatchObject({
- type: "file",
- path: path.join(test.directory, "small.txt"),
- text: "hello world",
- lineStart: 1,
- lineEnd: 1,
- totalLines: 1,
- truncated: false,
- })
- }),
- )
- it.live("respects offset parameter", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
- yield* put(path.join(dir, "offset.txt"), lines)
- const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
- expect(result.output).toContain("10: line10")
- expect(result.output).toContain("14: line14")
- expect(result.output).not.toContain("9: line10")
- expect(result.output).not.toContain("15: line15")
- expect(result.output).toContain("line10")
- expect(result.output).toContain("line14")
- expect(result.output).not.toContain("line0")
- expect(result.output).not.toContain("line15")
- }),
- )
- it.live("throws when offset is beyond end of file", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
- yield* put(path.join(dir, "short.txt"), lines)
- const err = yield* fail(dir, { filePath: path.join(dir, "short.txt"), offset: 4, limit: 5 })
- expect(err.message).toContain("Offset 4 is out of range for this file (3 lines)")
- }),
- )
- it.live("allows reading empty file at default offset", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* put(path.join(dir, "empty.txt"), "")
- const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
- expect(result.metadata.truncated).toBe(false)
- expect(result.output).toContain("End of file - total 0 lines")
- }),
- )
- it.live("throws when offset > 1 for empty file", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* put(path.join(dir, "empty.txt"), "")
- const err = yield* fail(dir, { filePath: path.join(dir, "empty.txt"), offset: 2 })
- expect(err.message).toContain("Offset 2 is out of range for this file (0 lines)")
- }),
- )
- it.live("does not mark final directory page as truncated", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* Effect.forEach(
- Array.from({ length: 10 }, (_, i) => i),
- (i) => put(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`),
- {
- concurrency: "unbounded",
- },
- )
- const result = yield* exec(dir, { filePath: path.join(dir, "dir"), offset: 6, limit: 5 })
- expect(result.metadata.truncated).toBe(false)
- expect(result.output).not.toContain("Showing 5 of 10 entries")
- expect(result.metadata.display).toMatchObject({
- type: "directory",
- path: path.join(dir, "dir"),
- entries: ["file-5.txt", "file-6.txt", "file-7.txt", "file-8.txt", "file-9.txt"],
- offset: 6,
- totalEntries: 10,
- truncated: false,
- })
- }),
- )
- it.live("truncates long lines", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* put(path.join(dir, "long-line.txt"), "x".repeat(3000))
- const result = yield* exec(dir, { filePath: path.join(dir, "long-line.txt") })
- expect(result.output).toContain("(line truncated to 2000 chars)")
- expect(result.output.length).toBeLessThan(3000)
- }),
- )
- it.live("image files set truncated to false", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- const png = Buffer.from(
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
- "base64",
- )
- yield* put(path.join(dir, "image.png"), png)
- const result = yield* exec(dir, { filePath: path.join(dir, "image.png") })
- expect(result.metadata.truncated).toBe(false)
- expect(result.attachments).toBeDefined()
- expect(result.attachments?.length).toBe(1)
- expect(result.attachments?.[0]).not.toHaveProperty("id")
- expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
- expect(result.attachments?.[0]).not.toHaveProperty("messageID")
- }),
- )
- it.live("detects attachment media from file contents", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01])
- yield* put(path.join(dir, "image.bin"), jpeg)
- const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") })
- expect(result.output).toBe("Image read successfully")
- expect(result.attachments?.[0].mime).toBe("image/jpeg")
- expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true)
- }),
- )
- it.live("large image files are properly attached without error", () =>
- Effect.gen(function* () {
- const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
- expect(result.metadata.truncated).toBe(false)
- expect(result.attachments).toBeDefined()
- expect(result.attachments?.length).toBe(1)
- expect(result.attachments?.[0].type).toBe("file")
- expect(result.attachments?.[0]).not.toHaveProperty("id")
- expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
- expect(result.attachments?.[0]).not.toHaveProperty("messageID")
- }),
- )
- it.live(".fbs files (FlatBuffers schema) are read as text, not images", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- const fbs = `namespace MyGame;
- table Monster {
- pos:Vec3;
- name:string;
- inventory:[ubyte];
- }
- root_type Monster;`
- yield* put(path.join(dir, "schema.fbs"), fbs)
- const result = yield* exec(dir, { filePath: path.join(dir, "schema.fbs") })
- expect(result.attachments).toBeUndefined()
- expect(result.output).toContain("namespace MyGame")
- expect(result.output).toContain("table Monster")
- }),
- )
- it.live("falls through unsupported image mime types to text", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- const cases = [
- ["image.bmp", "BM text content"],
- ["photo.tiff", "II text content"],
- ["photo.avif", "avif text content"],
- ] as const
- for (const item of cases) {
- yield* put(path.join(dir, item[0]), item[1])
- const result = yield* exec(dir, { filePath: path.join(dir, item[0]) })
- expect(result.attachments).toBeUndefined()
- expect(result.output).toContain(item[1])
- }
- }),
- )
- })
- describe("tool.read loaded instructions", () => {
- it.live("loads AGENTS.md from parent directory and includes in metadata", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* put(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
- yield* put(path.join(dir, "subdir", "nested", "test.txt"), "test content")
- const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "test.txt") })
- expect(result.output).toContain("test content")
- expect(result.output).toContain("system-reminder")
- expect(result.output).toContain("Test Instructions")
- expect(result.metadata.loaded).toBeDefined()
- expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
- }),
- )
- })
- describe("tool.read binary detection", () => {
- it.live("rejects text extension files with null bytes", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
- yield* put(path.join(dir, "null-byte.txt"), bytes)
- const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") })
- expect(err.message).toContain("Cannot read binary file")
- }),
- )
- it.live("rejects known binary extensions", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* put(path.join(dir, "module.wasm"), "not really wasm")
- const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") })
- expect(err.message).toContain("Cannot read binary file")
- }),
- )
- })
|