Ver Fonte

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

Aiden Cline há 2 semanas atrás
pai
commit
9977ef0160

+ 7 - 4
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" },

+ 16 - 2
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<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(

+ 14 - 3
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==",