e549174c87
Yield AppFileSystem, Instruction, LSP, and FileTime from the read tool effect so the tool closes over real services instead of static facades. Rewrite read tool tests to use the effect test harness and document the migration pattern for effectified tools.
482 lines
17 KiB
TypeScript
482 lines
17 KiB
TypeScript
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 { 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: "build",
|
|
abort: AbortSignal.any([]),
|
|
messages: [],
|
|
metadata: () => {},
|
|
ask: async () => {},
|
|
}
|
|
|
|
const it = testEffect(
|
|
Layer.mergeAll(
|
|
Agent.defaultLayer,
|
|
AppFileSystem.defaultLayer,
|
|
CrossSpawnSpawner.defaultLayer,
|
|
FileTime.defaultLayer,
|
|
Instruction.defaultLayer,
|
|
LSP.defaultLayer,
|
|
),
|
|
)
|
|
|
|
const init = Effect.fn("ReadToolTest.init")(function* () {
|
|
const info = yield* ReadTool
|
|
return yield* Effect.promise(() => info.init())
|
|
})
|
|
|
|
const exec = Effect.fn("ReadToolTest.exec")(function* (
|
|
dir: string,
|
|
args: Tool.InferParameters<typeof ReadTool>,
|
|
next: Tool.Context = ctx,
|
|
) {
|
|
return yield* provideInstance(dir)(
|
|
Effect.gen(function* () {
|
|
const tool = yield* init()
|
|
return yield* Effect.promise(() => tool.execute(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)
|
|
})
|
|
|
|
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 requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
|
const next = {
|
|
...ctx,
|
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
|
requests.push(req)
|
|
},
|
|
}
|
|
|
|
yield* exec(dir, { filePath: path.join(outer, "secret.txt") }, next)
|
|
const ext = requests.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 requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
|
const next = {
|
|
...ctx,
|
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
|
requests.push(req)
|
|
},
|
|
}
|
|
const target = path.join(dir, "test.txt")
|
|
const alt = target
|
|
.replace(/^[A-Za-z]:/, "")
|
|
.replaceAll("\\", "/")
|
|
.toLowerCase()
|
|
|
|
yield* exec(dir, { filePath: alt }, next)
|
|
const read = requests.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 requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
|
const next = {
|
|
...ctx,
|
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
|
requests.push(req)
|
|
},
|
|
}
|
|
|
|
yield* exec(dir, { filePath: path.join(outer, "external") }, next)
|
|
const ext = requests.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 requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
|
const next = {
|
|
...ctx,
|
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
|
requests.push(req)
|
|
},
|
|
}
|
|
|
|
yield* fail(dir, { filePath: "../outside.txt" }, next)
|
|
const ext = requests.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 requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
|
const next = {
|
|
...ctx,
|
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
|
requests.push(req)
|
|
},
|
|
}
|
|
|
|
yield* exec(dir, { filePath: path.join(dir, "internal.txt") }, next)
|
|
const ext = requests.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 info = yield* provideInstance(dir)(
|
|
Effect.gen(function* () {
|
|
const agent = yield* Agent.Service
|
|
return yield* agent.get(agentName)
|
|
}),
|
|
)
|
|
let asked = false
|
|
const next = {
|
|
...ctx,
|
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
|
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* exec(dir, { filePath: path.join(dir, filename) }, next)
|
|
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")
|
|
}),
|
|
)
|
|
})
|