From 9977ef016015d795168ad25c125d4fe160151d08 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:09:44 -0500 Subject: [PATCH] refactor(core): tag read outputs (#39122) --- packages/core/src/tool/plugin/read.ts | 11 +++++++---- packages/core/src/tool/read-filesystem.ts | 18 ++++++++++++++++-- packages/core/test/tool-read.test.ts | 17 ++++++++++++++--- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tool/plugin/read.ts b/packages/core/src/tool/plugin/read.ts index 2de9757bd68..e750d1f8dab 100644 --- a/packages/core/src/tool/plugin/read.ts +++ b/packages/core/src/tool/plugin/read.ts @@ -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" }, diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index 5eeab17b87e..bf85368e984 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -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("ReadTool.TextPage")({ type: Schema.Literal("text-page"), content: Schema.String, @@ -85,6 +91,7 @@ export class TextPage extends Schema.Class("ReadTool.TextPage")({ }) {} export class ListPage extends Schema.Class("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 + ) => Effect.Effect readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect } @@ -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( diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 3e8427c447b..c0bc9a2aa75 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -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==",