refactor(core): simplify location filesystem (#31545)

This commit is contained in:
Dax 2026-06-09 14:28:45 -04:00 committed by GitHub
parent 0777cf1ccf
commit 132ef57272
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 1054 additions and 1422 deletions

View file

@ -568,6 +568,7 @@
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",

View file

@ -3,7 +3,13 @@ import { serverAttachmentFile } from "./server-attachment"
describe("serverAttachmentFile", () => {
test("creates a file from server text content", async () => {
const file = serverAttachmentFile("docs/readme.txt", { type: "text", content: "hello", mime: "text/plain" })
const file = serverAttachmentFile("docs/readme.txt", {
uri: "file:///docs/readme.txt",
name: "readme.txt",
content: "hello",
encoding: "utf8",
mime: "text/plain",
})
expect(file.name).toBe("readme.txt")
expect(file.type).toBe("text/plain")
@ -12,7 +18,8 @@ describe("serverAttachmentFile", () => {
test("creates a file from server base64 content", async () => {
const file = serverAttachmentFile("images/pixel.png", {
type: "binary",
uri: "file:///images/pixel.png",
name: "pixel.png",
content: "aGVsbG8=",
encoding: "base64",
mime: "image/png",

View file

@ -1,8 +1,8 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSystemBinaryContent, FileSystemTextContent } from "@opencode-ai/sdk/v2"
import type { FileSystemContent } from "@opencode-ai/sdk/v2"
export function serverAttachmentFile(path: string, data: FileSystemTextContent | FileSystemBinaryContent) {
export function serverAttachmentFile(path: string, data: FileSystemContent) {
const content =
data.type === "text" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0))
data.encoding === "utf8" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0))
return new File([content], getFilename(path), { type: data.mime })
}

View file

@ -2,162 +2,32 @@ export * as FileSystem from "./filesystem"
import path from "path"
import { pathToFileURL } from "url"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import { Context, Effect, Layer, Option, Schema, Stream } from "effect"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Protected } from "./filesystem/protected"
import { Ripgrep } from "./filesystem/ripgrep"
import { ToolOutputStore } from "./tool-output-store"
import { Search } from "./filesystem/search"
export const ReadInput = Schema.Struct({
path: Schema.String,
path: RelativePath,
})
export type ReadInput = typeof ReadInput.Type
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const READ_SAMPLE_BYTES = 4 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
export class BinaryFileError extends Error {
constructor(readonly resource: string) {
super(`Cannot read binary file: ${resource}`)
this.name = "BinaryFileError"
}
}
const BINARY_EXTENSIONS = new Set([
".zip",
".tar",
".gz",
".exe",
".dll",
".so",
".class",
".jar",
".war",
".7z",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".odt",
".ods",
".odp",
".bin",
".dat",
".obj",
".o",
".a",
".lib",
".wasm",
".pyc",
".pyo",
])
export const isBinary = (resource: string, bytes: Uint8Array) => {
if (BINARY_EXTENSIONS.has(path.extname(resource).toLowerCase())) return true
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const supportedImageMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
}
export class MediaIngestLimitError extends Error {
constructor(
readonly resource: string,
readonly maximumBytes: number,
) {
super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
this.name = "MediaIngestLimitError"
}
}
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
type: Schema.Literal("text"),
export const Content = Schema.Struct({
uri: Schema.String,
name: Schema.String.pipe(Schema.optional),
content: Schema.String,
encoding: Schema.Literals(["utf8", "base64"]),
mime: Schema.String,
}) {}
export class BinaryContent extends Schema.Class<BinaryContent>("FileSystem.BinaryContent")({
type: Schema.Literal("binary"),
content: Schema.String,
encoding: Schema.Literal("base64"),
mime: Schema.String,
}) {}
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
}).annotate({ identifier: "FileSystem.Content" })
export type Content = typeof Content.Type
export const TextPageInput = Schema.Struct({
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
})
export type TextPageInput = typeof TextPageInput.Type
export class TextPage extends Schema.Class<TextPage>("FileSystem.TextPage")({
type: Schema.Literal("text-page"),
content: Schema.String,
mime: Schema.String,
offset: PositiveInt,
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export class ReadPath extends Schema.Class<ReadPath>("FileSystem.ReadPath")({
type: Schema.Literals(["file", "directory"]),
resource: Schema.String,
}) {}
export const ListInput = Schema.Struct({
path: Schema.String.pipe(Schema.optional),
path: RelativePath.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
export const ListPageInput = Schema.Struct({
...ListInput.fields,
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(2_000)).pipe(Schema.optional),
})
export type ListPageInput = typeof ListPageInput.Type
export class ListTarget extends Schema.Class<ListTarget>("FileSystem.ListTarget")({
absolute: Schema.String,
real: Schema.String,
directory: Schema.String,
root: Schema.String,
resource: Schema.String,
}) {}
/** Canonical root and permission resource for Location-scoped search. */
export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget")({
real: Schema.String,
root: Schema.String,
resource: Schema.String,
type: Schema.Literals(["file", "directory"]),
}) {}
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
path: RelativePath,
uri: Schema.String,
@ -165,12 +35,6 @@ export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
mime: Schema.String,
}) {}
export class ListPage extends Schema.Class<ListPage>("FileSystem.ListPage")({
entries: Schema.Array(Entry),
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export const FindInput = Schema.Struct({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
@ -185,7 +49,7 @@ export const GrepInput = Schema.Struct({
})
export type GrepInput = typeof GrepInput.Type
export class GrepMatch extends Schema.Class<GrepMatch>("FileSystem.GrepMatch")({
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
path: RelativePath,
lines: Schema.String,
line: PositiveInt,
@ -210,21 +74,9 @@ export const Event = {
export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<Content>
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPath>
readonly readTool: (input: ReadInput, page?: TextPageInput) => Effect.Effect<Content | TextPage>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
/** Resolve a contained canonical search root and its permission resource. */
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
readonly resolveList: (input?: ListInput) => Effect.Effect<ListTarget>
readonly listResolved: (target: ListTarget) => Effect.Effect<Entry[]>
readonly listPage: (input?: ListPageInput) => Effect.Effect<ListPage>
readonly listPageResolved: (
target: ListTarget,
page?: Pick<ListPageInput, "offset" | "limit">,
) => Effect.Effect<ListPage>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
@ -234,47 +86,22 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const ripgrep = yield* Ripgrep.Service
const search = yield* Search.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const ignored = ignore()
const gitignore = yield* fs
.readFileString(path.join(location.project.directory, ".gitignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (gitignore) ignored.add(gitignore)
const ignorefile = yield* fs
.readFileString(path.join(location.project.directory, ".ignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (ignorefile) ignored.add(ignorefile)
const resolve = Effect.fnUntraced(function* (input?: string) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (input && path.isAbsolute(input)) {
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
const real = yield* fs.realPath(input).pipe(Effect.orDie)
const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie)
if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_"))
return yield* Effect.die(new Error("Path escapes managed tool output"))
return { absolute: input, real, directory: managed, root: managedRoot }
}
const selected = { directory: location.directory, root }
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, ...selected }
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
})
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!real) return
if (!FSUtil.contains(selected.root, real)) return
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
if (!info) return
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return new Entry({
path: RelativePath.make(path.relative(selected.directory, absolute)),
@ -284,319 +111,73 @@ export const layer = Layer.effect(
})
})
const scan = Effect.fnUntraced(function* () {
if (location.directory === Global.Path.home && location.project.id === "global") {
const protectedNames = Protected.names()
const nested = new Set(["node_modules", "dist", "build", "target", "vendor"])
return (yield* Effect.forEach(
yield* fs.readDirectoryEntries(location.directory).pipe(Effect.orElseSucceed(() => [])),
(item) =>
Effect.gen(function* () {
if (item.type !== "directory" || item.name.startsWith(".") || protectedNames.has(item.name)) return []
const directory = path.join(location.directory, item.name)
return [
item.name + "/",
...(yield* fs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => []))).flatMap((child) =>
child.type === "directory" && !child.name.startsWith(".") && !nested.has(child.name)
? [`${item.name}/${child.name}/`]
: [],
),
]
}),
)).flat()
}
const files = Array.from(yield* ripgrep.files({ cwd: location.directory }).pipe(Stream.runCollect, Effect.orDie))
const dirs = new Set<string>()
for (const file of files) {
let current = file
while (true) {
const directory = path.dirname(current)
if (directory === "." || directory === current) break
current = directory
dirs.add(directory + "/")
}
}
return [...files, ...dirs]
})
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new ReadPath({
type,
resource: relative,
})
})
const resolveFile = Effect.fnUntraced(function* (input: ReadInput) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return {
real: target.real,
resource: relative,
}
})
const content = (target: { readonly real: string }, bytes: Uint8Array) =>
Effect.gen(function* () {
return Service.of({
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const bytes = yield* fs.readFile(target.real).pipe(Effect.orDie)
const mime = FSUtil.mimeType(target.real)
if (!bytes.includes(0)) {
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
Effect.option,
)
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
}
return new BinaryContent({
type: "binary",
content: Buffer.from(bytes).toString("base64"),
encoding: "base64",
mime,
})
})
const readTool = Effect.fn("FileSystem.readTool")(function* (input: ReadInput, page: TextPageInput = {}) {
const target = yield* resolveFile(input)
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || READ_SAMPLE_BYTES)).pipe(Effect.orDie),
() => new Uint8Array(),
)
const mime = supportedImageMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file
.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
.pipe(Effect.orDie)
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
return new BinaryContent({
type: "binary",
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64",
if (Option.isSome(content))
return {
uri: pathToFileURL(target.real).href,
name: path.basename(target.real),
content: content.value,
encoding: "utf8" as const,
mime,
})
}
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || isBinary(target.resource, first))
return yield* Effect.die(new BinaryFileError(target.resource))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(target.resource))
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
}
text.push(yield* Effect.sync(() => decoder.decode()))
return new TextContent({ type: "text", content: text.join(""), mime: FSUtil.mimeType(target.real) })
}
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
let discard = false
let line = 1
let bytes = 0
let found = false
let truncated = false
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
truncated = true
next ??= line
line++
return
}
found = true
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
truncated = true
next ??= line
line++
return
}
lines.push(text)
bytes += size
line++
}
const consume = (chunk: Uint8Array) => {
if (chunk.includes(0)) throw new BinaryFileError(target.resource)
let text = decoder.decode(chunk, { stream: true })
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
append(current.endsWith("\r") ? current.slice(0, -1) : current)
}
}
yield* Effect.sync(() => consume(first))
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
yield* Effect.sync(() => consume(chunk.value))
}
const tail = yield* Effect.sync(() => decoder.decode())
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
const text = lines.join("\n")
return new TextPage({
type: "text-page",
content: text,
mime: FSUtil.mimeType(target.real),
offset,
truncated,
...(next === undefined ? {} : { next }),
})
}),
)
})
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
const directory = yield* resolve(input.path)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
return new ListTarget({
...directory,
resource: relative,
})
})
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new RootTarget({
...target,
resource: relative,
type,
})
})
const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) {
return yield* fs.readDirectoryEntries(directory.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
})
const listPageResolved = Effect.fn("FileSystem.listPageResolved")(function* (
target: ListTarget,
page: Pick<ListPageInput, "offset" | "limit"> = {},
) {
type Candidate = Entry | { readonly name: string; readonly type: "file" | "directory" }
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? 2_000, 2_000)
const items = yield* fs.readDirectoryEntries(target.real).pipe(Effect.orDie)
const candidates = yield* Effect.forEach(
items,
(item): Effect.Effect<Candidate | undefined> => {
if (item.type === "other") return Effect.succeed(undefined)
if (item.type === "symlink") return entry(path.join(target.absolute, item.name), target)
return Effect.succeed({ name: item.name, type: item.type } as const)
},
{ concurrency: 16 },
).pipe(Effect.map((items) => items.filter((item): item is Candidate => item !== undefined)))
candidates.sort((a, b) => {
return a.type === b.type
? (a instanceof Entry ? a.path : a.name).localeCompare(b instanceof Entry ? b.path : b.name)
: a.type === "directory"
? -1
: 1
})
const selected = candidates.slice(offset - 1, offset - 1 + limit)
const entries = yield* Effect.forEach(
selected,
(item) => (item instanceof Entry ? Effect.succeed(item) : entry(path.join(target.absolute, item.name), target)),
{
concurrency: 16,
},
).pipe(Effect.map((items) => items.filter((item): item is Entry => item !== undefined)))
const truncated = offset - 1 + selected.length < candidates.length
return new ListPage({ entries, truncated, ...(truncated ? { next: offset + selected.length } : {}) })
})
return Service.of({
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolveFile(input)
return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie))
}
return {
uri: pathToFileURL(target.real).href,
name: path.basename(target.real),
content: Buffer.from(bytes).toString("base64"),
encoding: "base64" as const,
mime,
}
}),
resolveReadPath,
readTool,
list: Effect.fn("FileSystem.list")(function* (input) {
return yield* listResolved(yield* resolveList(input))
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(target.absolute, item.name), target), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
resolveRoot,
resolveList,
listResolved,
listPage: Effect.fn("FileSystem.listPage")(function* (input) {
return yield* listPageResolved(yield* resolveList(input), input)
}),
listPageResolved,
find: Effect.fn("FileSystem.find")(function* (input) {
const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/"))
const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/"))
const sorted = input.query.trim()
? fuzzysort.go(input.query.trim(), filtered, { limit: input.limit ?? 100 }).map((item) => item.target)
: filtered.slice(0, input.limit)
return yield* Effect.forEach(sorted, (item) => entry(path.join(location.directory, item))).pipe(
Effect.map((items) => items.filter((item): item is Entry => item !== undefined)),
const found = yield* search
.file({
cwd: location.directory,
query: input.query,
limit: input.limit,
kind: input.type ?? "all",
})
.pipe(Effect.orDie)
return found.map(
(item) =>
new Entry({
path: RelativePath.make(item.path),
uri: pathToFileURL(path.join(location.directory, item.path)).href,
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(item.path),
}),
)
}),
grep: Effect.fn("FileSystem.grep")(function* (input) {
return (yield* ripgrep
return (yield* search
.search({
cwd: location.directory,
pattern: input.pattern,
@ -618,13 +199,8 @@ export const layer = Layer.effect(
}),
)
}),
isIgnored: (input, type) =>
ignored.ignores(
path.relative(location.project.directory, path.join(location.directory, input)) +
(type === "directory" ? "/" : ""),
),
})
}),
)
export const locationLayer = layer.pipe(Layer.provide(Ripgrep.defaultLayer))
export const locationLayer = layer

View file

@ -30,6 +30,11 @@ export interface FileInput {
readonly kind?: "file" | "directory" | "all"
}
export interface FileResult {
readonly path: string
readonly type: "file" | "directory"
}
export interface GlobInput {
readonly cwd: string
readonly pattern: string
@ -61,7 +66,7 @@ export interface Interface {
readonly files: Ripgrep.Interface["files"]
readonly tree: Ripgrep.Interface["tree"]
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, SearchError>
readonly file: (input: FileInput) => Effect.Effect<string[] | undefined, SearchError>
readonly file: (input: FileInput) => Effect.Effect<readonly FileResult[], SearchError>
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError>
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void, SearchError>
readonly warm: (cwd: string) => Effect.Effect<void>
@ -131,17 +136,29 @@ function item(hit: Fff.Hit): Item {
}
}
function collectPaths<T>(items: T[], scores: Array<{ total: number }>, toPath: (item: T) => string): string[] {
const rows = items.flatMap((item, index): Array<{ text: string; score: number }> => {
const text = toPath(item)
if (!text) return []
return [{ text, score: scores[index]?.total ?? 0 }]
function collectPaths<T>(
items: T[],
scores: Array<{ total: number }>,
toResult: (item: T) => FileResult,
): FileResult[] {
const rows = items.flatMap((item, index): Array<FileResult & { score: number }> => {
const result = toResult(item)
if (!result.path) return []
return [{ ...result, score: scores[index]?.total ?? 0 }]
})
rows.sort(
(a, b) => b.score - a.score || a.text.length - b.text.length || (a.text < b.text ? -1 : a.text > b.text ? 1 : 0),
(a, b) =>
b.score - a.score ||
a.path.length - b.path.length ||
(a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
)
return Array.from(new Set(rows.map((item) => item.text)))
const seen = new Set<string>()
return rows.flatMap((item) => {
if (seen.has(item.path)) return []
seen.add(item.path)
return [{ path: item.path, type: item.type }]
})
}
function searchFff(
@ -149,13 +166,16 @@ function searchFff(
kind: "file" | "directory" | "all",
query: string,
opts: { currentFile?: string; pageIndex?: number; pageSize?: number },
): Fff.Result<string[]> {
): Fff.Result<FileResult[]> {
if (kind === "directory") {
const out = pick.directorySearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => normalize(entry.relativePath)),
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
path: normalize(entry.relativePath),
type: "directory",
})),
}
}
if (kind === "all") {
@ -163,14 +183,20 @@ function searchFff(
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => normalize(entry.item.relativePath)),
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
path: normalize(entry.item.relativePath),
type: entry.type,
})),
}
}
const out = pick.fileSearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => normalize(entry.relativePath)),
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
path: normalize(entry.relativePath),
type: "file",
})),
}
}
@ -232,10 +258,6 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
// and does not await the scan; the native background scan starts as soon as
// the picker exists. The `wait` gate dedupes concurrent creation.
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
// The opencode test runtime owns an isolated XDG tree that Windows must
// remove before process exit, so use ripgrep instead of native FFF there.
if (process.env.OPENCODE_TEST_HOME) return undefined
const dir = FSUtil.resolve(cwd)
const existing = state.pick.get(dir)
if (existing) return existing
@ -341,8 +363,9 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
const query = input.query.trim()
const kind = input.kind ?? "file"
const entry = yield* acquire(input.cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
if (!entry) return undefined
const entry = yield* acquire(input.cwd)
if (!entry) return yield* Effect.fail(new Error("fff is unavailable"))
yield* entry.ready
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const fffResult = yield* fffSync(`${kind} search`, () =>
@ -354,14 +377,13 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
).pipe(
Effect.catch((error) =>
Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe(
Effect.as<Fff.Result<string[]> | undefined>(undefined),
Effect.andThen(Effect.fail(error)),
),
),
)
if (!fffResult) return undefined
if (!fffResult.ok) {
yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
return undefined
return yield* Effect.fail(new Error(fffResult.error))
}
const rows = fffResult.value
@ -369,7 +391,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
state,
dir,
query,
rows.map((row) => path.join(dir, row)),
rows.map((row) => path.join(dir, row.path)),
)
return rows.slice(0, limit)
})

View file

@ -34,8 +34,8 @@ export class SizeError extends Schema.TaggedErrorClass<SizeError>()("Image.SizeE
export interface Interface {
readonly normalize: (
resource: string,
content: FileSystem.BinaryContent,
) => Effect.Effect<FileSystem.BinaryContent, ResizerUnavailableError | DecodeError | SizeError>
content: FileSystem.Content & { readonly encoding: "base64" },
) => Effect.Effect<FileSystem.Content & { readonly encoding: "base64" }, ResizerUnavailableError | DecodeError | SizeError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
@ -50,7 +50,10 @@ export const layer = Layer.effect(
catch: () => new ResizerUnavailableError(),
}).pipe(Effect.flatMap((adapter) => adapter.make)),
)
const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) {
const normalize = Effect.fn("Image.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
) {
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>

View file

@ -19,7 +19,7 @@ export const make = Effect.gen(function* () {
)
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.BinaryContent,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: {
readonly autoResize: boolean
readonly maxWidth: number
@ -72,7 +72,7 @@ export const make = Effect.gen(function* () {
for (const [mime, encode] of encoders) {
const candidate = Buffer.from(encode()).toString("base64")
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime })
return { ...content, content: candidate, encoding: "base64" as const, mime }
}
} finally {
resized.free()

View file

@ -19,6 +19,7 @@ import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { FileSystem } from "./filesystem"
import { Watcher } from "./filesystem/watcher"
import { Search } from "./filesystem/search"
import { LocationMutation } from "./location-mutation"
import { LocationSearch } from "./location-search"
import { FileMutation } from "./file-mutation"
@ -124,5 +125,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
ApplicationTools.layer,
Search.defaultLayer,
],
}) {}

View file

@ -2,10 +2,12 @@ export * as LocationSearch from "./location-search"
import path from "path"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { Ripgrep } from "./ripgrep"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { ToolOutputStore } from "./tool-output-store"
/**
* Location-scoped raw search substrate. Search authority is selected only by
@ -25,7 +27,7 @@ export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESU
export const FilesInput = Schema.Struct({
pattern: Schema.String,
...FileSystem.ListInput.fields,
path: Schema.String.pipe(Schema.optional),
limit: ResultLimit.pipe(Schema.optional),
})
export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
@ -33,7 +35,7 @@ export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSigna
export const GrepInput = Schema.Struct({
pattern: Schema.String,
include: Schema.String.pipe(Schema.optional),
...FileSystem.ListInput.fields,
path: Schema.String.pipe(Schema.optional),
limit: ResultLimit.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
@ -89,10 +91,37 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const ripgrep = yield* Ripgrep.Service
const candidate = Effect.fnUntraced(function* (root: FileSystem.RootTarget, cwd: string, value: string) {
const resolve = Effect.fnUntraced(function* (input?: string) {
const directory = input && path.isAbsolute(input) ? path.dirname(input) : location.directory
const absolute = path.resolve(location.directory, input ?? ".")
if (!path.isAbsolute(input ?? "") && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new globalThis.Error("Path escapes the location"))
if (path.isAbsolute(input ?? "")) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (directory !== managed || !path.basename(absolute).startsWith("tool_"))
return yield* Effect.die(new globalThis.Error("Absolute path is not managed tool output"))
}
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
const root = yield* fs.realPath(directory).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new globalThis.Error("Path escapes the search root"))
const info = yield* fs.stat(real).pipe(Effect.orDie)
const type = info.type === "File" ? ("file" as const) : info.type === "Directory" ? ("directory" as const) : undefined
if (!type) return yield* Effect.die(new globalThis.Error("Search root is not a file or directory"))
return { real, root, resource: slash(path.relative(root, real)) || ".", type }
})
const candidate = Effect.fnUntraced(function* (
root: { readonly real: string; readonly root: string; readonly type: "file" | "directory" },
cwd: string,
value: string,
) {
const absolute = path.resolve(cwd, value)
const lexicallyContained =
root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real
@ -115,7 +144,7 @@ export const layer = Layer.effect(
return Service.of({
files: Effect.fn("LocationSearch.files")(function* (input) {
const root = yield* filesystem.resolveRoot(input)
const root = yield* resolve(input.path)
if (root.type !== "directory")
return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
const result = yield* ripgrep.files({
@ -137,7 +166,7 @@ export const layer = Layer.effect(
})
}),
grep: Effect.fn("LocationSearch.grep")(function* (input) {
const root = yield* filesystem.resolveRoot(input)
const root = yield* resolve(input.path)
const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
const result = yield* ripgrep.grep({
cwd,

View file

@ -1,9 +1,8 @@
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/llm"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { NonNegativeInt } from "../schema"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { FileAttachment, Prompt } from "./prompt"
import { SessionSchema } from "./schema"
@ -360,8 +359,8 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
},
})
export type Progress = typeof Progress.Type
@ -371,8 +370,8 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({

View file

@ -1,9 +1,8 @@
export * as SessionMessage from "./message"
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/llm"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { ModelV2 } from "../model"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { Prompt } from "./prompt"
@ -76,25 +75,25 @@ export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Me
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Message.ToolState.Running")({
status: Schema.Literal("running"),
input: Schema.Record(Schema.String, Schema.Unknown),
structured: ToolOutput.Structured,
content: ToolOutput.Content.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Any),
content: ToolContent.pipe(Schema.Array),
}) {}
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Message.ToolState.Completed")({
status: Schema.Literal("completed"),
input: Schema.Record(Schema.String, Schema.Unknown),
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
content: ToolOutput.Content.pipe(Schema.Array),
content: ToolContent.pipe(Schema.Array),
outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths,
structured: ToolOutput.Structured,
structured: Schema.Record(Schema.String, Schema.Any),
result: SessionEvent.Tool.Success.data.fields.result,
}) {}
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Message.ToolState.Error")({
status: Schema.Literal("error"),
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolOutput.Content.pipe(Schema.Array),
structured: ToolOutput.Structured,
content: ToolContent.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Any),
error: SessionEvent.UnknownError,
result: SessionEvent.Tool.Failed.data.fields.result,
}) {}

View file

@ -1,8 +1,7 @@
import {
ToolOutput as LLMToolOutput,
ToolOutput,
type LLMEvent,
type ProviderMetadata,
type ToolOutput as LLMToolOutputType,
type ToolResultValue,
type Usage,
} from "@opencode-ai/llm"
@ -45,13 +44,13 @@ const message = (value: unknown) => {
}
}
type ToolOutput =
| { readonly structured: Record<string, unknown>; readonly content: LLMToolOutputType["content"] }
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: { readonly type: "unknown"; readonly message: string } }
const settledOutput = (value: LLMToolOutputType | undefined, result: ToolResultValue): ToolOutput => {
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
const settled = value ?? LLMToolOutput.fromResultValue(result)
const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
}

View file

@ -38,9 +38,8 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
if (tool.state.status === "completed") {
// TODO: Materialize remote URL and managed file sources before provider-history lowering.
// ToolOutput.toResultValue intentionally rejects unmaterialized sources rather than
// guessing whether a provider can fetch them or leaking host-local resource paths.
// TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result =
tool.provider?.executed === true && tool.state.result !== undefined
? tool.state.result

View file

@ -1,11 +0,0 @@
export * as ToolOutput from "./tool-output"
export {
ToolContent as Content,
ToolFileContent as FileContent,
ToolTextContent as TextContent,
toolFile as file,
toolText as text,
} from "@opencode-ai/llm"
import { Schema } from "effect"
export const Structured = Schema.Record(Schema.String, Schema.Any)

View file

@ -1,6 +1,6 @@
export * as ApplyPatchTool from "./apply-patch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard(
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string) => {

View file

@ -1,7 +1,7 @@
export * as BashTool from "./bash"
import path from "path"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config"
@ -119,7 +119,7 @@ export const layer = Layer.effectDiscard(
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {

View file

@ -8,6 +8,7 @@ import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { QuestionTool } from "./question"
import { ReadTool } from "./read"
import { ReadToolFileSystem } from "./read-filesystem"
import { SkillTool } from "./skill"
import { TodoWriteTool } from "./todowrite"
import { WebFetchTool } from "./webfetch"
@ -34,7 +35,7 @@ export const locationLayer = Layer.mergeAll(
GlobTool.layer,
GrepTool.layer,
QuestionTool.layer,
ReadTool.layer,
ReadTool.layer.pipe(Layer.provide(ReadToolFileSystem.layer)),
SkillTool.layer,
TodoWriteTool.layer,
WebFetchTool.layer,

View file

@ -6,7 +6,7 @@
*/
export * as EditTool from "./edit"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@ -103,7 +103,7 @@ export const layer = Layer.effectDiscard(
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
],
execute: (input, context) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>

View file

@ -1,8 +1,7 @@
export * as GlobTool from "./glob"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@ -42,7 +41,6 @@ export const toModelOutput = (output: ModelOutput) => {
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
@ -53,16 +51,15 @@ export const layer = Layer.effectDiscard(
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot({ path: input.path })
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: root.resource,
root: input.path ?? ".",
path: input.path,
limit: input.limit,
},

View file

@ -1,8 +1,7 @@
export * as GrepTool from "./grep"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { Ripgrep } from "../ripgrep"
import { PermissionV2 } from "../permission"
@ -57,7 +56,6 @@ export const toModelOutput = (output: Output) => {
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
@ -68,16 +66,15 @@ export const layer = Layer.effectDiscard(
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot(input)
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: root.resource,
root: input.path ?? ".",
path: input.path,
include: input.include,
limit: input.limit,

View file

@ -1,6 +1,6 @@
export * as QuestionTool from "./question"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
@ -55,7 +55,7 @@ export const layer = Layer.effectDiscard(
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }),
{ type: "text", text: toModelOutput(input.questions, output.answers) },
],
execute: (input, context) =>
permission

View file

@ -0,0 +1,275 @@
export * as ReadToolFileSystem from "./read-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { AbsolutePath, PositiveInt, RelativePath } from "../schema"
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
export class BinaryFileError extends Error {
constructor(readonly resource: string) {
super(`Cannot read binary file: ${resource}`)
this.name = "BinaryFileError"
}
}
export class MediaIngestLimitError extends Error {
constructor(
readonly resource: string,
readonly maximumBytes: number,
) {
super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
this.name = "MediaIngestLimitError"
}
}
export const PageInput = Schema.Struct({
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
})
export type PageInput = typeof PageInput.Type
export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
type: Schema.Literal("text-page"),
content: Schema.String,
mime: Schema.String,
offset: PositiveInt,
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
entries: Schema.Array(FileSystem.Entry),
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export interface Interface {
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory">
readonly read: (path: AbsolutePath, resource: string, page?: PageInput) => Effect.Effect<FileSystem.Content | TextPage>
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
const extensions = new Set([
".zip", ".tar", ".gz", ".exe", ".dll", ".so", ".class", ".jar", ".war", ".7z", ".doc", ".docx",
".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".bin", ".dat", ".obj", ".o", ".a",
".lib", ".wasm", ".pyc", ".pyo",
])
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const imageMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
}
const binary = (resource: string, bytes: Uint8Array) => {
if (extensions.has(path.extname(resource).toLowerCase())) return true
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
const info = yield* fs.stat(input).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
return type
})
export const read = Effect.fn("ReadTool.read")(function* (
fs: FSUtil.Interface,
input: string,
resource: string,
page: PageInput = {},
) {
const real = yield* fs.realPath(input).pipe(Effect.orDie)
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie),
() => new Uint8Array(),
)
const mime = imageMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
return {
uri: pathToFileURL(real).href,
name: path.basename(real),
content: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total).toString("base64"),
encoding: "base64" as const,
mime,
}
}
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first))
return yield* Effect.die(new BinaryFileError(resource))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource))
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
}
text.push(yield* Effect.sync(() => decoder.decode()))
return {
uri: pathToFileURL(real).href,
name: path.basename(real),
content: text.join(""),
encoding: "utf8" as const,
mime: FSUtil.mimeType(real),
}
}
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
let discard = false
let line = 1
let bytes = 0
let found = false
let truncated = false
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
truncated = true
next ??= line++
return
}
found = true
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
truncated = true
next ??= line++
return
}
lines.push(text)
bytes += size
line++
}
const consume = (chunk: Uint8Array) => {
if (chunk.includes(0)) throw new BinaryFileError(resource)
let text = decoder.decode(chunk, { stream: true })
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
append(current.endsWith("\r") ? current.slice(0, -1) : current)
}
}
yield* Effect.sync(() => consume(first))
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
yield* Effect.sync(() => consume(chunk.value))
}
const tail = yield* Effect.sync(() => decoder.decode())
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
return new TextPage({
type: "text-page",
content: lines.join("\n"),
mime: FSUtil.mimeType(real),
offset,
truncated,
...(next === undefined ? {} : { next }),
})
}),
)
})
export const list = Effect.fn("ReadTool.list")(function* (
fs: FSUtil.Interface,
input: string,
page: PageInput = {},
) {
const real = yield* fs.realPath(input).pipe(Effect.orDie)
const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const entries = yield* Effect.forEach(
items,
(item) =>
Effect.gen(function* () {
const absolute = path.join(real, item.name)
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!target || !FSUtil.contains(real, target)) return
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return new FileSystem.Entry({
path: RelativePath.make(item.name),
uri: pathToFileURL(target).href,
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target),
})
}),
{ concurrency: 16 },
)
const visible = entries
.filter((item): item is FileSystem.Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
const selected = visible.slice(offset - 1, offset - 1 + limit)
const truncated = offset - 1 + selected.length < visible.length
return new ListPage({ entries: selected, truncated, ...(truncated ? { next: offset + selected.length } : {}) })
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return Service.of({
inspect: (path) => inspect(fs, path),
read: (path, resource, page) => read(fs, path, resource, page),
list: (path, page) => list(fs, path, page),
})
}),
)

View file

@ -1,31 +1,38 @@
export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Image } from "../image"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "read"
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
const LocationInput = Schema.Struct({
...FileSystem.ReadInput.fields,
offset: FileSystem.ListPageInput.fields.offset.annotate({
path: Schema.String,
offset: ReadToolFileSystem.PageInput.fields.offset.annotate({
description: "The 1-based directory entry or text line offset to start reading from",
}),
limit: FileSystem.ListPageInput.fields.limit.annotate({
limit: ReadToolFileSystem.PageInput.fields.limit.annotate({
description: "The maximum number of directory entries or text lines to read",
}),
})
const Input = LocationInput
const Output = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const reader = yield* ReadToolFileSystem.Service
const location = yield* Location.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
@ -33,11 +40,12 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
return []
return [
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
@ -45,33 +53,43 @@ export const layer = Layer.effectDiscard(
},
execute: (input, context) => {
return Effect.gen(function* () {
const resolved = yield* filesystem.resolveReadPath(input)
const absolute = path.resolve(location.directory, input.path)
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
const root = yield* fs.realPath(selected).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the allowed read root"))
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
const target = AbsolutePath.make(real)
const type = yield* reader.inspect(target)
yield* permission.assert({
action: name,
resources: [resolved.resource],
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
if (resolved.type === "directory") return yield* filesystem.listPage(input)
const content = yield* filesystem.readTool(input, {
if (type === "directory")
return yield* reader.list(target, { offset: input.offset, limit: input.limit })
const content = yield* reader.read(target, resource, {
offset: input.offset,
limit: input.limit,
})
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
return yield* image
.normalize(resolved.resource, content)
.normalize(resource, { ...content, encoding: "base64" })
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
}
if (content.type === "binary")
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
if ("encoding" in content && content.encoding === "base64")
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource))
return content
}).pipe(
Effect.mapError((error) => {
const message =
error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
? error.message

View file

@ -1,6 +1,6 @@
export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolSettlement } from "@opencode-ai/llm"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
@ -30,7 +30,9 @@ export interface Materialization {
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
}
export interface Settlement extends ToolSettlement {
export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
}

View file

@ -2,7 +2,7 @@ export * as SkillTool from "./skill"
import path from "path"
import { pathToFileURL } from "url"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FSUtil } from "../fs-util"
import { PluginBoot } from "../plugin/boot"
@ -68,7 +68,7 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()

View file

@ -1,6 +1,6 @@
export * as TodoWriteTool from "./todowrite"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { SessionTodo } from "../session/todo"
@ -33,7 +33,7 @@ export const layer = Layer.effectDiscard(
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({

View file

@ -93,19 +93,20 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
),
),
Effect.map((output) =>
ToolOutput.make(
output,
config.toModelOutput?.({ input, output }).map((part) =>
({
structured: output,
content:
config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
source: { type: "data" as const, data: part.data },
uri: `data:${part.mime};base64,${part.data}`,
mime: part.mime,
name: part.name,
},
) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
),
) ?? (typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
}),
),
),
),

View file

@ -1,6 +1,6 @@
export * as WebFetchTool from "./webfetch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
@ -136,7 +136,7 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({

View file

@ -1,6 +1,6 @@
export * as WebSearchTool from "./websearch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { truthy } from "../flag/flag"
@ -194,7 +194,7 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {

View file

@ -6,7 +6,7 @@
*/
export * as WriteTool from "./write"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
@ -57,7 +57,7 @@ export const layer = Layer.effectDiscard(
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {

View file

@ -63,7 +63,7 @@ describe("ApplicationTools", () => {
type: "content",
value: [
{ type: "text", text: "ONCE" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
],
})
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
@ -132,14 +132,14 @@ describe("ApplicationTools", () => {
type: "content",
value: [
{ type: "text", text: "HELLO" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
],
},
output: {
structured: { answer: "HELLO" },
content: [
{ type: "text", text: "HELLO" },
{ type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
],
},
})

View file

@ -48,7 +48,7 @@ describe("file.search", () => {
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
expect(results).toContain("README.md")
expect(results).toContainEqual({ path: "README.md", type: "file" })
}),
)
@ -63,9 +63,9 @@ describe("file.search", () => {
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" })
expect(results).toContain("README.md")
expect(results).toContain("src/")
expect(results).not.toContain("")
expect(results).toContainEqual({ path: "README.md", type: "file" })
expect(results).toContainEqual({ path: "src/", type: "directory" })
expect(results.map((item) => item.path)).not.toContain("")
}),
)
@ -80,7 +80,10 @@ describe("file.search", () => {
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "", limit: 10 })
expect(results?.slice(0, 2)).toEqual(["a.ts", "src/longer-name.ts"])
expect(results.slice(0, 2)).toEqual([
{ path: "a.ts", type: "file" },
{ path: "src/longer-name.ts", type: "file" },
])
}),
)
@ -163,7 +166,7 @@ describe("file.search", () => {
const search = yield* Search.Service
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
expect(results).toContain("alpha-target-two.ts")
expect(results).toContainEqual({ path: "alpha-target-two.ts", type: "file" })
// open() records the query->file association in fff's history db via the
// live picker. It must resolve a remembered file and run without error.

View file

@ -1,27 +1,25 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Search } from "@opencode-ai/core/filesystem/search"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
function provide(directory: string, filesystem = FSUtil.defaultLayer, data = Global.Path.data) {
function provide(directory: string, search = Search.defaultLayer) {
return Effect.provide(
FileSystem.layer.pipe(
Layer.provide(
Layer.mergeAll(
filesystem,
Ripgrep.defaultLayer,
FSUtil.defaultLayer,
search,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Global.layerWith({ data }),
),
),
),
@ -36,411 +34,121 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("FileSystem", () => {
it.live("accepts generated managed output paths and rejects other absolute paths", () =>
withTmp((directory) => {
const worktree = directory
const data = path.join(directory, "data")
return Effect.gen(function* () {
const managed = path.join(data, "tool-output")
const output = path.join(managed, "tool_123")
const unrelated = path.join(directory, "secret.txt")
yield* Effect.promise(() => fs.mkdir(managed, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(output, "failure here"))
yield* Effect.promise(() => fs.writeFile(unrelated, "secret"))
const service = yield* FileSystem.Service
expect(yield* service.read({ path: output })).toMatchObject({ type: "text", content: "failure here" })
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
}).pipe(provide(worktree, FSUtil.defaultLayer, data))
}),
)
it.live("reads text and binary files", () =>
it.live("reads complete text and binary files", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
const text = Array.from({ length: 3_000 }, (_, index) => `line-${index + 1}`).join("\n")
yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), text))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
const service = yield* FileSystem.Service
expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
type: "text",
content: "hello",
const textContent = yield* service.read({ path: RelativePath.make("large.txt") })
expect(textContent).toEqual({
uri: textContent.uri,
name: "large.txt",
content: text,
encoding: "utf8",
mime: "text/plain",
})
expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({
type: "binary",
expect(fileURLToPath(textContent.uri)).toBe(path.join(directory, "large.txt"))
const binaryContent = yield* service.read({ path: RelativePath.make("data.bin") })
expect(binaryContent).toEqual({
uri: binaryContent.uri,
name: "data.bin",
content: "AAEC",
encoding: "base64",
mime: "application/octet-stream",
})
expect(Exit.isFailure(yield* service.readTool({ path: RelativePath.make("data.bin") }).pipe(Effect.exit))).toBe(
true,
)
expect(fileURLToPath(binaryContent.uri)).toBe(path.join(directory, "data.bin"))
}).pipe(provide(directory)),
),
)
it.live("pages large UTF-8 text files by line with continuation", () =>
withTmp((directory) =>
Effect.gen(function* () {
const lines = Array.from({ length: 30 }, (_, index) => `line-${index + 1}`.padEnd(2_000, "x"))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), lines.join("\n")))
const service = yield* FileSystem.Service
const input = { path: RelativePath.make("large.txt") }
const result = yield* service.readTool(input)
expect(result).toMatchObject({
type: "text-page",
offset: 1,
truncated: true,
})
const first = result.type === "text-page" ? result : yield* Effect.die(new Error("Expected a text page"))
expect(first.next).toBeDefined()
const next = first.next!
expect(yield* service.readTool(input, { offset: next, limit: 1 })).toEqual({
type: "text-page",
content: lines[next - 1],
mime: "text/plain",
offset: next,
truncated: true,
next: next + 1,
})
expect(yield* service.readTool(input, { offset: 30 })).toEqual({
type: "text-page",
content: lines[29],
mime: "text/plain",
offset: 30,
truncated: false,
})
}).pipe(provide(directory)),
),
)
it.live("rejects paged text when a late NUL appears after the requested page", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "late-binary.txt")
yield* Effect.promise(() =>
fs.writeFile(
file,
Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0])]),
),
)
const service = yield* FileSystem.Service
expect(
Exit.isFailure(
yield* service.readTool({ path: RelativePath.make("late-binary.txt") }, { limit: 1 }).pipe(Effect.exit),
),
).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("rejects paged text when invalid UTF-8 appears near EOF", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "invalid-utf8.txt")
yield* Effect.promise(() =>
fs.writeFile(
file,
Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0xc3, 0x28])]),
),
)
const service = yield* FileSystem.Service
expect(
Exit.isFailure(
yield* service.readTool({ path: RelativePath.make("invalid-utf8.txt") }, { limit: 1 }).pipe(Effect.exit),
),
).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("rejects PDFs for direct, large, and paged reads", () =>
withTmp((directory) =>
Effect.gen(function* () {
const small = path.join(directory, "small.pdf")
const large = path.join(directory, "large.pdf")
yield* Effect.promise(() => fs.writeFile(small, "%PDF-1.7\nsmall"))
yield* Effect.promise(() =>
fs.writeFile(large, Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(80_000)])),
)
const service = yield* FileSystem.Service
expect(
Exit.isFailure(yield* service.readTool({ path: RelativePath.make("small.pdf") }).pipe(Effect.exit)),
).toBe(true)
expect(
Exit.isFailure(yield* service.readTool({ path: RelativePath.make("large.pdf") }).pipe(Effect.exit)),
).toBe(true)
expect(
Exit.isFailure(
yield* service.readTool({ path: RelativePath.make("large.pdf") }, { limit: 1 }).pipe(Effect.exit),
),
).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("rejects signature-bearing media beyond the ingestion cap before loading", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "huge.png")
yield* Effect.promise(async () => {
const handle = await fs.open(file, "w")
try {
await handle.write(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 0, 8, 0)
await handle.truncate(FileSystem.MAX_MEDIA_INGEST_BYTES + 1)
} finally {
await handle.close()
}
})
const service = yield* FileSystem.Service
const exit = yield* service.readTool({ path: RelativePath.make("huge.png") }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("Media exceeds")
}).pipe(provide(directory)),
),
)
it.live("closes descriptors after successful and failed reads", () =>
withTmp((directory) => {
let active = 0
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
open: (target, options) =>
Effect.acquireRelease(
service.open(target, options).pipe(Effect.tap(() => Effect.sync(() => active++))),
() => Effect.sync(() => active--),
),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
const text = path.join(directory, "text.txt")
const binary = path.join(directory, "binary.pdf")
yield* Effect.promise(() => fs.writeFile(text, "hello"))
yield* Effect.promise(() => fs.writeFile(binary, "%PDF-1.7"))
const service = yield* FileSystem.Service
const before =
process.platform === "win32"
? undefined
: yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
for (let index = 0; index < 50; index++) {
yield* service.readTool({ path: RelativePath.make("text.txt") })
yield* service.readTool({ path: RelativePath.make("binary.pdf") }).pipe(Effect.exit)
}
expect(active).toBe(0)
if (before !== undefined) {
const after = yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
expect(after).toBeLessThanOrEqual(before + 2)
}
yield* Effect.promise(() => fs.rename(text, text + ".moved"))
yield* Effect.promise(() => fs.rename(binary, binary + ".moved"))
}).pipe(provide(directory, filesystem))
}),
)
it.live("lists direct children with relative paths and resolved URIs", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const service = yield* FileSystem.Service
const entries = yield* service.list()
const entries = yield* (yield* FileSystem.Service).list()
expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
{
path: RelativePath.make("src"),
type: "directory",
mime: "application/x-directory",
},
{
path: RelativePath.make("README.md"),
type: "file",
mime: "text/markdown",
},
{ path: RelativePath.make("src"), type: "directory", mime: "application/x-directory" },
{ path: RelativePath.make("README.md"), type: "file", mime: "text/markdown" },
])
expect(
yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
).toEqual(
yield* Effect.promise(() =>
Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
),
expect(yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri)))))).toEqual(
yield* Effect.promise(() => Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))])),
)
}).pipe(provide(directory)),
),
)
it.live("lists stable bounded pages", () =>
it.live("rejects lexical and symlink escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "README.md"), "# Test")
})
const service = yield* FileSystem.Service
expect(yield* service.listPage({ limit: 1 })).toMatchObject({
entries: [{ path: "src", type: "directory" }],
truncated: true,
next: 2,
})
expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
entries: [{ path: "README.md", type: "file" }],
truncated: false,
})
expect((yield* service.resolveList()).resource).toBe(".")
}).pipe(provide(directory)),
),
)
it.live("materializes only the selected direct children for a page", () =>
withTmp((directory) => {
const realPaths: string[] = []
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
realPath: (target) =>
Effect.sync(() => realPaths.push(target)).pipe(Effect.andThen(service.realPath(target))),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "alpha.txt"), "alpha")
await fs.writeFile(path.join(directory, "beta.txt"), "beta")
})
const service = yield* FileSystem.Service
expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
entries: [{ path: "alpha.txt", type: "file" }],
truncated: true,
next: 3,
})
expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
}).pipe(provide(directory, filesystem))
}),
)
it.live("materializes selected page entries with at most 16 concurrent real path lookups", () =>
withTmp((directory) => {
let active = 0
let maximum = 0
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
realPath: (target) =>
target === directory
? service.realPath(target)
: Effect.acquireUseRelease(
Effect.sync(() => {
active++
maximum = Math.max(maximum, active)
}),
() => Effect.sleep("10 millis").pipe(Effect.andThen(service.realPath(target))),
() => Effect.sync(() => active--),
),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all(Array.from({ length: 32 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), ""))),
)
const service = yield* FileSystem.Service
expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
expect(maximum).toBe(16)
}).pipe(provide(directory, filesystem))
}),
)
it.live("caps direct list page service calls at 2000 entries", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all(
Array.from({ length: 2_001 }, (_, index) =>
fs.writeFile(path.join(directory, `${index.toString().padStart(4, "0")}.txt`), ""),
),
),
)
const service = yield* FileSystem.Service
const target = yield* service.resolveList()
expect((yield* service.listPageResolved(target, { limit: 2_001 })).entries).toHaveLength(2_000)
}).pipe(provide(directory)),
),
)
test("rejects page limits over 2000", () => {
const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
expect(() => decode({ limit: 2_001 })).toThrow()
})
it.live("rejects escaping list paths and omits escaping symlink children", () =>
withTmp((directory) =>
Effect.gen(function* () {
expect(Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit))).toBe(true)
if (process.platform === "win32") return
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.writeFile(path.join(outside, "secret.txt"), "secret")
await fs.symlink(outside, path.join(directory, "escape"))
})
const service = yield* FileSystem.Service
expect(
Exit.isFailure(yield* service.listPage({ path: RelativePath.make("../outside") }).pipe(Effect.exit)),
).toBe(true)
expect((yield* service.listPage()).entries).toEqual([])
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
const outside = `${directory}-outside.txt`
yield* Effect.promise(() => fs.writeFile(outside, "outside"))
yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "link.txt")))
expect(Exit.isFailure(yield* service.read({ path: RelativePath.make("link.txt") }).pipe(Effect.exit))).toBe(true)
yield* Effect.promise(() => fs.rm(outside, { force: true }))
}).pipe(provide(directory)),
),
)
it.live("paginates visible entries after omitting escaping symlink children", () =>
it.live("finds and greps files", () =>
withTmp((directory) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "a-escape"))
await fs.writeFile(path.join(directory, "b-visible.txt"), "visible")
})
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "src", "index.ts"), "const needle = true\n"))
const service = yield* FileSystem.Service
expect(yield* service.listPage({ limit: 1 })).toMatchObject({
entries: [{ path: "b-visible.txt", type: "file" }],
truncated: false,
})
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)),
expect((yield* service.find({ query: "index", type: "file" })).map((item) => item.path)).toEqual([
RelativePath.make(path.join("src", "index.ts")),
])
expect(yield* service.grep({ pattern: "needle" })).toMatchObject([
{ path: RelativePath.make(path.join("src", "index.ts")), line: 1, offset: 0 },
])
}).pipe(
provide(
directory,
Layer.effect(
Search.Service,
Effect.gen(function* () {
const search = yield* Search.Service
return Search.Service.of({
...search,
file: () => Effect.succeed([{ path: path.join("src", "index.ts"), type: "file" }]),
})
}),
).pipe(Layer.provide(Search.defaultLayer)),
),
),
),
)
it.live("rejects paths outside the location", () =>
it.live("uses the type supplied by Search file results", () =>
withTmp((directory) =>
Effect.gen(function* () {
const service = yield* FileSystem.Service
expect(
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
).toBe(true)
}).pipe(provide(directory)),
yield* Effect.promise(() => fs.writeFile(path.join(directory, "selected.ts"), "export {}\n"))
expect((yield* (yield* FileSystem.Service).find({ query: "ignored", limit: 1 }))[0]).toMatchObject({
path: RelativePath.make("selected.ts"),
type: "directory",
mime: "application/x-directory",
})
}).pipe(
provide(
directory,
Layer.effect(
Search.Service,
Effect.gen(function* () {
const search = yield* Search.Service
return Search.Service.of({
...search,
file: () => Effect.succeed([{ path: "selected.ts", type: "directory" }]),
})
}),
).pipe(Layer.provide(Search.defaultLayer)),
),
),
),
)
})

View file

@ -5,6 +5,7 @@ import { Cause, Effect, Exit, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Search } from "@opencode-ai/core/filesystem/search"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { AppProcess } from "@opencode-ai/core/process"
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
@ -19,6 +20,7 @@ function provide(directory: string, data = Global.Path.data) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
Search.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Global.layerWith({ data }),

View file

@ -7,7 +7,7 @@ test("compaction describes tool media without embedding base64", () => {
{ type: "text", text: "Image read successfully" },
{
type: "file",
source: { type: "data", data: base64 },
uri: `data:image/png;base64,${base64}`,
mime: "image/png",
name: "pixel.png",
},

View file

@ -7,7 +7,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
@ -150,13 +149,13 @@ Recent work
status: "completed",
input: { path: "README.md" },
content: [
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
new ToolOutput.FileContent({
{ type: "text", text: "Hello" },
{
type: "file",
source: { type: "data", data: "aGVsbG8=" },
uri: "data:image/png;base64,aGVsbG8=",
mime: "image/png",
name: "hello.png",
}),
},
],
structured: {},
}),
@ -174,7 +173,7 @@ Recent work
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
content: [{ type: "text", text: "Found it" }],
structured: {},
}),
time: { created, completed: created },
@ -257,7 +256,7 @@ Recent work
type: "content",
value: [
{ type: "text", text: "Hello" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
],
},
},

View file

@ -57,14 +57,14 @@ const result = LLMEvent.toolResult({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: base64, filename: "pixel.png" },
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
],
},
output: {
structured: { type: "media", mime: "image/png" },
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", source: { type: "data", data: base64 }, mime: "image/png", name: "pixel.png" },
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
],
},
})
@ -83,7 +83,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
expect(success?.data).toMatchObject({
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", source: { type: "data", data: base64 }, mime: "image/png" },
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" },
],
})
})
@ -119,8 +119,8 @@ test("old success event data containing result still decodes", () => {
assistantMessageID: SessionMessage.ID.create(),
callID: "call-old",
structured: { type: "media", mime: "image/png" },
content: [{ type: "file", source: { type: "data", data: base64 }, mime: "image/png" }],
result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: base64 }] },
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
provider: { executed: false },
})
expect(decoded.result).toMatchObject({ type: "content" })

View file

@ -1687,7 +1687,7 @@ describe("SessionRunnerLLM", () => {
type: "content",
value: [
{ type: "text", text: "Hello" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
],
},
providerExecuted: true,
@ -1740,7 +1740,7 @@ describe("SessionRunnerLLM", () => {
structured: {},
content: [
{ type: "text", text: "Hello" },
{ type: "file", mime: "image/png", source: { type: "data", data: "aGVsbG8=" }, name: "hello.png" },
{ type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" },
],
},
},

View file

@ -14,7 +14,6 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
@ -24,7 +23,7 @@ const it = testEffect(Layer.mergeAll(database, events, projector))
const timestamp = DateTime.makeUnsafe(1)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const content = (text: string) => [ToolOutput.text({ type: "text", text })]
const content = (text: string) => [{ type: "text" as const, text }]
describe("Tool.Progress", () => {
it.effect("projects durable progress and keeps final settlements durable", () =>

View file

@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { RelativePath } from "@opencode-ai/core/schema"
@ -12,7 +11,6 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to
const sessionID = SessionV2.ID.make("ses_glob_tool_test")
const assertions: PermissionV2.AssertInput[] = []
const resolutions: FileSystem.ListInput[] = []
const searches: LocationSearch.FilesInput[] = []
let allow = true
let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
@ -32,34 +30,6 @@ const permission = Layer.succeed(
}),
)
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => Effect.die("unused"),
resolveReadPath: () => Effect.die("unused"),
readTool: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.sync(() => {
resolutions.push(input)
const relative = input.path ?? RelativePath.make(".")
return new FileSystem.RootTarget({
real: `/project/${relative}`,
root: "/project",
resource: relative,
type: "directory",
})
}),
resolveList: () => Effect.die("unused"),
listResolved: () => Effect.die("unused"),
listPage: () => Effect.die("unused"),
listPageResolved: () => Effect.die("unused"),
find: () => Effect.die("unused"),
grep: () => Effect.die("unused"),
isIgnored: () => false,
}),
)
const search = Layer.succeed(
LocationSearch.Service,
LocationSearch.Service.of({
@ -76,14 +46,12 @@ const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const glob = GlobTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(filesystem),
Layer.provide(search),
)
const it = testEffect(Layer.mergeAll(registry, permission, filesystem, search, glob))
const it = testEffect(Layer.mergeAll(registry, permission, search, glob))
const reset = () => {
assertions.length = 0
resolutions.length = 0
searches.length = 0
allow = true
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
@ -123,7 +91,6 @@ describe("GlobTool", () => {
metadata: { root: "src", path: "src", limit: 12 },
},
])
expect(resolutions).toEqual([{ path: RelativePath.make("src") }])
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
}),
)

View file

@ -5,6 +5,7 @@ import { Effect, Exit, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Search } from "@opencode-ai/core/filesystem/search"
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { PermissionV2 } from "@opencode-ai/core/permission"
@ -26,31 +27,6 @@ let allow = true
let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
let searchFailure: Ripgrep.InvalidPatternError | undefined
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => Effect.die("unused"),
resolveReadPath: () => Effect.die("unused"),
readTool: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.succeed(
new FileSystem.RootTarget({
real: `/project/${input.path ?? "."}`,
root: "/project",
resource: input.path ?? ".",
type: "directory",
}),
),
resolveList: () => Effect.die("unused"),
listResolved: () => Effect.die("unused"),
listPage: () => Effect.die("unused"),
listPageResolved: () => Effect.die("unused"),
find: () => Effect.die("unused"),
grep: () => Effect.die("unused"),
isIgnored: () => false,
}),
)
const search = Layer.succeed(
LocationSearch.Service,
LocationSearch.Service.of({
@ -80,11 +56,10 @@ const permission = Layer.succeed(
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const grep = GrepTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(search),
Layer.provide(permission),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, search, permission, grep))
const it = testEffect(Layer.mergeAll(registry, search, permission, grep))
const sessionID = SessionV2.ID.make("ses_grep_tool_test")
const execute = (input: Record<string, unknown>) =>
@ -117,6 +92,7 @@ function provideLive(directory: string) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
Search.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
)

View file

@ -90,7 +90,7 @@ describe("ToolOutputStore", () => {
toolCallID: "call-file",
output: {
structured: { caption: "pixel" },
content: [{ type: "file", source: { type: "data", data }, mime: "image/png", name: "pixel.png" }],
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
},
})
expect(result.outputPaths).toEqual([])
@ -98,7 +98,7 @@ describe("ToolOutputStore", () => {
expect(result.output.content).toHaveLength(1)
expect(result.output.content[0]).toEqual({
type: "file",
source: { type: "data", data },
uri: `data:image/png;base64,${data}`,
mime: "image/png",
name: "pixel.png",
})
@ -112,7 +112,7 @@ describe("ToolOutputStore", () => {
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
const media = {
type: "file" as const,
source: { type: "data" as const, data: "aGVsbG8=" },
uri: "data:image/png;base64,aGVsbG8=",
mime: "image/png",
name: "pixel.png",
}

View file

@ -3,60 +3,54 @@ import { Effect, Exit, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { Image } from "@opencode-ai/core/image"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { location } from "./fixture/location"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const assertions: PermissionV2.AssertInput[] = []
const readCalls: {
input: FileSystem.ReadInput & FileSystem.TextPageInput
page: FileSystem.TextPageInput
input: AbsolutePath
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: FileSystem.ListPageInput[] = []
const listCalls: ReadToolFileSystem.PageInput[] = []
let resolvedType: "file" | "directory" = "file"
let resolveFailure: unknown
let readResult: FileSystem.Content | FileSystem.TextPage = new FileSystem.TextContent({
type: "text",
let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = {
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
})
}
let readFailure: unknown
let configEntries: Config.Entry[] = []
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => Effect.die("unused"),
resolveReadPath: (input) =>
const reader = Layer.succeed(
ReadToolFileSystem.Service,
ReadToolFileSystem.Service.of({
inspect: () =>
resolveFailure === undefined
? Effect.succeed(
new FileSystem.ReadPath({
type: resolvedType,
resource: input.path,
}),
)
? Effect.succeed(resolvedType)
: Effect.die(resolveFailure),
readTool: (input, page = {}) => {
read: (input, _resource, page = {}) => {
readCalls.push({ input, page })
if (readFailure !== undefined) return Effect.die(readFailure)
return Effect.succeed(readResult)
},
resolveRoot: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveList: () => Effect.die("unused"),
listResolved: () => Effect.die("unused"),
listPage: (input = {}) =>
list: (_path, input = {}) =>
Effect.sync(() => {
listCalls.push(input)
return new FileSystem.ListPage({ entries: [], truncated: false })
return new ReadToolFileSystem.ListPage({ entries: [], truncated: false })
}),
listPageResolved: () => Effect.die("unused"),
find: () => Effect.die("unused"),
grep: () => Effect.die("unused"),
isIgnored: () => false,
}),
)
let allow = true
@ -77,27 +71,40 @@ const permission = Layer.succeed(
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
const image = Image.layer.pipe(Layer.provide(config))
const testFileSystem = Layer.effect(
FSUtil.Service,
FSUtil.Service.use((fs) =>
Effect.succeed(FSUtil.Service.of({ ...fs, realPath: (path) => Effect.succeed(path) })),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
const infrastructure = Layer.mergeAll(
testFileSystem,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
Global.layerWith({ data: Global.Path.data }),
)
const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const read = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(image),
Layer.provide(infrastructure),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, image, read))
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
Layer.provide(infrastructure),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, filesystem, permission, config, unavailableImage, unavailableRead),
Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead),
)
const sessionID = SessionV2.ID.make("ses_read_tool_test")
@ -109,7 +116,13 @@ describe("ReadTool", () => {
allow = true
resolvedType = "file"
resolveFailure = undefined
readResult = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
readResult = {
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
}
readFailure = undefined
configEntries = []
})
@ -126,21 +139,31 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
).toEqual({
type: "json",
value: {
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
},
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(readCalls).toEqual([{ input: { path: "README.md" }, page: {} }])
expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/README.md`), page: {} }])
}),
)
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///pixel.png",
name: "pixel.png",
content: png,
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
expect(
@ -153,20 +176,25 @@ describe("ReadTool", () => {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "pixel.png" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
],
})
expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }])
expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/pixel.png`), page: {} }])
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(settled.output?.structured).toMatchObject({
uri: "file:///pixel.png",
name: "pixel.png",
mime: "image/png",
encoding: "base64",
})
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", source: { type: "data", data: png } },
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
])
}),
)
@ -179,12 +207,13 @@ describe("ReadTool", () => {
const png = Buffer.from(source.get_bytes()).toString("base64")
source.free()
expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024)
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///large.png",
name: "large.png",
content: png,
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
@ -194,12 +223,17 @@ describe("ReadTool", () => {
})
expect(settled.outputPaths).toBeUndefined()
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(settled.output?.structured).toMatchObject({
uri: "file:///large.png",
name: "large.png",
mime: "image/png",
encoding: "base64",
})
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "large.png" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
],
})
}),
@ -208,12 +242,13 @@ describe("ReadTool", () => {
itWithoutResizer.effect("returns the original image when the resizer is unavailable", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///pixel.png",
name: "pixel.png",
content: png,
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
expect(
@ -224,19 +259,20 @@ describe("ReadTool", () => {
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "media", mediaType: "image/png", data: png }],
value: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }],
})
}),
)
it.effect("rejects invalid image data returned by the filesystem", () =>
Effect.gen(function* () {
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///truncated.png",
name: "truncated.png",
content: "iVBORw0KGgo=",
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
expect(
@ -255,12 +291,13 @@ describe("ReadTool", () => {
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
const base64 = Buffer.from(source.get_bytes()).toString("base64")
source.free()
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///wide.png",
name: "wide.png",
content: base64,
encoding: "base64",
mime: "image/png",
})
}
configEntries = [
new Config.Document({
type: "document",
@ -289,12 +326,13 @@ describe("ReadTool", () => {
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
const base64 = Buffer.from(source.get_bytes()).toString("base64")
source.free()
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///wide.png",
name: "wide.png",
content: base64,
encoding: "base64",
mime: "image/png",
})
}
configEntries = [
new Config.Document({
type: "document",
@ -313,9 +351,9 @@ describe("ReadTool", () => {
expect(result.type).toBe("content")
if (result.type !== "content") return
const media = result.value[1]
expect(media?.type).toBe("media")
if (media?.type !== "media") return
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.data, "base64"))
expect(media?.type).toBe("file")
if (media?.type !== "file") return
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.uri.split(",")[1] ?? "", "base64"))
expect(resized.get_width()).toBeLessThanOrEqual(4)
expect(resized.get_height()).toBeLessThanOrEqual(2_000)
resized.free()
@ -325,12 +363,13 @@ describe("ReadTool", () => {
it.effect("enforces max base64 bytes after resize attempts", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///pixel.png",
name: "pixel.png",
content: png,
encoding: "base64",
mime: "image/png",
})
}
configEntries = [
new Config.Document({
type: "document",
@ -356,12 +395,13 @@ describe("ReadTool", () => {
it.effect("returns supported image contents despite a misleading binary extension", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///pixel.bin",
name: "pixel.bin",
content: png,
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
expect(
@ -372,14 +412,14 @@ describe("ReadTool", () => {
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "media", mediaType: "image/png", filename: "pixel.bin" }],
value: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }],
})
}),
)
it.effect("preserves unexpected filesystem defects", () =>
Effect.gen(function* () {
readFailure = new FileSystem.BinaryFileError("archive.dat")
readFailure = new ReadToolFileSystem.BinaryFileError("archive.dat")
const registry = yield* ToolRegistry.Service
expect(
@ -397,7 +437,7 @@ describe("ReadTool", () => {
),
).toBe(true)
expect(readCalls).toEqual([
{ input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } },
{ input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } },
])
}),
)
@ -436,7 +476,7 @@ describe("ReadTool", () => {
}),
).toEqual({ type: "json", value: { entries: [], truncated: false } })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(listCalls).toEqual([{ path: "src", offset: 2, limit: 10 }])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}),
)
@ -478,7 +518,7 @@ describe("ReadTool", () => {
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
Effect.gen(function* () {
readResult = new FileSystem.TextPage({
readResult = new ReadToolFileSystem.TextPage({
type: "text-page",
content: "hello",
mime: "text/plain",
@ -503,18 +543,21 @@ describe("ReadTool", () => {
type: "json",
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
})
expect(readCalls).toEqual([{ input: { path: "large.txt", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }])
expect(readCalls).toEqual([
{ input: AbsolutePath.make(`${process.cwd()}/large.txt`), page: { offset: 2, limit: 1 } },
])
}),
)
it.effect("rejects unsupported binary discovered by a direct read", () =>
Effect.gen(function* () {
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///late-binary",
name: "late-binary",
content: "AAECAw==",
encoding: "base64",
mime: "application/octet-stream",
})
}
const registry = yield* ToolRegistry.Service
expect(

View file

@ -14,7 +14,7 @@ import {
type ProviderMetadata,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolContent,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@ -321,10 +321,10 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
// Tool results may carry structured text/images. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: ToolResultContentPart,
item: ToolContent,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
const media = yield* ProviderShared.validateMedia(
const media = yield* ProviderShared.validateToolFile(
"Anthropic Messages",
item,
new Set<string>(ProviderShared.IMAGE_MIMES),
@ -344,7 +344,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
// with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
})

View file

@ -269,7 +269,7 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
content.push({ text: item.text })
continue
}
const media = yield* BedrockMedia.lower(item)
const media = yield* BedrockMedia.lower({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
if (!("image" in media))
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
content.push(media)

View file

@ -14,7 +14,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolContent,
} from "../schema"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
@ -262,7 +262,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
continue
}
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
parts.push({
functionResponse: {
@ -275,7 +275,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
for (const item of content) {
if (item.type === "text") continue
const media = yield* ProviderShared.validateMedia("Gemini", item, IMAGE_MIMES)
const media = yield* ProviderShared.validateToolFile("Gemini", item, IMAGE_MIMES)
parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
}
}

View file

@ -14,7 +14,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolContent,
} from "../schema"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
@ -201,7 +201,7 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
})
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (
part: Extract<MediaPart | ToolResultContentPart, { type: "media" }>,
part: MediaPart,
) {
const media = yield* ProviderShared.validateMedia("OpenAI Chat", part, IMAGE_MIMES)
return { type: "image_url" as const, image_url: { url: media.dataUrl } }
@ -271,13 +271,15 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
continue
}
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
const media = content.filter(
(item): item is Extract<ToolResultContentPart, { type: "media" }> => item.type === "media",
const files = content.filter((item) => item.type === "file")
images.push(
...(yield* Effect.forEach(files, (item) =>
lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }),
)),
)
images.push(...(yield* Effect.forEach(media, lowerMedia)))
}
return { messages, images }
})

View file

@ -14,7 +14,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultContentPart,
type ToolContent,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@ -318,10 +318,10 @@ const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function*
// Tool results may carry structured text/images. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
item: ToolResultContentPart,
item: ToolContent,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
const media = yield* ProviderShared.validateMedia(
const media = yield* ProviderShared.validateToolFile(
"OpenAI Responses",
item,
new Set<string>(ProviderShared.IMAGE_MIMES),
@ -334,7 +334,7 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
})

View file

@ -9,6 +9,7 @@ import {
type ContentPart,
type LLMRequest,
type MediaPart,
type ToolFileContent,
type TextPart,
type ToolResultPart,
} from "../schema"
@ -233,6 +234,9 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
})
export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
export const toolResultText = (part: ToolResultPart) => {

View file

@ -39,68 +39,24 @@ export const MediaPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
export const ToolResultMediaPart = Schema.Struct({
type: Schema.Literal("media"),
mediaType: Schema.String,
data: Schema.String,
filename: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).annotate({ identifier: "LLM.ToolResult.Media" })
export type ToolResultMediaPart = Schema.Schema.Type<typeof ToolResultMediaPart>
export const ToolResultContentPart = Schema.Union([TextPart, ToolResultMediaPart])
export type ToolResultContentPart = Schema.Schema.Type<typeof ToolResultContentPart>
export class ToolTextContent extends Schema.Class<ToolTextContent>("Tool.TextContent")({
export const ToolTextContent = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
}) {}
}).annotate({ identifier: "Tool.TextContent" })
export type ToolTextContent = typeof ToolTextContent.Type
export const ToolFileSource = Schema.Union([
Schema.Struct({ type: Schema.Literal("data"), data: Schema.String }),
Schema.Struct({ type: Schema.Literal("url"), url: Schema.String }),
Schema.Struct({ type: Schema.Literal("file"), uri: Schema.String }),
]).pipe(Schema.toTaggedUnion("type"))
export type ToolFileSource = Schema.Schema.Type<typeof ToolFileSource>
export class ToolFileContent extends Schema.Class<ToolFileContent>("Tool.FileContent")({
export const ToolFileContent = Schema.Struct({
type: Schema.Literal("file"),
source: ToolFileSource,
uri: Schema.String,
mime: Schema.String,
name: Schema.optional(Schema.String),
}) {}
}).annotate({ identifier: "Tool.FileContent" })
export type ToolFileContent = typeof ToolFileContent.Type
/** Ordered, provider-independent content shown to models and UIs after a tool succeeds. */
export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]).pipe(Schema.toTaggedUnion("type"))
export type ToolContent = Schema.Schema.Type<typeof ToolContent>
export const toolText = (value: ConstructorParameters<typeof ToolTextContent>[0]) => new ToolTextContent(value)
export const toolFile = (value: ConstructorParameters<typeof ToolFileContent>[0]) => new ToolFileContent(value)
const inlineData = (uri: string) => {
if (!uri.startsWith("data:")) return undefined
const match = /^data:[^;,]+;base64,(.*)$/s.exec(uri)
if (!match) throw new Error("Tool file data URI must contain raw base64 bytes")
return match[1]!
}
const legacyInlineData = (value: string) => {
const data = inlineData(value)
if (data !== undefined) return data
if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return value
throw new Error("Legacy tool-result media must contain raw base64 bytes or a base64 data URI")
}
/** Convert a legacy attachment URI without guessing unknown string semantics. */
export const toolFileSourceFromUri = (uri: string): ToolFileSource => {
const data = inlineData(uri)
if (data !== undefined) return { type: "data", data }
const url = URL.parse(uri)
if (url?.protocol === "file:") return { type: "file", uri }
if (url?.protocol === "http:" || url?.protocol === "https:") return { type: "url", url: uri }
throw new Error(`Unsupported tool file URI: ${uri}`)
}
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
@ -122,7 +78,7 @@ export const ToolResultValue = Object.assign(
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(ToolResultContentPart),
value: Schema.Array(ToolContent),
}),
]).annotate({ identifier: "LLM.ToolResult" }),
{
@ -147,34 +103,15 @@ export const ToolOutput = Object.assign(
content: Schema.Array(ToolContent),
}).annotate({ identifier: "LLM.ToolOutput" }),
{
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({
structured,
content: content.map((item) =>
item.type === "text"
? toolText({ type: "text", text: item.text })
: toolFile({ type: "file", source: item.source, mime: item.mime, name: item.name }),
),
}),
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
switch (result.type) {
case "json":
return { structured: result.value, content: [] }
case "text":
return { structured: {}, content: [toolText({ type: "text", text: toolResultText(result.value) })] }
return { structured: {}, content: [{ type: "text", text: toolResultText(result.value) }] }
case "content":
return {
structured: {},
content: result.value.map((item) =>
item.type === "text"
? toolText({ type: "text", text: item.text })
: toolFile({
type: "file",
source: { type: "data", data: legacyInlineData(item.data) },
mime: item.mediaType,
name: item.filename,
}),
),
}
return { structured: {}, content: result.value }
case "error":
return undefined
}
@ -183,21 +120,7 @@ export const ToolOutput = Object.assign(
if (output.content.length === 0) return { type: "json", value: output.structured }
if (output.content.length === 1 && output.content[0]?.type === "text")
return { type: "text", value: output.content[0].text }
const unsupported = output.content.find((item) => item.type === "file" && item.source.type !== "data")
if (unsupported?.type === "file")
return {
type: "error",
value: `Tool file source "${unsupported.source.type}" must be materialized to inline data before provider conversion`,
}
return {
type: "content",
value: output.content.map((item) => {
if (item.type === "text") return { type: "text", text: item.text }
if (item.source.type !== "data")
throw new Error("Unmaterialized tool file source reached provider conversion")
return { type: "media", mediaType: item.mime, data: item.source.data, filename: item.name }
}),
}
return { type: "content", value: output.content }
},
},
)

View file

@ -5,7 +5,7 @@ import type {
ToolDefinition as ToolDefinitionClass,
ToolOutput as ToolOutputType,
} from "./schema"
import { ToolDefinition, ToolFailure, ToolOutput, toolText } from "./schema"
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
/**
* Schema constraint for tool parameters / success values: no decoding or
@ -245,7 +245,7 @@ const project = (
ToolOutput.make(
toStructuredOutput?.(output) ?? output,
toModelOutput?.({ callID, parameters, output }) ??
(typeof output === "string" ? [toolText({ type: "text", text: output })] : []),
(typeof output === "string" ? [{ type: "text", text: output }] : []),
)
export { ToolFailure }

View file

@ -235,7 +235,7 @@ describe("Anthropic Messages route", () => {
resultType: "content",
result: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
],
}),
],
@ -262,7 +262,7 @@ describe("Anthropic Messages route", () => {
id: "call_1",
name: "screenshot",
resultType: "content",
result: [{ type: "media", mediaType: "image/jpeg", data: "/9j/AA==" }],
result: [{ type: "file", uri: "data:image/jpeg;base64,/9j/AA==", mime: "image/jpeg" }],
}),
],
cache: "none",
@ -287,7 +287,7 @@ describe("Anthropic Messages route", () => {
id: "call_1",
name: "fetch",
resultType: "content",
result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }],
result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }],
}),
],
cache: "none",

View file

@ -189,7 +189,7 @@ describe("Bedrock Converse route", () => {
type: "content",
value: [
{ type: "text", text: "Screenshot captured." },
{ type: "media", mediaType: "image/png", data: "AAAA" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
],
},
}),

View file

@ -124,7 +124,7 @@ describe("Gemini route", () => {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: "AAECAw==", filename: "pixel.png" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
],
},
}),
@ -163,7 +163,7 @@ describe("Gemini route", () => {
name: "read",
result: {
type: "content",
value: [{ type: "media", mediaType: "image/jpeg", data: "data:image/jpeg;base64,/9j/" }],
value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }],
},
}),
],

View file

@ -238,7 +238,7 @@ describe("OpenAI Chat route", () => {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: "AAECAw==", filename: "pixel.png" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
],
},
}),
@ -285,13 +285,19 @@ describe("OpenAI Chat route", () => {
type: "tool-result",
id: "call_1",
name: "read",
result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAEC" }] },
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
},
{
type: "tool-result",
id: "call_2",
name: "read",
result: { type: "content", value: [{ type: "media", mediaType: "image/jpeg", data: "/9j/" }] },
result: {
type: "content",
value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }],
},
},
],
}),
@ -321,12 +327,12 @@ describe("OpenAI Chat route", () => {
Message.tool({
id: "call_1",
name: "read",
result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAEC" }] },
result: { type: "content", value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }] },
}),
Message.tool({
id: "call_2",
name: "read",
result: { type: "content", value: [{ type: "media", mediaType: "image/webp", data: "UklG" }] },
result: { type: "content", value: [{ type: "file", uri: "data:image/webp;base64,UklG", mime: "image/webp" }] },
}),
Message.system("Inspect both images."),
],

View file

@ -377,7 +377,7 @@ describe("OpenAI Responses route", () => {
resultType: "content",
result: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
],
}),
],
@ -403,7 +403,7 @@ describe("OpenAI Responses route", () => {
id: "call_1",
name: "screenshot",
resultType: "content",
result: [{ type: "media", mediaType: "image/png", data: "AAECAw==" }],
result: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }],
}),
],
}),
@ -427,7 +427,7 @@ describe("OpenAI Responses route", () => {
id: "call_1",
name: "fetch",
resultType: "content",
result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }],
result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }],
}),
],
}),

View file

@ -388,7 +388,7 @@ const runImageToolResultScenario = (context: GoldenScenarioContext) =>
resultType: "content",
result: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: image },
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png" },
],
}),
],

View file

@ -9,7 +9,6 @@ import {
ToolChoice,
ToolContent,
ToolOutput,
toolFileSourceFromUri,
toDefinitions,
} from "../src"
import { Auth, LLMClient } from "../src/route"
@ -217,7 +216,7 @@ describe("LLMClient tools", () => {
execute: () => Effect.succeed({ mime: "image/png", data: "AAECAw==" }),
toStructuredOutput: (output) => ({ mime: output.mime }),
toModelOutput: ({ output }) => [
{ type: "file", source: { type: "data", data: output.data }, mime: output.mime },
{ type: "file", uri: `data:${output.mime};base64,${output.data}`, mime: output.mime },
],
})
@ -228,81 +227,80 @@ describe("LLMClient tools", () => {
expect(dispatched.output).toEqual({
structured: { mime: "image/png" },
content: [{ type: "file", source: { type: "data", data: "AAECAw==" }, mime: "image/png" }],
content: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }],
})
}),
)
it.effect("models canonical tool files with explicit data, url, and file sources", () =>
it.effect("models canonical tool files with URIs", () =>
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(ToolContent)
expect(decode({ type: "file", source: { type: "data", data: "AAAA" }, mime: "image/png" })).toEqual({
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
source: { type: "data", data: "AAAA" },
uri: "data:image/png;base64,AAAA",
mime: "image/png",
})
expect(
decode({ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" }),
decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" }),
).toEqual({
type: "file",
source: { type: "url", url: "https://example.test/image.png" },
uri: "https://example.test/image.png",
mime: "image/png",
})
expect(
decode({ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" }),
decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }),
).toEqual({
type: "file",
source: { type: "file", uri: "file:///tmp/image.png" },
uri: "file:///tmp/image.png",
mime: "image/png",
})
}),
)
it.effect("converts canonical data files deliberately and rejects unmaterialized sources", () =>
it.effect("preserves canonical tool file URIs", () =>
Effect.sync(() => {
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [{ type: "file", source: { type: "data", data: "AAAA" }, mime: "image/png" }]),
),
).toEqual({ type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAAA" }] })
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [
{ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" },
]),
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
),
).toEqual({
type: "error",
value: 'Tool file source "url" must be materialized to inline data before provider conversion',
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
})
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [
{ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" },
{ type: "file", uri: "https://example.test/image.png", mime: "image/png" },
]),
),
).toEqual({
type: "error",
value: 'Tool file source "file" must be materialized to inline data before provider conversion',
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(toolFileSourceFromUri("data:image/png;base64,AAAA")).toEqual({ type: "data", data: "AAAA" })
expect(toolFileSourceFromUri("https://example.test/image.png")).toEqual({
type: "url",
url: "https://example.test/image.png",
expect(
ToolOutput.toResultValue(
ToolOutput.make({}, [
{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" },
]),
),
).toEqual({
type: "content",
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
})
expect(toolFileSourceFromUri("file:///tmp/image.png")).toEqual({ type: "file", uri: "file:///tmp/image.png" })
expect(() => toolFileSourceFromUri("opaque-value")).toThrow("Unsupported tool file URI")
expect(() =>
expect(
ToolOutput.fromResultValue({
type: "content",
value: [{ type: "media", mediaType: "image/png", data: "https://example.test/image.png" }],
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
}),
).toThrow("Legacy tool-result media must contain raw base64 bytes or a base64 data URI")
).toEqual({
structured: {},
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
}),
)
it.effect("settles projected url files as materialization errors", () =>
it.effect("settles projected URL files as canonical tool results", () =>
Effect.gen(function* () {
const remote = Tool.make({
description: "Return a remote file.",
@ -310,7 +308,7 @@ describe("LLMClient tools", () => {
success: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
toModelOutput: () => [
{ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" },
{ type: "file", uri: "https://example.test/image.png", mime: "image/png" },
],
})
@ -319,12 +317,15 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_remote", name: "remote", input: {} }),
)
expect(dispatched.output).toBeUndefined()
expect(dispatched.result).toEqual({
type: "error",
value: 'Tool file source "url" must be materialized to inline data before provider conversion',
expect(dispatched.output).toEqual({
structured: { ok: true },
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(dispatched.events.map((event) => event.type)).toEqual(["tool-error", "tool-result"])
expect(dispatched.result).toEqual({
type: "content",
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
})
expect(dispatched.events.map((event) => event.type)).toEqual(["tool-result"])
}),
)
@ -357,7 +358,7 @@ describe("LLMClient tools", () => {
type: "content" as const,
value: [
{ type: "text" as const, text: "Screenshot captured." },
{ type: "media" as const, mediaType: "image/png", data: "AAAA" },
{ type: "file" as const, uri: "data:image/png;base64,AAAA", mime: "image/png" },
],
}),
})
@ -379,7 +380,7 @@ describe("LLMClient tools", () => {
type: "content",
value: [
{ type: "text", text: "Screenshot captured." },
{ type: "media", mediaType: "image/png", data: "AAAA" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
],
},
})

View file

@ -121,6 +121,7 @@
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",

View file

@ -92,7 +92,7 @@ for (const q of FILE_QUERIES) {
const t = performance.now()
const r = await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: q, limit: FILE_LIMIT })))
console.log(
`[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r?.length ?? "undefined (cache fallback)"} results)`,
`[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} results)`,
)
}

View file

@ -38,7 +38,9 @@ const FileReadCommand = effectCmd({
description: "File path to read",
}),
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
const content = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
const content = yield* filesystem(
FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })),
)
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
}),
})
@ -53,7 +55,9 @@ const FileListCommand = effectCmd({
description: "File path to list",
}),
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
const files = yield* filesystem(FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })))
const files = yield* filesystem(
FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })),
)
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
}),
})

View file

@ -4,8 +4,10 @@ import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Search } from "@opencode-ai/core/filesystem/search"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Effect, Layer } from "effect"
import ignore from "ignore"
import path from "path"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
@ -35,40 +37,17 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
const limit = ctx.query.limit ?? 10
const kind = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : "all")
const started = performance.now()
// Prefer fff (frecency + fuzzy ranking) and trust its ordering. Fall back
// to the ripgrep-backed FileSystem.find when fff is unavailable.
const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie)
if (fff !== undefined) {
yield* Effect.logInfo("find file", {
engine: "fff",
query: ctx.query.query,
kind,
directory,
limit,
results: fff.length,
duration: Math.round(performance.now() - started),
})
return fff
}
const fallback = (yield* filesystem(
FileSystem.Service.use((fs) =>
fs.find({
query: ctx.query.query,
limit,
type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined),
}),
),
)).map((item) => item.path)
yield* Effect.logInfo("find file", {
engine: "ripgrep",
engine: "fff",
query: ctx.query.query,
kind,
directory,
limit,
results: fallback.length,
results: fff.length,
duration: Math.round(performance.now() - started),
})
return fallback
return fff.map((item) => item.path)
})
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
@ -78,19 +57,30 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
const directory = (yield* InstanceState.context).directory
return yield* filesystem(
FileSystem.Service.use((fs) =>
fs.list({ path: RelativePath.make(ctx.query.path) }).pipe(
Effect.map((items) =>
items.map((item) => ({
name: path.basename(item.path),
path: item.path,
absolute: path.join(directory, item.path),
type: item.type,
ignored: fs.isIgnored(item.path, item.type),
})),
Effect.gen(function* () {
const fs = yield* FileSystem.Service
const raw = yield* FSUtil.Service
const location = yield* Location.Service
const ignored = ignore()
const gitignore = yield* raw
.readFileString(path.join(location.project.directory, ".gitignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (gitignore) ignored.add(gitignore)
const ignorefile = yield* raw
.readFileString(path.join(location.project.directory, ".ignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (ignorefile) ignored.add(ignorefile)
return (yield* fs.list({ path: RelativePath.make(ctx.query.path) })).map((item) => ({
name: path.basename(item.path),
path: item.path,
absolute: path.join(directory, item.path),
type: item.type,
ignored: ignored.ignores(
path.relative(location.project.directory, path.join(location.directory, item.path)) +
(item.type === "directory" ? "/" : ""),
),
),
),
}))
}),
)
})
@ -103,9 +93,9 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
).pipe(
Effect.map((item) => ({
type: item.type,
content: item.type === "text" ? item.content.trim() : item.content,
...(item.type === "binary" ? { encoding: item.encoding, mimeType: item.mime } : {}),
type: item.encoding === "utf8" ? ("text" as const) : ("binary" as const),
content: item.encoding === "utf8" ? item.content.trim() : item.content,
...(item.encoding === "base64" ? { encoding: item.encoding, mimeType: item.mime } : {}),
})),
)
})

View file

@ -29,8 +29,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { ToolOutput, Usage, type LLMEvent } from "@opencode-ai/llm"
const DOOM_LOOP_THRESHOLD = 3
export type Result = "compact" | "stop" | "continue"
@ -595,20 +594,20 @@ export const layer = Layer.effect(
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
const content = [
ToolOutput.text({ type: "text", text: output.output }),
{ type: "text" as const, text: output.output },
...(output.attachments?.map((item: SessionV1.FilePart) =>
ToolOutput.file({
({
type: "file",
source: toolFileSourceFromUri(item.url),
uri: item.url,
mime: item.mime,
name: item.filename,
}),
}) as const,
) ?? []),
]
const unsupported = content.find((item) => item.type === "file" && item.source.type !== "data")
const unsupported = content.find((item) => item.type === "file" && !item.uri.startsWith("data:"))
if (unsupported?.type === "file") {
const error = new Error(
`Tool attachment source "${unsupported.source.type}" must be materialized before durable V2 settlement`,
`Tool attachment URI "${unsupported.uri}" must be materialized before durable V2 settlement`,
)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,

View file

@ -670,6 +670,11 @@ const scenarios: Scenario[] = [
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
.json(200, locationData(object)),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
http.protected
.get("/api/fs/find", "v2.fs.find")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: "/api/fs/find?query=hello&type=file", headers: ctx.headers() }))
.json(200, locationData(array)),
http.protected.get("/api/reference", "v2.reference.list").json(200, object),
http.protected
.get("/api/provider/{providerID}", "v2.provider.get")

View file

@ -390,10 +390,13 @@ describe("HttpApi SDK", () => {
onRequest: (value) => (request = value),
})
const file = yield* call(() => sdk.v2.fs.read({ path: "hello.txt" }))
const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" }))
const url = new URL(request!.url)
expect(file.response.status).toBe(200)
expect(file.data).toMatchObject({ data: { content: "hello" } })
expect(found.response.status).toBe(200)
expect(found.data).toMatchObject({ data: [{ path: "hello.txt", type: "file" }] })
expect(url.searchParams.get("directory")).toBe(directory)
expect(url.searchParams.get("workspace")).toBe(workspaceID)
expect(url.searchParams.get("location[directory]")).toBe(directory)

View file

@ -8,7 +8,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutput } from "@opencode-ai/core/tool-output"
test.skip("step snapshots carry over to assistant messages", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
@ -182,7 +181,7 @@ test.skip("tool completion stores completed timestamp", () => {
timestamp: DateTime.makeUnsafe(4),
callID,
structured: {},
content: [ToolOutput.text({ type: "text", text: "/tmp" })],
content: [{ type: "text", text: "/tmp" }],
provider: { executed: true, metadata: { fake: { status: "done" } } },
},
} satisfies SessionEvent.Event),

View file

@ -1,8 +1,7 @@
export * from "./gen/types.gen.js"
export type {
FileSystemBinaryContent as LocationFileSystemBinaryContent,
FileSystemContent as LocationFileSystemContent,
FileSystemEntry as LocationFileSystemEntry,
FileSystemTextContent as LocationFileSystemTextContent,
} from "./gen/types.gen.js"
import { createClient } from "./gen/client/client.gen.js"

View file

@ -266,6 +266,8 @@ import type {
V2CommandListResponses,
V2EventSubscribeErrors,
V2EventSubscribeResponses,
V2FsFindErrors,
V2FsFindResponses,
V2FsListErrors,
V2FsListResponses,
V2FsReadErrors,
@ -5601,6 +5603,43 @@ export class Fs extends HeyApiClient {
...params,
})
}
/**
* Find files
*
* Find recursively ranked filesystem entries relative to the requested location.
*/
public find<ThrowOnError extends boolean = false>(
parameters: {
location?: {
directory?: string
workspace?: string
}
query: string
type?: "file" | "directory"
limit?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "location" },
{ in: "query", key: "query" },
{ in: "query", key: "type" },
{ in: "query", key: "limit" },
],
},
],
)
return (options?.client ?? this.client).get<V2FsFindResponses, V2FsFindErrors, ThrowOnError>({
url: "/api/fs/find",
...options,
...params,
})
}
}
export class Command2 extends HeyApiClient {

View file

@ -2985,19 +2985,7 @@ export type ToolTextContent = {
export type ToolFileContent = {
type: "file"
source:
| {
type: "data"
data: string
}
| {
type: "url"
url: string
}
| {
type: "file"
uri: string
}
uri: string
mime: string
name?: string
}
@ -4131,16 +4119,11 @@ export type PermissionSavedInfo = {
resource: string
}
export type FileSystemTextContent = {
type: "text"
export type FileSystemContent = {
uri: string
name?: string
content: string
mime: string
}
export type FileSystemBinaryContent = {
type: "binary"
content: string
encoding: "base64"
encoding: "utf8" | "base64"
mime: string
}
@ -10095,7 +10078,7 @@ export type V2FsReadResponses = {
*/
200: {
location: LocationInfo
data: FileSystemTextContent | FileSystemBinaryContent
data: FileSystemContent
}
}
@ -10139,6 +10122,46 @@ export type V2FsListResponses = {
export type V2FsListResponse = V2FsListResponses[keyof V2FsListResponses]
export type V2FsFindData = {
body?: never
path?: never
query: {
location?: {
directory?: string
workspace?: string
}
query: string
type?: "file" | "directory"
limit?: string
}
url: "/api/fs/find"
}
export type V2FsFindErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2FsFindError = V2FsFindErrors[keyof V2FsFindErrors]
export type V2FsFindResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: Array<FileSystemEntry>
}
}
export type V2FsFindResponse = V2FsFindResponses[keyof V2FsFindResponses]
export type V2CommandListData = {
body?: never
path?: never

View file

@ -1,6 +1,6 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { RelativePath } from "@opencode-ai/core/schema"
import { PositiveInt, RelativePath } from "@opencode-ai/core/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
@ -15,6 +15,13 @@ const ListQuery = Schema.Struct({
path: RelativePath.pipe(Schema.optional),
})
const FindQuery = Schema.Struct({
...LocationQuery.fields,
query: FileSystem.FindInput.fields.query,
type: FileSystem.FindInput.fields.type,
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional),
})
export const FileSystemGroup = HttpApiGroup.make("server.fs")
.add(
HttpApiEndpoint.get("fs.read", "/api/fs/read", {
@ -44,6 +51,20 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
}),
),
)
.add(
HttpApiEndpoint.get("fs.find", "/api/fs/find", {
query: FindQuery,
success: Location.response(Schema.Array(FileSystem.Entry)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.find",
summary: "Find files",
description: "Find recursively ranked filesystem entries relative to the requested location.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "filesystem",

View file

@ -5,9 +5,10 @@ import { Api } from "../api"
import { response } from "../groups/location"
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
Effect.gen(function* () {
return handlers
Effect.succeed(
handlers
.handle("fs.read", (ctx) => response(FileSystem.Service.use((fs) => fs.read(ctx.query))))
.handle("fs.list", (ctx) => response(FileSystem.Service.use((fs) => fs.list(ctx.query))))
}),
.handle("fs.find", (ctx) => response(FileSystem.Service.use((fs) => fs.find(ctx.query)))),
),
)

View file

@ -316,9 +316,9 @@ export function Autocomplete(props: {
const { lineRange, baseQuery } = extractLineRange(query ?? "")
// Get files from SDK
const result = await sdk.client.find.files({
const result = await sdk.client.v2.fs.find({
query: baseQuery,
workspace: project.workspace.current(),
location: { workspace: project.workspace.current() },
})
const options: AutocompleteOption[] = []
@ -328,15 +328,14 @@ export function Autocomplete(props: {
if (!result.error && result.data) {
const width = props.anchor().width - 4
options.push(
...result.data.map((item): AutocompleteOption => {
const { filename, url, part } = createFilePart(item, lineRange)
...result.data.data.map((item): AutocompleteOption => {
const { filename, url, part } = createFilePart(item.path, lineRange)
const isDir = item.endsWith("/")
return {
display: Locale.truncateMiddle(filename, width),
value: filename,
isDirectory: isDir,
path: item,
isDirectory: item.type === "directory",
path: item.path,
onSelect: () => {
insertPart(filename, part)
},
@ -561,7 +560,7 @@ export function Autocomplete(props: {
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText("@" + path)
input.insertText("@" + path + "/")
setStore("selected", 0)
}

View file

@ -1074,7 +1074,7 @@ function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
.map((item) => {
if (item.type === "text") return item.text.trim()
const source =
item.source.type === "data" ? "inline data" : item.source.type === "url" ? item.source.url : item.source.uri
item.uri
return `[file ${item.name ?? source}]`
})
.filter(Boolean)