refactor(effect): move read tool onto defineEffect (#21016)

pull/21054/head
Kit Langton 2026-04-04 19:06:00 -04:00 committed by GitHub
parent 6ea108a03b
commit c796b9a19e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 829 additions and 819 deletions

View File

@ -235,11 +235,27 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
2. Update `Tool.define()` factory to work with Effects 2. Update `Tool.define()` factory to work with Effects
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing 3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
### Tool migration details
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.
Individual tools, ordered by value: Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events - [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture - [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream - [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock - [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling - [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events - [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events

View File

@ -188,13 +188,23 @@ export namespace AppFileSystem {
export function normalizePath(p: string): string { export function normalizePath(p: string): string {
if (process.platform !== "win32") return p if (process.platform !== "win32") return p
const resolved = pathResolve(windowsPath(p))
try { try {
return realpathSync.native(p) return realpathSync.native(resolved)
} catch { } catch {
return p return resolved
} }
} }
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}
export function resolve(p: string): string { export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p)) const resolved = pathResolve(windowsPath(p))
try { try {

View File

@ -1,7 +1,8 @@
import path from "path" import path from "path"
import { Effect } from "effect"
import type { Tool } from "./tool" import type { Tool } from "./tool"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "@/util/filesystem" import { AppFileSystem } from "../filesystem"
type Kind = "file" | "directory" type Kind = "file" | "directory"
@ -15,14 +16,14 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return if (options?.bypass) return
const full = process.platform === "win32" ? Filesystem.normalizePath(target) : target const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
if (Instance.containsPath(full)) return if (Instance.containsPath(full)) return
const kind = options?.kind ?? "file" const kind = options?.kind ?? "file"
const dir = kind === "directory" ? full : path.dirname(full) const dir = kind === "directory" ? full : path.dirname(full)
const glob = const glob =
process.platform === "win32" process.platform === "win32"
? Filesystem.normalizePathPattern(path.join(dir, "*")) ? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/") : path.join(dir, "*").replaceAll("\\", "/")
await ctx.ask({ await ctx.ask({
@ -35,3 +36,11 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
}, },
}) })
} }
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
yield* Effect.promise(() => assertExternalDirectory(ctx, target, options))
})

View File

@ -1,16 +1,17 @@
import z from "zod" import z from "zod"
import { Effect, Scope } from "effect"
import { createReadStream } from "fs" import { createReadStream } from "fs"
import * as fs from "fs/promises" import { open } from "fs/promises"
import * as path from "path" import * as path from "path"
import { createInterface } from "readline" import { createInterface } from "readline"
import { Tool } from "./tool" import { Tool } from "./tool"
import { AppFileSystem } from "../filesystem"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt" import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction" import { Instruction } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
const DEFAULT_READ_LIMIT = 2000 const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000 const MAX_LINE_LENGTH = 2000
@ -18,98 +19,126 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024 const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
export const ReadTool = Tool.define("read", { const parameters = z.object({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file or directory to read"), filePath: z.string().describe("The absolute path to the file or directory to read"),
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(), limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
}), })
async execute(params, ctx) {
if (params.offset !== undefined && params.offset < 1) { export const ReadTool = Tool.defineEffect(
throw new Error("offset must be greater than or equal to 1") "read",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const time = yield* FileTime.Service
const scope = yield* Scope.Scope
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
const dir = path.dirname(filepath)
const base = path.basename(filepath)
const items = yield* fs.readDirectory(dir).pipe(
Effect.map((items) =>
items
.filter(
(item) =>
item.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(item.toLowerCase()),
)
.map((item) => path.join(dir, item))
.slice(0, 3),
),
Effect.catch(() => Effect.succeed([] as string[])),
)
if (items.length > 0) {
return yield* Effect.fail(
new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${items.join("\n")}`),
)
} }
return yield* Effect.fail(new Error(`File not found: ${filepath}`))
})
const list = Effect.fn("ReadTool.list")(function* (filepath: string) {
const items = yield* fs.readDirectoryEntries(filepath)
return yield* Effect.forEach(
items,
Effect.fnUntraced(function* (item) {
if (item.type === "directory") return item.name + "/"
if (item.type !== "symlink") return item.name
const target = yield* fs
.stat(path.join(filepath, item.name))
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (target?.type === "Directory") return item.name + "/"
return item.name
}),
{ concurrency: "unbounded" },
).pipe(Effect.map((items: string[]) => items.sort((a, b) => a.localeCompare(b))))
})
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) {
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
yield* time.read(sessionID, filepath)
})
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
if (params.offset !== undefined && params.offset < 1) {
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
}
let filepath = params.filePath let filepath = params.filePath
if (!path.isAbsolute(filepath)) { if (!path.isAbsolute(filepath)) {
filepath = path.resolve(Instance.directory, filepath) filepath = path.resolve(Instance.directory, filepath)
} }
if (process.platform === "win32") { if (process.platform === "win32") {
filepath = Filesystem.normalizePath(filepath) filepath = AppFileSystem.normalizePath(filepath)
} }
const title = path.relative(Instance.worktree, filepath) const title = path.relative(Instance.worktree, filepath)
const stat = Filesystem.stat(filepath) const stat = yield* fs.stat(filepath).pipe(
Effect.catchIf(
(err) => "reason" in err && err.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
)
await assertExternalDirectory(ctx, filepath, { yield* assertExternalDirectoryEffect(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
kind: stat?.isDirectory() ? "directory" : "file", kind: stat?.type === "Directory" ? "directory" : "file",
}) })
await ctx.ask({ yield* Effect.promise(() =>
ctx.ask({
permission: "read", permission: "read",
patterns: [filepath], patterns: [filepath],
always: ["*"], always: ["*"],
metadata: {}, metadata: {},
})
if (!stat) {
const dir = path.dirname(filepath)
const base = path.basename(filepath)
const suggestions = await fs
.readdir(dir)
.then((entries) =>
entries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3),
)
.catch(() => [])
if (suggestions.length > 0) {
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
}
throw new Error(`File not found: ${filepath}`)
}
if (stat.isDirectory()) {
const dirents = await fs.readdir(filepath, { withFileTypes: true })
const entries = await Promise.all(
dirents.map(async (dirent) => {
if (dirent.isDirectory()) return dirent.name + "/"
if (dirent.isSymbolicLink()) {
const target = await fs.stat(path.join(filepath, dirent.name)).catch(() => undefined)
if (target?.isDirectory()) return dirent.name + "/"
}
return dirent.name
}), }),
) )
entries.sort((a, b) => a.localeCompare(b))
if (!stat) return yield* miss(filepath)
if (stat.type === "Directory") {
const items = yield* list(filepath)
const limit = params.limit ?? DEFAULT_READ_LIMIT const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset ?? 1 const offset = params.offset ?? 1
const start = offset - 1 const start = offset - 1
const sliced = entries.slice(start, start + limit) const sliced = items.slice(start, start + limit)
const truncated = start + sliced.length < entries.length const truncated = start + sliced.length < items.length
const output = [ return {
title,
output: [
`<path>${filepath}</path>`, `<path>${filepath}</path>`,
`<type>directory</type>`, `<type>directory</type>`,
`<entries>`, `<entries>`,
sliced.join("\n"), sliced.join("\n"),
truncated truncated
? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})` ? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
: `\n(${entries.length} entries)`, : `\n(${items.length} entries)`,
`</entries>`, `</entries>`,
].join("\n") ].join("\n"),
return {
title,
output,
metadata: { metadata: {
preview: sliced.slice(0, 20).join("\n"), preview: sliced.slice(0, 20).join("\n"),
truncated, truncated,
@ -118,10 +147,9 @@ export const ReadTool = Tool.define("read", {
} }
} }
const instructions = await Instruction.resolve(ctx.messages, filepath, ctx.messageID) const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files) const mime = AppFileSystem.mimeType(filepath)
const mime = Filesystem.mimeType(filepath)
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
const isPdf = mime === "application/pdf" const isPdf = mime === "application/pdf"
if (isImage || isPdf) { if (isImage || isPdf) {
@ -132,21 +160,74 @@ export const ReadTool = Tool.define("read", {
metadata: { metadata: {
preview: msg, preview: msg,
truncated: false, truncated: false,
loaded: instructions.map((i) => i.filepath), loaded: loaded.map((item) => item.filepath),
}, },
attachments: [ attachments: [
{ {
type: "file", type: "file" as const,
mime, mime,
url: `data:${mime};base64,${Buffer.from(await Filesystem.readBytes(filepath)).toString("base64")}`, url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`,
}, },
], ],
} }
} }
const isBinary = await isBinaryFile(filepath, Number(stat.size)) if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) {
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
}
const file = yield* Effect.promise(() =>
lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset ?? 1 }),
)
if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {
return yield* Effect.fail(
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
)
}
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
const last = file.offset + file.raw.length - 1
const next = last + 1
const truncated = file.more || file.cut
if (file.cut) {
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${file.offset}-${last}. Use offset=${next} to continue.)`
} else if (file.more) {
output += `\n\n(Showing lines ${file.offset}-${last} of ${file.count}. Use offset=${next} to continue.)`
} else {
output += `\n\n(End of file - total ${file.count} lines)`
}
output += "\n</content>"
yield* warm(filepath, ctx.sessionID)
if (loaded.length > 0) {
output += `\n\n<system-reminder>\n${loaded.map((item) => item.content).join("\n\n")}\n</system-reminder>`
}
return {
title,
output,
metadata: {
preview: file.raw.slice(0, 20).join("\n"),
truncated,
loaded: loaded.map((item) => item.filepath),
},
}
})
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
}
}),
)
async function lines(filepath: string, opts: { limit: number; offset: number }) {
const stream = createReadStream(filepath, { encoding: "utf8" }) const stream = createReadStream(filepath, { encoding: "utf8" })
const rl = createInterface({ const rl = createInterface({
input: stream, input: stream,
@ -155,29 +236,27 @@ export const ReadTool = Tool.define("read", {
crlfDelay: Infinity, crlfDelay: Infinity,
}) })
const limit = params.limit ?? DEFAULT_READ_LIMIT const start = opts.offset - 1
const offset = params.offset ?? 1
const start = offset - 1
const raw: string[] = [] const raw: string[] = []
let bytes = 0 let bytes = 0
let lines = 0 let count = 0
let truncatedByBytes = false let cut = false
let hasMoreLines = false let more = false
try { try {
for await (const text of rl) { for await (const text of rl) {
lines += 1 count += 1
if (lines <= start) continue if (count <= start) continue
if (raw.length >= limit) { if (raw.length >= opts.limit) {
hasMoreLines = true more = true
continue continue
} }
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) { if (bytes + size > MAX_BYTES) {
truncatedByBytes = true cut = true
hasMoreLines = true more = true
break break
} }
@ -189,51 +268,8 @@ export const ReadTool = Tool.define("read", {
stream.destroy() stream.destroy()
} }
if (lines < offset && !(lines === 0 && offset === 1)) { return { raw, count, cut, more, offset: opts.offset }
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`) }
}
const content = raw.map((line, index) => {
return `${index + offset}: ${line}`
})
const preview = raw.slice(0, 20).join("\n")
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
output += content.join("\n")
const totalLines = lines
const lastReadLine = offset + raw.length - 1
const nextOffset = lastReadLine + 1
const truncated = hasMoreLines || truncatedByBytes
if (truncatedByBytes) {
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
} else if (hasMoreLines) {
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
} else {
output += `\n\n(End of file - total ${totalLines} lines)`
}
output += "\n</content>"
// just warms the lsp client
LSP.touchFile(filepath, false)
await FileTime.read(ctx.sessionID, filepath)
if (instructions.length > 0) {
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
}
return {
title,
output,
metadata: {
preview,
truncated,
loaded: instructions.map((i) => i.filepath),
},
}
},
})
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> { async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
const ext = path.extname(filepath).toLowerCase() const ext = path.extname(filepath).toLowerCase()
@ -274,7 +310,7 @@ async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean
if (fileSize === 0) return false if (fileSize === 0) return false
const fh = await fs.open(filepath, "r") const fh = await open(filepath, "r")
try { try {
const sampleSize = Math.min(4096, fileSize) const sampleSize = Math.min(4096, fileSize)
const bytes = Buffer.alloc(sampleSize) const bytes = Buffer.alloc(sampleSize)

View File

@ -35,6 +35,10 @@ import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
import { Question } from "../question" import { Question } from "../question"
import { Todo } from "../session/todo" import { Todo } from "../session/todo"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
export namespace ToolRegistry { export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" }) const log = Log.create({ service: "tool.registry" })
@ -57,8 +61,18 @@ export namespace ToolRegistry {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service | Question.Service | Todo.Service> = export const layer: Layer.Layer<
Layer.effect( Service,
never,
| Config.Service
| Plugin.Service
| Question.Service
| Todo.Service
| LSP.Service
| FileTime.Service
| Instruction.Service
| AppFileSystem.Service
> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
@ -142,8 +156,7 @@ export namespace ToolRegistry {
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) { const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get() const cfg = yield* config.get()
const question = const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [ return [
invalid, invalid,
@ -226,6 +239,10 @@ export namespace ToolRegistry {
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer), Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer), Layer.provide(Todo.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
), ),
), ),
) )

View File

@ -1,12 +1,20 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path" import path from "path"
import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent" 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 { 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") const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@ -25,173 +33,171 @@ const ctx = {
ask: async () => {}, 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 run = Effect.fn("ReadToolTest.run")(function* (
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* Effect.promise(() => 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 full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const glob = (p: string) => const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") 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: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
items.push(req)
},
},
}
}
describe("tool.read external_directory permission", () => { describe("tool.read external_directory permission", () => {
test("allows reading absolute path inside project directory", async () => { it.live("allows reading absolute path inside project directory", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Bun.write(path.join(dir, "test.txt"), "hello world") yield* put(path.join(dir, "test.txt"), "hello world")
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "test.txt") })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "test.txt") }, ctx)
expect(result.output).toContain("hello world") expect(result.output).toContain("hello world")
}, }),
}) )
})
test("allows reading file in subdirectory inside project directory", async () => { it.live("allows reading file in subdirectory inside project directory", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content") yield* put(path.join(dir, "subdir", "test.txt"), "nested content")
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "test.txt") })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "test.txt") }, ctx)
expect(result.output).toContain("nested content") expect(result.output).toContain("nested content")
}, }),
}) )
})
test("asks for external_directory permission when reading absolute path outside project", async () => { it.live("asks for external_directory permission when reading absolute path outside project", () =>
await using outerTmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const outer = yield* tmpdirScoped()
await Bun.write(path.join(dir, "secret.txt"), "secret data") const dir = yield* tmpdirScoped({ git: true })
}, yield* put(path.join(outer, "secret.txt"), "secret data")
})
await using tmp = await tmpdir({ git: true }) const { items, next } = asks()
await Instance.provide({
directory: tmp.path, yield* exec(dir, { filePath: path.join(outer, "secret.txt") }, next)
fn: async () => { const ext = items.find((item) => item.permission === "external_directory")
const read = await ReadTool.init() expect(ext).toBeDefined()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] expect(ext!.patterns).toContain(glob(path.join(outer, "*")))
const testCtx = { }),
...ctx, )
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*")))
},
})
})
if (process.platform === "win32") { if (process.platform === "win32") {
test("normalizes read permission paths on Windows", async () => { it.live("normalizes read permission paths on Windows", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
git: true, const dir = yield* tmpdirScoped({ git: true })
init: async (dir) => { yield* put(path.join(dir, "test.txt"), "hello world")
await Bun.write(path.join(dir, "test.txt"), "hello world")
}, const { items, next } = asks()
}) const target = path.join(dir, "test.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
const target = path.join(tmp.path, "test.txt")
const alt = target const alt = target
.replace(/^[A-Za-z]:/, "") .replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/") .replaceAll("\\", "/")
.toLowerCase() .toLowerCase()
await read.execute({ filePath: alt }, testCtx)
const readReq = requests.find((r) => r.permission === "read") yield* exec(dir, { filePath: alt }, next)
expect(readReq).toBeDefined() const read = items.find((item) => item.permission === "read")
expect(readReq!.patterns).toEqual([full(target)]) expect(read).toBeDefined()
}, expect(read!.patterns).toEqual([full(target)])
}) }),
}) )
} }
test("asks for directory-scoped external_directory permission when reading external directory", async () => { it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
await using outerTmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const outer = yield* tmpdirScoped()
await Bun.write(path.join(dir, "external", "a.txt"), "a") const dir = yield* tmpdirScoped({ git: true })
}, yield* put(path.join(outer, "external", "a.txt"), "a")
})
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "external", "*")))
},
})
})
test("asks for external_directory permission when reading relative path outside project", async () => { const { items, next } = asks()
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
// This will fail because file doesn't exist, but we can check if permission was asked
await read.execute({ filePath: "../outside.txt" }, testCtx).catch(() => {})
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
},
})
})
test("does not ask for external_directory permission when reading inside project", async () => { yield* exec(dir, { filePath: path.join(outer, "external") }, next)
await using tmp = await tmpdir({ const ext = items.find((item) => item.permission === "external_directory")
git: true, expect(ext).toBeDefined()
init: async (dir) => { expect(ext!.patterns).toContain(glob(path.join(outer, "external", "*")))
await Bun.write(path.join(dir, "internal.txt"), "internal content") }),
}, )
})
await Instance.provide({ it.live("asks for external_directory permission when reading relative path outside project", () =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const dir = yield* tmpdirScoped({ git: true })
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const { items, next } = asks()
const testCtx = {
...ctx, yield* fail(dir, { filePath: "../outside.txt" }, next)
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => { const ext = items.find((item) => item.permission === "external_directory")
requests.push(req) expect(ext).toBeDefined()
}, }),
} )
await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory") it.live("does not ask for external_directory permission when reading inside project", () =>
expect(extDirReq).toBeUndefined() 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", () => { describe("tool.read env file permissions", () => {
@ -205,112 +211,97 @@ describe("tool.read env file permissions", () => {
["environment.ts", false], ["environment.ts", false],
] ]
describe.each(["build", "plan"])("agent=%s", (agentName) => { for (const agentName of ["build", "plan"] as const) {
test.each(cases)("%s asks=%s", async (filename, shouldAsk) => { describe(`agent=${agentName}`, () => {
await using tmp = await tmpdir({ for (const [filename, shouldAsk] of cases) {
init: (dir) => Bun.write(path.join(dir, filename), "content"), it.live(`${filename} asks=${shouldAsk}`, () =>
}) Effect.gen(function* () {
await Instance.provide({ const dir = yield* tmpdirScoped()
directory: tmp.path, yield* put(path.join(dir, filename), "content")
fn: async () => {
const agent = await Agent.get(agentName) const asked = yield* provideInstance(dir)(
let askedForEnv = false Effect.gen(function* () {
const ctxWithPermissions = { const agent = yield* Agent.Service
const info = yield* agent.get(agentName)
let asked = false
const next = {
...ctx, ...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => { ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
for (const pattern of req.patterns) { for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, agent.permission) const rule = Permission.evaluate(req.permission, pattern, info.permission)
if (rule.action === "ask" && req.permission === "read") { if (rule.action === "ask" && req.permission === "read") {
askedForEnv = true asked = true
} }
if (rule.action === "deny") { if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset: agent.permission }) throw new Permission.DeniedError({ ruleset: info.permission })
} }
} }
}, },
} }
const read = await ReadTool.init()
await read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions) yield* run({ filePath: path.join(dir, filename) }, next)
expect(askedForEnv).toBe(shouldAsk) return asked
}, }),
}) )
})
expect(asked).toBe(shouldAsk)
}),
)
}
}) })
}
}) })
describe("tool.read truncation", () => { describe("tool.read truncation", () => {
test("truncates large file by bytes and sets truncated metadata", async () => { it.live("truncates large file by bytes and sets truncated metadata", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const base = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
const target = 60 * 1024 const target = 60 * 1024
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length)) const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
await Filesystem.write(path.join(dir, "large.json"), content) yield* put(path.join(dir, "large.json"), content)
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
expect(result.metadata.truncated).toBe(true) expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Output capped at") expect(result.output).toContain("Output capped at")
expect(result.output).toContain("Use offset=") expect(result.output).toContain("Use offset=")
}, }),
}) )
})
test("truncates by line count when limit is specified", async () => { it.live("truncates by line count when limit is specified", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
await Bun.write(path.join(dir, "many-lines.txt"), lines) yield* put(path.join(dir, "many-lines.txt"), lines)
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
expect(result.metadata.truncated).toBe(true) expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Showing lines 1-10 of 100") expect(result.output).toContain("Showing lines 1-10 of 100")
expect(result.output).toContain("Use offset=11") expect(result.output).toContain("Use offset=11")
expect(result.output).toContain("line0") expect(result.output).toContain("line0")
expect(result.output).toContain("line9") expect(result.output).toContain("line9")
expect(result.output).not.toContain("line10") expect(result.output).not.toContain("line10")
}, }),
}) )
})
test("does not truncate small file", async () => { it.live("does not truncate small file", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Bun.write(path.join(dir, "small.txt"), "hello world") yield* put(path.join(dir, "small.txt"), "hello world")
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx)
expect(result.metadata.truncated).toBe(false) expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file") expect(result.output).toContain("End of file")
}, }),
}) )
})
test("respects offset parameter", async () => { it.live("respects offset parameter", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n") const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
await Bun.write(path.join(dir, "offset.txt"), lines) yield* put(path.join(dir, "offset.txt"), lines)
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
expect(result.output).toContain("10: line10") expect(result.output).toContain("10: line10")
expect(result.output).toContain("14: line14") expect(result.output).toContain("14: line14")
expect(result.output).not.toContain("9: line10") expect(result.output).not.toContain("9: line10")
@ -319,131 +310,91 @@ describe("tool.read truncation", () => {
expect(result.output).toContain("line14") expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0") expect(result.output).not.toContain("line0")
expect(result.output).not.toContain("line15") expect(result.output).not.toContain("line15")
}, }),
}) )
})
test("throws when offset is beyond end of file", async () => { it.live("throws when offset is beyond end of file", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n") const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
await Bun.write(path.join(dir, "short.txt"), lines) yield* put(path.join(dir, "short.txt"), lines)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(
read.execute({ filePath: path.join(tmp.path, "short.txt"), offset: 4, limit: 5 }, ctx),
).rejects.toThrow("Offset 4 is out of range for this file (3 lines)")
},
})
})
test("allows reading empty file at default offset", async () => { const err = yield* fail(dir, { filePath: path.join(dir, "short.txt"), offset: 4, limit: 5 })
await using tmp = await tmpdir({ expect(err.message).toContain("Offset 4 is out of range for this file (3 lines)")
init: async (dir) => { }),
await Bun.write(path.join(dir, "empty.txt"), "") )
},
}) it.live("allows reading empty file at default offset", () =>
await Instance.provide({ Effect.gen(function* () {
directory: tmp.path, const dir = yield* tmpdirScoped()
fn: async () => { yield* put(path.join(dir, "empty.txt"), "")
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "empty.txt") }, ctx) const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
expect(result.metadata.truncated).toBe(false) expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file - total 0 lines") expect(result.output).toContain("End of file - total 0 lines")
}, }),
})
})
test("throws when offset > 1 for empty file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "empty.txt"), "")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "empty.txt"), offset: 2 }, ctx)).rejects.toThrow(
"Offset 2 is out of range for this file (0 lines)",
) )
},
})
})
test("does not mark final directory page as truncated", async () => { it.live("throws when offset > 1 for empty file", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Promise.all( yield* put(path.join(dir, "empty.txt"), "")
Array.from({ length: 10 }, (_, i) => Bun.write(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`)),
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",
}, },
}) )
await Instance.provide({
directory: tmp.path, const result = yield* exec(dir, { filePath: path.join(dir, "dir"), offset: 6, limit: 5 })
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "dir"), offset: 6, limit: 5 }, ctx)
expect(result.metadata.truncated).toBe(false) expect(result.metadata.truncated).toBe(false)
expect(result.output).not.toContain("Showing 5 of 10 entries") expect(result.output).not.toContain("Showing 5 of 10 entries")
}, }),
}) )
})
test("truncates long lines", async () => { it.live("truncates long lines", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const longLine = "x".repeat(3000) yield* put(path.join(dir, "long-line.txt"), "x".repeat(3000))
await Bun.write(path.join(dir, "long-line.txt"), longLine)
}, const result = yield* exec(dir, { filePath: path.join(dir, "long-line.txt") })
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
expect(result.output).toContain("(line truncated to 2000 chars)") expect(result.output).toContain("(line truncated to 2000 chars)")
expect(result.output.length).toBeLessThan(3000) expect(result.output.length).toBeLessThan(3000)
}, }),
}) )
})
test("image files set truncated to false", async () => { it.live("image files set truncated to false", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
// 1x1 red PNG
const png = Buffer.from( const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64", "base64",
) )
await Bun.write(path.join(dir, "image.png"), png) yield* put(path.join(dir, "image.png"), png)
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "image.png") })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx)
expect(result.metadata.truncated).toBe(false) expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined() expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1) expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0]).not.toHaveProperty("id") expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID") expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID") expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}, }),
}) )
})
test("large image files are properly attached without error", async () => { it.live("large image files are properly attached without error", () =>
await Instance.provide({ Effect.gen(function* () {
directory: FIXTURES_DIR, const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(FIXTURES_DIR, "large-image.png") }, ctx)
expect(result.metadata.truncated).toBe(false) expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined() expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1) expect(result.attachments?.length).toBe(1)
@ -451,15 +402,13 @@ describe("tool.read truncation", () => {
expect(result.attachments?.[0]).not.toHaveProperty("id") expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID") expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID") expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}, }),
}) )
})
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => { it.live(".fbs files (FlatBuffers schema) are read as text, not images", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
// FlatBuffers schema content const fbs = `namespace MyGame;
const fbsContent = `namespace MyGame;
table Monster { table Monster {
pos:Vec3; pos:Vec3;
@ -468,79 +417,52 @@ table Monster {
} }
root_type Monster;` root_type Monster;`
await Bun.write(path.join(dir, "schema.fbs"), fbsContent) yield* put(path.join(dir, "schema.fbs"), fbs)
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "schema.fbs") })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "schema.fbs") }, ctx)
// Should be read as text, not as image
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
expect(result.output).toContain("namespace MyGame") expect(result.output).toContain("namespace MyGame")
expect(result.output).toContain("table Monster") expect(result.output).toContain("table Monster")
}, }),
}) )
})
}) })
describe("tool.read loaded instructions", () => { describe("tool.read loaded instructions", () => {
test("loads AGENTS.md from parent directory and includes in metadata", async () => { it.live("loads AGENTS.md from parent directory and includes in metadata", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.") yield* put(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content") yield* put(path.join(dir, "subdir", "nested", "test.txt"), "test content")
},
}) const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "test.txt") })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
expect(result.output).toContain("test content") expect(result.output).toContain("test content")
expect(result.output).toContain("system-reminder") expect(result.output).toContain("system-reminder")
expect(result.output).toContain("Test Instructions") expect(result.output).toContain("Test Instructions")
expect(result.metadata.loaded).toBeDefined() expect(result.metadata.loaded).toBeDefined()
expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md")) expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
}, }),
}) )
})
}) })
describe("tool.read binary detection", () => { describe("tool.read binary detection", () => {
test("rejects text extension files with null bytes", async () => { it.live("rejects text extension files with null bytes", () =>
await using tmp = await tmpdir({ Effect.gen(function* () {
init: async (dir) => { const dir = yield* tmpdirScoped()
const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64]) const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
await Bun.write(path.join(dir, "null-byte.txt"), bytes) yield* put(path.join(dir, "null-byte.txt"), bytes)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "null-byte.txt") }, ctx)).rejects.toThrow(
"Cannot read binary file",
)
},
})
})
test("rejects known binary extensions", async () => { const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") })
await using tmp = await tmpdir({ expect(err.message).toContain("Cannot read binary file")
init: async (dir) => { }),
await Bun.write(path.join(dir, "module.wasm"), "not really wasm") )
},
}) it.live("rejects known binary extensions", () =>
await Instance.provide({ Effect.gen(function* () {
directory: tmp.path, const dir = yield* tmpdirScoped()
fn: async () => { yield* put(path.join(dir, "module.wasm"), "not really wasm")
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "module.wasm") }, ctx)).rejects.toThrow( const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") })
"Cannot read binary file", expect(err.message).toContain("Cannot read binary file")
}),
) )
},
})
})
}) })