refactor(core): tag read outputs (#39122)

This commit is contained in:
Aiden Cline 2026-07-27 09:09:44 -05:00 committed by GitHub
parent 9a55d125f6
commit 9977ef0160
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 37 additions and 9 deletions

View file

@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { dirname } from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
@ -26,7 +25,11 @@ const LocationInput = Schema.Struct({
}),
})
const Input = LocationInput
const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
const Output = Schema.Union([
ReadToolFileSystem.FileContent,
ReadToolFileSystem.TextPage,
ReadToolFileSystem.ListPage,
])
export const Plugin = {
id: "opencode.tool.read",
@ -107,7 +110,7 @@ export const Plugin = {
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
)
if ("encoding" in content && content.encoding === "base64" && !SUPPORTED_IMAGE_MIMES.has(content.mime))
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_IMAGE_MIMES.has(content.mime))
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
@ -115,7 +118,7 @@ export const Plugin = {
// Image base64 reaches the model through content items; avoid a second
// unresized copy in model text.
const content =
"encoding" in output && output.encoding === "base64"
output.type === "file" && output.encoding === "base64"
? SUPPORTED_IMAGE_MIMES.has(output.mime)
? ([
{ type: "text", text: "Image read successfully" },

View file

@ -75,6 +75,12 @@ export const PageInput = Schema.Struct({
})
export type PageInput = typeof PageInput.Type
export const FileContent = Schema.Struct({
type: Schema.Literal("file"),
...FileSystem.Content.fields,
}).annotate({ identifier: "ReadTool.FileContent" })
export type FileContent = typeof FileContent.Type
export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
type: Schema.Literal("text-page"),
content: Schema.String,
@ -85,6 +91,7 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
}) {}
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
type: Schema.Literal("list-page"),
entries: Schema.Array(FileSystem.Entry),
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
@ -96,7 +103,7 @@ export interface Interface {
path: AbsolutePath,
resource: string,
page?: PageInput,
) => Effect.Effect<FileSystem.Content | TextPage, ReadError>
) => Effect.Effect<FileContent | TextPage, ReadError>
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
}
@ -199,6 +206,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: Buffer.concat(
@ -223,6 +231,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
}
text.push(yield* decodeUtf8(resource, decoder))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: text.join(""),
@ -348,7 +357,12 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
.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 } : {}) })
return new ListPage({
type: "list-page",
entries: selected,
truncated,
...(truncated ? { next: offset + selected.length } : {}),
})
})
const layer = Layer.effect(

View file

@ -5,7 +5,6 @@ import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { Image } from "@opencode-ai/core/image"
@ -48,7 +47,8 @@ const readCalls: {
const listCalls: ReadToolFileSystem.PageInput[] = []
let resolvedType: "file" | "directory" = "file"
let resolveFailure: unknown
let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = {
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
type: "file",
uri: "file:///README.md",
name: "README.md",
content: "hello",
@ -69,7 +69,7 @@ const reader = Layer.succeed(
list: (_path, input = {}) =>
Effect.sync(() => {
listCalls.push(input)
return new ReadToolFileSystem.ListPage({ entries: [], truncated: false })
return new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
}),
}),
)
@ -181,6 +181,7 @@ describe("ReadTool", () => {
resolvedType = "file"
resolveFailure = undefined
readResult = {
type: "file",
uri: "file:///README.md",
name: "README.md",
content: "hello",
@ -209,6 +210,7 @@ describe("ReadTool", () => {
expect(execution.status).toBe("completed")
if (execution.status !== "completed") return
expect(execution.output).toEqual({
type: "file",
uri: "file:///README.md",
name: "README.md",
content: "hello",
@ -253,6 +255,7 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = {
type: "file",
uri: "file:///pixel.png",
name: "pixel.png",
content: png,
@ -305,6 +308,7 @@ describe("ReadTool", () => {
source.free()
expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024)
readResult = {
type: "file",
uri: "file:///large.png",
name: "large.png",
content: png,
@ -338,6 +342,7 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = {
type: "file",
uri: "file:///pixel.png",
name: "pixel.png",
content: png,
@ -362,6 +367,7 @@ describe("ReadTool", () => {
it.effect("drops undecodable image data from the outcome", () =>
Effect.gen(function* () {
readResult = {
type: "file",
uri: "file:///truncated.png",
name: "truncated.png",
content: "iVBORw0KGgo=",
@ -393,6 +399,7 @@ describe("ReadTool", () => {
const base64 = Buffer.from(source.get_bytes()).toString("base64")
source.free()
readResult = {
type: "file",
uri: "file:///wide.png",
name: "wide.png",
content: base64,
@ -434,6 +441,7 @@ describe("ReadTool", () => {
const base64 = Buffer.from(source.get_bytes()).toString("base64")
source.free()
readResult = {
type: "file",
uri: "file:///wide.png",
name: "wide.png",
content: base64,
@ -471,6 +479,7 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = {
type: "file",
uri: "file:///pixel.png",
name: "pixel.png",
content: png,
@ -509,6 +518,7 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = {
type: "file",
uri: "file:///pixel.bin",
name: "pixel.bin",
content: png,
@ -698,6 +708,7 @@ describe("ReadTool", () => {
it.effect("rejects unsupported binary discovered by a direct read", () =>
Effect.gen(function* () {
readResult = {
type: "file",
uri: "file:///late-binary",
name: "late-binary",
content: "AAECAw==",