refactor(effect): wire read tool through services
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.pull/21016/head
parent
62f1421120
commit
64f6c66984
|
|
@ -235,6 +235,22 @@ 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. In the temporary `async execute(...)` implementation, call service methods with `await Effect.runPromise(...)` instead of falling back to static async facades.
|
||||||
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect } from "effect"
|
import { Effect } 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"
|
||||||
|
|
@ -27,217 +28,228 @@ const parameters = z.object({
|
||||||
|
|
||||||
export const ReadTool = Tool.defineEffect(
|
export const ReadTool = Tool.defineEffect(
|
||||||
"read",
|
"read",
|
||||||
Effect.succeed({
|
Effect.gen(function* () {
|
||||||
description: DESCRIPTION,
|
const fs = yield* AppFileSystem.Service
|
||||||
parameters,
|
const instruction = yield* Instruction.Service
|
||||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
const lsp = yield* LSP.Service
|
||||||
if (params.offset !== undefined && params.offset < 1) {
|
const time = yield* FileTime.Service
|
||||||
throw new Error("offset must be greater than or equal to 1")
|
|
||||||
}
|
|
||||||
let filepath = params.filePath
|
|
||||||
if (!path.isAbsolute(filepath)) {
|
|
||||||
filepath = path.resolve(Instance.directory, filepath)
|
|
||||||
}
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
filepath = Filesystem.normalizePath(filepath)
|
|
||||||
}
|
|
||||||
const title = path.relative(Instance.worktree, filepath)
|
|
||||||
|
|
||||||
const stat = Filesystem.stat(filepath)
|
return {
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters,
|
||||||
|
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||||
|
if (params.offset !== undefined && params.offset < 1) {
|
||||||
|
throw new Error("offset must be greater than or equal to 1")
|
||||||
|
}
|
||||||
|
let filepath = params.filePath
|
||||||
|
if (!path.isAbsolute(filepath)) {
|
||||||
|
filepath = path.resolve(Instance.directory, filepath)
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
filepath = Filesystem.normalizePath(filepath)
|
||||||
|
}
|
||||||
|
const title = path.relative(Instance.worktree, filepath)
|
||||||
|
|
||||||
await assertExternalDirectory(ctx, filepath, {
|
const stat = await Effect.runPromise(fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))))
|
||||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
|
||||||
kind: stat?.isDirectory() ? "directory" : "file",
|
|
||||||
})
|
|
||||||
|
|
||||||
await ctx.ask({
|
await assertExternalDirectory(ctx, filepath, {
|
||||||
permission: "read",
|
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
||||||
patterns: [filepath],
|
kind: stat?.type === "Directory" ? "directory" : "file",
|
||||||
always: ["*"],
|
})
|
||||||
metadata: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!stat) {
|
await ctx.ask({
|
||||||
const dir = path.dirname(filepath)
|
permission: "read",
|
||||||
const base = path.basename(filepath)
|
patterns: [filepath],
|
||||||
|
always: ["*"],
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
|
|
||||||
const suggestions = await fs
|
if (!stat) {
|
||||||
.readdir(dir)
|
const dir = path.dirname(filepath)
|
||||||
.then((entries) =>
|
const base = path.basename(filepath)
|
||||||
entries
|
|
||||||
.filter(
|
const suggestions = await Effect.runPromise(
|
||||||
(entry) =>
|
fs.readDirectory(dir).pipe(
|
||||||
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
|
Effect.map((entries) =>
|
||||||
)
|
entries
|
||||||
.map((entry) => path.join(dir, entry))
|
.filter(
|
||||||
.slice(0, 3),
|
(entry) =>
|
||||||
|
entry.toLowerCase().includes(base.toLowerCase()) ||
|
||||||
|
base.toLowerCase().includes(entry.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map((entry) => path.join(dir, entry))
|
||||||
|
.slice(0, 3),
|
||||||
|
),
|
||||||
|
Effect.catch(() => Effect.succeed([] as string[])),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.catch(() => [])
|
|
||||||
|
|
||||||
if (suggestions.length > 0) {
|
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}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`File not found: ${filepath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`File not found: ${filepath}`)
|
if (stat.type === "Directory") {
|
||||||
}
|
const dirents = await Effect.runPromise(fs.readDirectoryEntries(filepath))
|
||||||
|
const entries = await Promise.all(
|
||||||
|
dirents.map(async (dirent) => {
|
||||||
|
if (dirent.type === "directory") return dirent.name + "/"
|
||||||
|
if (dirent.type === "symlink") {
|
||||||
|
const target = await Effect.runPromise(
|
||||||
|
fs.stat(path.join(filepath, dirent.name)).pipe(Effect.catch(() => Effect.succeed(undefined))),
|
||||||
|
)
|
||||||
|
if (target?.type === "Directory") return dirent.name + "/"
|
||||||
|
}
|
||||||
|
return dirent.name
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
entries.sort((a, b) => a.localeCompare(b))
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
||||||
const dirents = await fs.readdir(filepath, { withFileTypes: true })
|
const offset = params.offset ?? 1
|
||||||
const entries = await Promise.all(
|
const start = offset - 1
|
||||||
dirents.map(async (dirent) => {
|
const sliced = entries.slice(start, start + limit)
|
||||||
if (dirent.isDirectory()) return dirent.name + "/"
|
const truncated = start + sliced.length < entries.length
|
||||||
if (dirent.isSymbolicLink()) {
|
|
||||||
const target = await fs.stat(path.join(filepath, dirent.name)).catch(() => undefined)
|
const output = [
|
||||||
if (target?.isDirectory()) return dirent.name + "/"
|
`<path>${filepath}</path>`,
|
||||||
}
|
`<type>directory</type>`,
|
||||||
return dirent.name
|
`<entries>`,
|
||||||
}),
|
sliced.join("\n"),
|
||||||
)
|
truncated
|
||||||
entries.sort((a, b) => a.localeCompare(b))
|
? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
|
||||||
|
: `\n(${entries.length} entries)`,
|
||||||
|
`</entries>`,
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
output,
|
||||||
|
metadata: {
|
||||||
|
preview: sliced.slice(0, 20).join("\n"),
|
||||||
|
truncated,
|
||||||
|
loaded: [] as string[],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instructions = await Effect.runPromise(instruction.resolve(ctx.messages, filepath, ctx.messageID))
|
||||||
|
|
||||||
|
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||||
|
const mime = Filesystem.mimeType(filepath)
|
||||||
|
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||||
|
const isPdf = mime === "application/pdf"
|
||||||
|
if (isImage || isPdf) {
|
||||||
|
const msg = `${isImage ? "Image" : "PDF"} read successfully`
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
output: msg,
|
||||||
|
metadata: {
|
||||||
|
preview: msg,
|
||||||
|
truncated: false,
|
||||||
|
loaded: instructions.map((i) => i.filepath),
|
||||||
|
},
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
mime,
|
||||||
|
url: `data:${mime};base64,${Buffer.from(await Effect.runPromise(fs.readFile(filepath))).toString("base64")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBinary = await isBinaryFile(filepath, Number(stat.size))
|
||||||
|
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
|
||||||
|
|
||||||
|
const stream = createReadStream(filepath, { encoding: "utf8" })
|
||||||
|
const rl = createInterface({
|
||||||
|
input: stream,
|
||||||
|
// Note: we use the crlfDelay option to recognize all instances of CR LF
|
||||||
|
// ('\r\n') in file as a single line break.
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
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 raw: string[] = []
|
||||||
const truncated = start + sliced.length < entries.length
|
let bytes = 0
|
||||||
|
let lines = 0
|
||||||
|
let truncatedByBytes = false
|
||||||
|
let hasMoreLines = false
|
||||||
|
try {
|
||||||
|
for await (const text of rl) {
|
||||||
|
lines += 1
|
||||||
|
if (lines <= start) continue
|
||||||
|
|
||||||
const output = [
|
if (raw.length >= limit) {
|
||||||
`<path>${filepath}</path>`,
|
hasMoreLines = true
|
||||||
`<type>directory</type>`,
|
continue
|
||||||
`<entries>`,
|
}
|
||||||
sliced.join("\n"),
|
|
||||||
truncated
|
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
||||||
? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
|
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
||||||
: `\n(${entries.length} entries)`,
|
if (bytes + size > MAX_BYTES) {
|
||||||
`</entries>`,
|
truncatedByBytes = true
|
||||||
].join("\n")
|
hasMoreLines = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
raw.push(line)
|
||||||
|
bytes += size
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rl.close()
|
||||||
|
stream.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines < offset && !(lines === 0 && offset === 1)) {
|
||||||
|
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>"
|
||||||
|
|
||||||
|
await Effect.runPromise(lsp.touchFile(filepath, false))
|
||||||
|
await Effect.runPromise(time.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 {
|
return {
|
||||||
title,
|
title,
|
||||||
output,
|
output,
|
||||||
metadata: {
|
metadata: {
|
||||||
preview: sliced.slice(0, 20).join("\n"),
|
preview,
|
||||||
truncated,
|
truncated,
|
||||||
loaded: [] as string[],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const instructions = await Instruction.resolve(ctx.messages, filepath, ctx.messageID)
|
|
||||||
|
|
||||||
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
|
||||||
const mime = Filesystem.mimeType(filepath)
|
|
||||||
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
|
||||||
const isPdf = mime === "application/pdf"
|
|
||||||
if (isImage || isPdf) {
|
|
||||||
const msg = `${isImage ? "Image" : "PDF"} read successfully`
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
output: msg,
|
|
||||||
metadata: {
|
|
||||||
preview: msg,
|
|
||||||
truncated: false,
|
|
||||||
loaded: instructions.map((i) => i.filepath),
|
loaded: instructions.map((i) => i.filepath),
|
||||||
},
|
},
|
||||||
attachments: [
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
mime,
|
|
||||||
url: `data:${mime};base64,${Buffer.from(await Filesystem.readBytes(filepath)).toString("base64")}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
}
|
||||||
const isBinary = await isBinaryFile(filepath, Number(stat.size))
|
|
||||||
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
|
|
||||||
|
|
||||||
const stream = createReadStream(filepath, { encoding: "utf8" })
|
|
||||||
const rl = createInterface({
|
|
||||||
input: stream,
|
|
||||||
// Note: we use the crlfDelay option to recognize all instances of CR LF
|
|
||||||
// ('\r\n') in file as a single line break.
|
|
||||||
crlfDelay: Infinity,
|
|
||||||
})
|
|
||||||
|
|
||||||
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
|
||||||
const offset = params.offset ?? 1
|
|
||||||
const start = offset - 1
|
|
||||||
const raw: string[] = []
|
|
||||||
let bytes = 0
|
|
||||||
let lines = 0
|
|
||||||
let truncatedByBytes = false
|
|
||||||
let hasMoreLines = false
|
|
||||||
try {
|
|
||||||
for await (const text of rl) {
|
|
||||||
lines += 1
|
|
||||||
if (lines <= start) continue
|
|
||||||
|
|
||||||
if (raw.length >= limit) {
|
|
||||||
hasMoreLines = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
if (bytes + size > MAX_BYTES) {
|
|
||||||
truncatedByBytes = true
|
|
||||||
hasMoreLines = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
raw.push(line)
|
|
||||||
bytes += size
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
rl.close()
|
|
||||||
stream.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines < offset && !(lines === 0 && offset === 1)) {
|
|
||||||
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
|
|
||||||
await 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),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -280,7 +292,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)
|
||||||
|
|
|
||||||
|
|
@ -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,167 +61,176 @@ 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,
|
||||||
Service,
|
never,
|
||||||
Effect.gen(function* () {
|
| Config.Service
|
||||||
const config = yield* Config.Service
|
| Plugin.Service
|
||||||
const plugin = yield* Plugin.Service
|
| Question.Service
|
||||||
|
| Todo.Service
|
||||||
|
| LSP.Service
|
||||||
|
| FileTime.Service
|
||||||
|
| Instruction.Service
|
||||||
|
| AppFileSystem.Service
|
||||||
|
> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
|
||||||
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
|
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
|
||||||
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
|
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||||
const custom: Tool.Info[] = []
|
const custom: Tool.Info[] = []
|
||||||
|
|
||||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
init: async (initCtx) => ({
|
init: async (initCtx) => ({
|
||||||
parameters: z.object(def.args),
|
parameters: z.object(def.args),
|
||||||
description: def.description,
|
description: def.description,
|
||||||
execute: async (args, toolCtx) => {
|
execute: async (args, toolCtx) => {
|
||||||
const pluginCtx = {
|
const pluginCtx = {
|
||||||
...toolCtx,
|
...toolCtx,
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
worktree: ctx.worktree,
|
worktree: ctx.worktree,
|
||||||
} as unknown as PluginToolContext
|
} as unknown as PluginToolContext
|
||||||
const result = await def.execute(args as any, pluginCtx)
|
const result = await def.execute(args as any, pluginCtx)
|
||||||
const out = await Truncate.output(result, {}, initCtx?.agent)
|
const out = await Truncate.output(result, {}, initCtx?.agent)
|
||||||
return {
|
return {
|
||||||
title: "",
|
title: "",
|
||||||
output: out.truncated ? out.content : result,
|
output: out.truncated ? out.content : result,
|
||||||
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dirs = yield* config.directories()
|
const dirs = yield* config.directories()
|
||||||
const matches = dirs.flatMap((dir) =>
|
const matches = dirs.flatMap((dir) =>
|
||||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||||
)
|
|
||||||
if (matches.length) yield* config.waitForDependencies()
|
|
||||||
for (const match of matches) {
|
|
||||||
const namespace = path.basename(match, path.extname(match))
|
|
||||||
const mod = yield* Effect.promise(
|
|
||||||
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
|
|
||||||
)
|
|
||||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
|
||||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins = yield* plugin.list()
|
|
||||||
for (const p of plugins) {
|
|
||||||
for (const [id, def] of Object.entries(p.tool ?? {})) {
|
|
||||||
custom.push(fromPlugin(id, def))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { custom }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const invalid = yield* build(InvalidTool)
|
|
||||||
const ask = yield* build(QuestionTool)
|
|
||||||
const bash = yield* build(BashTool)
|
|
||||||
const read = yield* build(ReadTool)
|
|
||||||
const glob = yield* build(GlobTool)
|
|
||||||
const grep = yield* build(GrepTool)
|
|
||||||
const edit = yield* build(EditTool)
|
|
||||||
const write = yield* build(WriteTool)
|
|
||||||
const task = yield* build(TaskTool)
|
|
||||||
const fetch = yield* build(WebFetchTool)
|
|
||||||
const todo = yield* build(TodoWriteTool)
|
|
||||||
const search = yield* build(WebSearchTool)
|
|
||||||
const code = yield* build(CodeSearchTool)
|
|
||||||
const skill = yield* build(SkillTool)
|
|
||||||
const patch = yield* build(ApplyPatchTool)
|
|
||||||
const lsp = yield* build(LspTool)
|
|
||||||
const batch = yield* build(BatchTool)
|
|
||||||
const plan = yield* build(PlanExitTool)
|
|
||||||
|
|
||||||
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
|
|
||||||
const cfg = yield* config.get()
|
|
||||||
const question =
|
|
||||||
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
|
||||||
|
|
||||||
return [
|
|
||||||
invalid,
|
|
||||||
...(question ? [ask] : []),
|
|
||||||
bash,
|
|
||||||
read,
|
|
||||||
glob,
|
|
||||||
grep,
|
|
||||||
edit,
|
|
||||||
write,
|
|
||||||
task,
|
|
||||||
fetch,
|
|
||||||
todo,
|
|
||||||
search,
|
|
||||||
code,
|
|
||||||
skill,
|
|
||||||
patch,
|
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
|
|
||||||
...(cfg.experimental?.batch_tool === true ? [batch] : []),
|
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
|
|
||||||
...custom,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const tools = yield* all(s.custom)
|
|
||||||
return tools.map((t) => t.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const tools = Effect.fn("ToolRegistry.tools")(function* (
|
|
||||||
model: { providerID: ProviderID; modelID: ModelID },
|
|
||||||
agent?: Agent.Info,
|
|
||||||
) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const allTools = yield* all(s.custom)
|
|
||||||
const filtered = allTools.filter((tool) => {
|
|
||||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
|
||||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
|
||||||
}
|
|
||||||
|
|
||||||
const usePatch =
|
|
||||||
!!Env.get("OPENCODE_E2E_LLM_URL") ||
|
|
||||||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
|
|
||||||
if (tool.id === "apply_patch") return usePatch
|
|
||||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return yield* Effect.forEach(
|
|
||||||
filtered,
|
|
||||||
Effect.fnUntraced(function* (tool: Tool.Info) {
|
|
||||||
using _ = log.time(tool.id)
|
|
||||||
const next = yield* Effect.promise(() => tool.init({ agent }))
|
|
||||||
const output = {
|
|
||||||
description: next.description,
|
|
||||||
parameters: next.parameters,
|
|
||||||
}
|
|
||||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
|
||||||
return {
|
|
||||||
id: tool.id,
|
|
||||||
description: output.description,
|
|
||||||
parameters: output.parameters,
|
|
||||||
execute: next.execute,
|
|
||||||
formatValidationError: next.formatValidationError,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
)
|
||||||
})
|
if (matches.length) yield* config.waitForDependencies()
|
||||||
|
for (const match of matches) {
|
||||||
|
const namespace = path.basename(match, path.extname(match))
|
||||||
|
const mod = yield* Effect.promise(
|
||||||
|
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
|
||||||
|
)
|
||||||
|
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||||
|
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Service.of({ ids, named: { task, read }, tools })
|
const plugins = yield* plugin.list()
|
||||||
}),
|
for (const p of plugins) {
|
||||||
)
|
for (const [id, def] of Object.entries(p.tool ?? {})) {
|
||||||
|
custom.push(fromPlugin(id, def))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { custom }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const invalid = yield* build(InvalidTool)
|
||||||
|
const ask = yield* build(QuestionTool)
|
||||||
|
const bash = yield* build(BashTool)
|
||||||
|
const read = yield* build(ReadTool)
|
||||||
|
const glob = yield* build(GlobTool)
|
||||||
|
const grep = yield* build(GrepTool)
|
||||||
|
const edit = yield* build(EditTool)
|
||||||
|
const write = yield* build(WriteTool)
|
||||||
|
const task = yield* build(TaskTool)
|
||||||
|
const fetch = yield* build(WebFetchTool)
|
||||||
|
const todo = yield* build(TodoWriteTool)
|
||||||
|
const search = yield* build(WebSearchTool)
|
||||||
|
const code = yield* build(CodeSearchTool)
|
||||||
|
const skill = yield* build(SkillTool)
|
||||||
|
const patch = yield* build(ApplyPatchTool)
|
||||||
|
const lsp = yield* build(LspTool)
|
||||||
|
const batch = yield* build(BatchTool)
|
||||||
|
const plan = yield* build(PlanExitTool)
|
||||||
|
|
||||||
|
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||||
|
|
||||||
|
return [
|
||||||
|
invalid,
|
||||||
|
...(question ? [ask] : []),
|
||||||
|
bash,
|
||||||
|
read,
|
||||||
|
glob,
|
||||||
|
grep,
|
||||||
|
edit,
|
||||||
|
write,
|
||||||
|
task,
|
||||||
|
fetch,
|
||||||
|
todo,
|
||||||
|
search,
|
||||||
|
code,
|
||||||
|
skill,
|
||||||
|
patch,
|
||||||
|
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
|
||||||
|
...(cfg.experimental?.batch_tool === true ? [batch] : []),
|
||||||
|
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
|
||||||
|
...custom,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const tools = yield* all(s.custom)
|
||||||
|
return tools.map((t) => t.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const tools = Effect.fn("ToolRegistry.tools")(function* (
|
||||||
|
model: { providerID: ProviderID; modelID: ModelID },
|
||||||
|
agent?: Agent.Info,
|
||||||
|
) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const allTools = yield* all(s.custom)
|
||||||
|
const filtered = allTools.filter((tool) => {
|
||||||
|
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||||
|
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||||
|
}
|
||||||
|
|
||||||
|
const usePatch =
|
||||||
|
!!Env.get("OPENCODE_E2E_LLM_URL") ||
|
||||||
|
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
|
||||||
|
if (tool.id === "apply_patch") return usePatch
|
||||||
|
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return yield* Effect.forEach(
|
||||||
|
filtered,
|
||||||
|
Effect.fnUntraced(function* (tool: Tool.Info) {
|
||||||
|
using _ = log.time(tool.id)
|
||||||
|
const next = yield* Effect.promise(() => tool.init({ agent }))
|
||||||
|
const output = {
|
||||||
|
description: next.description,
|
||||||
|
parameters: next.parameters,
|
||||||
|
}
|
||||||
|
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||||
|
return {
|
||||||
|
id: tool.id,
|
||||||
|
description: output.description,
|
||||||
|
parameters: output.parameters,
|
||||||
|
execute: next.execute,
|
||||||
|
formatValidationError: next.formatValidationError,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ids, named: { task, read }, tools })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.unwrap(
|
export const defaultLayer = Layer.unwrap(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
import path from "path"
|
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 { Instance } from "../../src/project/instance"
|
||||||
import { ReadTool as ReadToolFx } from "../../src/tool/read"
|
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 { Tool } from "../../src/tool/tool"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { Permission } from "../../src/permission"
|
import { testEffect } from "../lib/effect"
|
||||||
import { Agent } from "../../src/agent/agent"
|
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
|
||||||
|
|
||||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||||
|
|
||||||
|
|
@ -27,185 +33,186 @@ const ctx = {
|
||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReadTool = {
|
const it = testEffect(
|
||||||
init: async () => ({
|
Layer.mergeAll(
|
||||||
execute: (args: Tool.InferParameters<typeof ReadToolFx>, ctx: Tool.Context) =>
|
Agent.defaultLayer,
|
||||||
Effect.runPromise(
|
AppFileSystem.defaultLayer,
|
||||||
ReadToolFx.pipe(
|
CrossSpawnSpawner.defaultLayer,
|
||||||
Effect.flatMap((tool) => Effect.promise(() => tool.init())),
|
FileTime.defaultLayer,
|
||||||
Effect.flatMap((tool) => Effect.promise(() => tool.execute(args, ctx))),
|
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 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)
|
||||||
|
})
|
||||||
|
|
||||||
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")
|
||||||
},
|
|
||||||
})
|
|
||||||
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")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("allows reading file in subdirectory inside project directory", async () => {
|
const result = yield* exec(dir, { filePath: path.join(dir, "test.txt") })
|
||||||
await using tmp = await tmpdir({
|
expect(result.output).toContain("hello world")
|
||||||
init: async (dir) => {
|
}),
|
||||||
await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content")
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
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")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("asks for external_directory permission when reading absolute path outside project", async () => {
|
it.live("allows reading file in subdirectory inside project directory", () =>
|
||||||
await using outerTmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const dir = yield* tmpdirScoped()
|
||||||
await Bun.write(path.join(dir, "secret.txt"), "secret data")
|
yield* put(path.join(dir, "subdir", "test.txt"), "nested content")
|
||||||
},
|
|
||||||
})
|
const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "test.txt") })
|
||||||
await using tmp = await tmpdir({ git: true })
|
expect(result.output).toContain("nested content")
|
||||||
await Instance.provide({
|
}),
|
||||||
directory: tmp.path,
|
)
|
||||||
fn: async () => {
|
|
||||||
const read = await ReadTool.init()
|
it.live("asks for external_directory permission when reading absolute path outside project", () =>
|
||||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
Effect.gen(function* () {
|
||||||
const testCtx = {
|
const outer = yield* tmpdirScoped()
|
||||||
...ctx,
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
yield* put(path.join(outer, "secret.txt"), "secret data")
|
||||||
requests.push(req)
|
|
||||||
},
|
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||||
}
|
const next = {
|
||||||
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
|
...ctx,
|
||||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||||
expect(extDirReq).toBeDefined()
|
requests.push(req)
|
||||||
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*")))
|
},
|
||||||
},
|
}
|
||||||
})
|
|
||||||
})
|
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") {
|
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 requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||||
})
|
const next = {
|
||||||
await Instance.provide({
|
...ctx,
|
||||||
directory: tmp.path,
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||||
fn: async () => {
|
requests.push(req)
|
||||||
const read = await ReadTool.init()
|
},
|
||||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
}
|
||||||
const testCtx = {
|
const target = path.join(dir, "test.txt")
|
||||||
...ctx,
|
const alt = target
|
||||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
.replace(/^[A-Za-z]:/, "")
|
||||||
requests.push(req)
|
.replaceAll("\\", "/")
|
||||||
},
|
.toLowerCase()
|
||||||
}
|
|
||||||
const target = path.join(tmp.path, "test.txt")
|
yield* exec(dir, { filePath: alt }, next)
|
||||||
const alt = target
|
const read = requests.find((item) => item.permission === "read")
|
||||||
.replace(/^[A-Za-z]:/, "")
|
expect(read).toBeDefined()
|
||||||
.replaceAll("\\", "/")
|
expect(read!.patterns).toEqual([full(target)])
|
||||||
.toLowerCase()
|
}),
|
||||||
await read.execute({ filePath: alt }, testCtx)
|
)
|
||||||
const readReq = requests.find((r) => r.permission === "read")
|
|
||||||
expect(readReq).toBeDefined()
|
|
||||||
expect(readReq!.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 requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||||
await using tmp = await tmpdir({ git: true })
|
const next = {
|
||||||
await Instance.provide({
|
...ctx,
|
||||||
directory: tmp.path,
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||||
fn: async () => {
|
requests.push(req)
|
||||||
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 = requests.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 requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||||
const testCtx = {
|
const next = {
|
||||||
...ctx,
|
...ctx,
|
||||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||||
requests.push(req)
|
requests.push(req)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx)
|
|
||||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
yield* fail(dir, { filePath: "../outside.txt" }, next)
|
||||||
expect(extDirReq).toBeUndefined()
|
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", () => {
|
describe("tool.read env file permissions", () => {
|
||||||
|
|
@ -219,261 +226,202 @@ 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 info = yield* provideInstance(dir)(
|
||||||
let askedForEnv = false
|
Effect.gen(function* () {
|
||||||
const ctxWithPermissions = {
|
const agent = yield* Agent.Service
|
||||||
...ctx,
|
return yield* agent.get(agentName)
|
||||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
}),
|
||||||
for (const pattern of req.patterns) {
|
)
|
||||||
const rule = Permission.evaluate(req.permission, pattern, agent.permission)
|
let asked = false
|
||||||
if (rule.action === "ask" && req.permission === "read") {
|
const next = {
|
||||||
askedForEnv = true
|
...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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rule.action === "deny") {
|
},
|
||||||
throw new Permission.DeniedError({ ruleset: agent.permission })
|
}
|
||||||
}
|
|
||||||
}
|
yield* exec(dir, { filePath: path.join(dir, filename) }, next)
|
||||||
},
|
expect(asked).toBe(shouldAsk)
|
||||||
}
|
}),
|
||||||
const read = await ReadTool.init()
|
)
|
||||||
await read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions)
|
}
|
||||||
expect(askedForEnv).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)
|
||||||
},
|
|
||||||
})
|
|
||||||
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.output).toContain("Output capped at")
|
|
||||||
expect(result.output).toContain("Use offset=")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("truncates by line count when limit is specified", async () => {
|
const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
|
||||||
await using tmp = await tmpdir({
|
expect(result.metadata.truncated).toBe(true)
|
||||||
init: async (dir) => {
|
expect(result.output).toContain("Output capped at")
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
expect(result.output).toContain("Use offset=")
|
||||||
await Bun.write(path.join(dir, "many-lines.txt"), lines)
|
}),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
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.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")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not truncate small file", 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()
|
||||||
await Bun.write(path.join(dir, "small.txt"), "hello world")
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
},
|
yield* put(path.join(dir, "many-lines.txt"), lines)
|
||||||
})
|
|
||||||
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.output).toContain("End of file")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("respects offset parameter", async () => {
|
const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
|
||||||
await using tmp = await tmpdir({
|
expect(result.metadata.truncated).toBe(true)
|
||||||
init: async (dir) => {
|
expect(result.output).toContain("Showing lines 1-10 of 100")
|
||||||
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
|
expect(result.output).toContain("Use offset=11")
|
||||||
await Bun.write(path.join(dir, "offset.txt"), lines)
|
expect(result.output).toContain("line0")
|
||||||
},
|
expect(result.output).toContain("line9")
|
||||||
})
|
expect(result.output).not.toContain("line10")
|
||||||
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("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")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws when offset is beyond end of file", async () => {
|
it.live("does not truncate small 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")
|
yield* put(path.join(dir, "small.txt"), "hello world")
|
||||||
await Bun.write(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 result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
|
||||||
await using tmp = await tmpdir({
|
expect(result.metadata.truncated).toBe(false)
|
||||||
init: async (dir) => {
|
expect(result.output).toContain("End of file")
|
||||||
await Bun.write(path.join(dir, "empty.txt"), "")
|
}),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const read = await ReadTool.init()
|
|
||||||
const result = await read.execute({ filePath: path.join(tmp.path, "empty.txt") }, ctx)
|
|
||||||
expect(result.metadata.truncated).toBe(false)
|
|
||||||
expect(result.output).toContain("End of file - total 0 lines")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws when offset > 1 for empty file", async () => {
|
it.live("respects offset parameter", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const dir = yield* tmpdirScoped()
|
||||||
await Bun.write(path.join(dir, "empty.txt"), "")
|
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
|
||||||
},
|
yield* put(path.join(dir, "offset.txt"), lines)
|
||||||
})
|
|
||||||
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 () => {
|
const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
|
||||||
await using tmp = await tmpdir({
|
expect(result.output).toContain("10: line10")
|
||||||
init: async (dir) => {
|
expect(result.output).toContain("14: line14")
|
||||||
await Promise.all(
|
expect(result.output).not.toContain("9: line10")
|
||||||
Array.from({ length: 10 }, (_, i) => Bun.write(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`)),
|
expect(result.output).not.toContain("15: line15")
|
||||||
)
|
expect(result.output).toContain("line10")
|
||||||
},
|
expect(result.output).toContain("line14")
|
||||||
})
|
expect(result.output).not.toContain("line0")
|
||||||
await Instance.provide({
|
expect(result.output).not.toContain("line15")
|
||||||
directory: tmp.path,
|
}),
|
||||||
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.output).not.toContain("Showing 5 of 10 entries")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("truncates long lines", 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 longLine = "x".repeat(3000)
|
const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
|
||||||
await Bun.write(path.join(dir, "long-line.txt"), longLine)
|
yield* put(path.join(dir, "short.txt"), lines)
|
||||||
},
|
|
||||||
})
|
|
||||||
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.length).toBeLessThan(3000)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("image files set truncated to false", 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) => {
|
}),
|
||||||
// 1x1 red PNG
|
)
|
||||||
const png = Buffer.from(
|
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
|
|
||||||
"base64",
|
|
||||||
)
|
|
||||||
await Bun.write(path.join(dir, "image.png"), 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.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")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("large image files are properly attached without error", async () => {
|
it.live("allows reading empty file at default offset", () =>
|
||||||
await Instance.provide({
|
Effect.gen(function* () {
|
||||||
directory: FIXTURES_DIR,
|
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(FIXTURES_DIR, "large-image.png") }, ctx)
|
|
||||||
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")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => {
|
const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
|
||||||
await using tmp = await tmpdir({
|
expect(result.metadata.truncated).toBe(false)
|
||||||
init: async (dir) => {
|
expect(result.output).toContain("End of file - total 0 lines")
|
||||||
// FlatBuffers schema content
|
}),
|
||||||
const fbsContent = `namespace MyGame;
|
)
|
||||||
|
|
||||||
|
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 {
|
table Monster {
|
||||||
pos:Vec3;
|
pos:Vec3;
|
||||||
|
|
@ -482,79 +430,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({
|
expect(result.attachments).toBeUndefined()
|
||||||
directory: tmp.path,
|
expect(result.output).toContain("namespace MyGame")
|
||||||
fn: async () => {
|
expect(result.output).toContain("table Monster")
|
||||||
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.output).toContain("namespace MyGame")
|
|
||||||
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({
|
expect(result.output).toContain("test content")
|
||||||
directory: tmp.path,
|
expect(result.output).toContain("system-reminder")
|
||||||
fn: async () => {
|
expect(result.output).toContain("Test Instructions")
|
||||||
const read = await ReadTool.init()
|
expect(result.metadata.loaded).toBeDefined()
|
||||||
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
|
expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
|
||||||
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(tmp.path, "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")
|
||||||
)
|
}),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue