| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- import { afterEach, describe, expect } from "bun:test"
- import { Cause, Effect, Exit, Layer } from "effect"
- import path from "path"
- import { Agent } from "../../src/agent/agent"
- import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
- import { AppFileSystem } from "../../src/filesystem"
- import { FileTime } from "../../src/file/time"
- import { LSP } from "../../src/lsp"
- import { Permission } from "../../src/permission"
- import { Instance } from "../../src/project/instance"
- import { SessionID, MessageID } from "../../src/session/schema"
- import { Instruction } from "../../src/session/instruction"
- import { ReadTool } from "../../src/tool/read"
- import { Truncate } from "../../src/tool/truncate"
- import { Tool } from "../../src/tool/tool"
- import { Filesystem } from "../../src/util/filesystem"
- import { provideInstance, tmpdirScoped } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
- afterEach(async () => {
- await Instance.disposeAll()
- })
- const ctx = {
- sessionID: SessionID.make("ses_test"),
- messageID: MessageID.make(""),
- callID: "",
- agent: "code", // kilocode_change
- abort: AbortSignal.any([]),
- messages: [],
- metadata: () => Effect.void,
- ask: () => Effect.void,
- }
- const it = testEffect(
- Layer.mergeAll(
- Agent.defaultLayer,
- AppFileSystem.defaultLayer,
- CrossSpawnSpawner.defaultLayer,
- FileTime.defaultLayer,
- Instruction.defaultLayer,
- LSP.defaultLayer,
- Truncate.defaultLayer,
- ),
- )
- 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 put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
- const fs = yield* AppFileSystem.Service
- yield* fs.writeWithDirs(p, content)
- })
- const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
- const fs = yield* AppFileSystem.Service
- return yield* fs.readFileString(p)
- })
- const asks = () => {
- const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
- return {
- items,
- next: {
- ...ctx,
- ask: (req: Omit<Permission.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([full(target)])
- }),
- )
- }
- 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],
- ]
- // kilocode_change start - renamed from "build" to "code"
- for (const agentName of ["code", "plan"] as const) {
- // kilocode_change end
- 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<Permission.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 Permission.DeniedError({ ruleset: info.permission })
- }
- }
- }),
- }
- yield* run({ filePath: path.join(dir, filename) }, next)
- return asked
- }),
- )
- expect(asked).toBe(shouldAsk)
- }),
- )
- }
- })
- }
- })
- describe("tool.read truncation", () => {
- it.live("truncates large file by bytes and sets truncated metadata", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- 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(dir, "large.json"), content)
- const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
- expect(result.metadata.truncated).toBe(true)
- expect(result.output).toContain("Output capped at")
- expect(result.output).toContain("Use offset=")
- }),
- )
- it.live("truncates by line count when limit is specified", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
- yield* put(path.join(dir, "many-lines.txt"), lines)
- const result = yield* exec(dir, { filePath: path.join(dir, "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.live("does not truncate small file", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped()
- yield* put(path.join(dir, "small.txt"), "hello world")
- const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
- expect(result.metadata.truncated).toBe(false)
- expect(result.output).toContain("End of file")
- }),
- )
- 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")
- }),
- )
- 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("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")
- }),
- )
- })
- 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")
- }),
- )
- })
|