mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 01:48:40 +00:00
refactor(core): simplify location filesystem (#31545)
This commit is contained in:
parent
0777cf1ccf
commit
132ef57272
73 changed files with 1054 additions and 1422 deletions
1
bun.lock
1
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
275
packages/core/src/tool/read-filesystem.ts
Normal file
275
packages/core/src/tool/read-filesystem.ts
Normal 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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 }] : []),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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", () =>
|
||||
|
|
|
|||
|
|
@ -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 }])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) }))),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)))),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue