浏览代码

refactor(core): simplify filesystem read protocol (#31058)

Kit Langton 3 月之前
父节点
当前提交
147169e9b7

+ 28 - 188
packages/core/src/filesystem.ts

@@ -28,16 +28,6 @@ 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 ReadLimitError extends Error {
-  constructor(
-    readonly resource: string,
-    readonly maximumBytes: number,
-  ) {
-    super(`File exceeds ${maximumBytes} byte read limit: ${resource}`)
-    this.name = "ReadLimitError"
-  }
-}
-
 export class BinaryFileError extends Error {
   constructor(readonly resource: string) {
     super(`Cannot read binary file: ${resource}`)
@@ -137,12 +127,9 @@ export class TextPage extends Schema.Class<TextPage>("FileSystem.TextPage")({
   next: PositiveInt.pipe(Schema.optional),
 }) {}
 
-export class ReadTarget extends Schema.Class<ReadTarget>("FileSystem.ReadTarget")({
-  real: Schema.String,
+export class ReadPath extends Schema.Class<ReadPath>("FileSystem.ReadPath")({
+  type: Schema.Literals(["file", "directory"]),
   resource: Schema.String,
-  size: NonNegativeInt,
-  dev: Schema.Number,
-  ino: Schema.Number.pipe(Schema.optional),
 }) {}
 
 export const ListInput = Schema.Struct({
@@ -179,10 +166,6 @@ export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget"
   ino: Schema.Number.pipe(Schema.optional),
 }) {}
 
-export type ReadPathTarget =
-  | { readonly type: "file"; readonly target: ReadTarget }
-  | { readonly type: "directory"; readonly target: ListTarget }
-
 export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
   path: RelativePath,
   uri: Schema.String,
@@ -235,12 +218,8 @@ export const Event = {
 
 export interface Interface {
   readonly read: (input: ReadInput) => Effect.Effect<Content>
-  readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPathTarget>
-  readonly resolveRead: (input: ReadInput) => Effect.Effect<ReadTarget>
-  readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect<Content>
-  readonly readSampleResolved: (target: ReadTarget, maximumBytes: number) => Effect.Effect<Uint8Array>
-  readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<TextPage>
-  readonly readToolResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<Content | TextPage>
+  readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPath>
+  readonly readTool: (input: ReadInput, page?: TextPageInput) => Effect.Effect<Content | TextPage>
   readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
   /** Select a contained canonical read root without asserting leaf policy. */
   readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
@@ -361,33 +340,27 @@ export const layer = Layer.effect(
     })
 
     const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
-      const file = yield* resolve(input.path, input.reference)
-      const info = yield* fs.stat(file.real).pipe(Effect.orDie)
-      const relative = path.relative(file.root, file.real).replaceAll("\\", "/")
-      const resource = input.reference === undefined ? relative || "." : `${input.reference}:${relative || "."}`
-      if (info.type === "File") {
-        return {
-          type: "file" as const,
-          target: new ReadTarget({
-            real: file.real,
-            resource,
-            size: Number(info.size),
-            dev: info.dev,
-            ino: Option.getOrUndefined(info.ino),
-          }),
-        }
-      }
-      if (info.type === "Directory") {
-        return { type: "directory" as const, target: new ListTarget({ ...file, resource }) }
-      }
-      return yield* Effect.die(new Error("Path is not a file or directory"))
+      const target = yield* resolve(input.path, input.reference)
+      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: input.reference === undefined ? relative : `${input.reference}:${relative}`,
+      })
     })
-    const resolveRead = Effect.fn("FileSystem.resolveRead")(function* (input: ReadInput) {
-      const resolved = yield* resolveReadPath(input)
-      if (resolved.type !== "file") return yield* Effect.die(new Error("Path is not a file"))
-      return resolved.target
+    const resolveFile = Effect.fnUntraced(function* (input: ReadInput) {
+      const target = yield* resolve(input.path, input.reference)
+      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: input.reference === undefined ? relative : `${input.reference}:${relative}`,
+      }
     })
-    const content = (target: ReadTarget, bytes: Uint8Array) =>
+    const content = (target: { readonly real: string }, bytes: Uint8Array) =>
       Effect.gen(function* () {
         const mime = FSUtil.mimeType(target.real)
         if (!bytes.includes(0)) {
@@ -403,143 +376,13 @@ export const layer = Layer.effect(
           mime,
         })
       })
-    const readResolved = Effect.fn("FileSystem.readResolved")(function* (target: ReadTarget, maximumBytes?: number) {
-      if (maximumBytes === undefined) return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie))
-      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"))
-          if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
-            return yield* Effect.die(new Error("File changed after permission approval"))
-          if (info.size > maximumBytes) return yield* Effect.die(new ReadLimitError(target.resource, maximumBytes))
-          const bytes = yield* file.readAlloc(maximumBytes + 1).pipe(Effect.orDie)
-          if (bytes._tag === "Some" && bytes.value.length > maximumBytes)
-            return yield* Effect.die(new ReadLimitError(target.resource, maximumBytes))
-          return yield* content(target, bytes._tag === "Some" ? bytes.value : new Uint8Array())
-        }),
-      )
-    })
-    const readSampleResolved = Effect.fn("FileSystem.readSampleResolved")(function* (
-      target: ReadTarget,
-      maximumBytes: number,
-    ) {
-      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"))
-          if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
-            return yield* Effect.die(new Error("File changed after permission approval"))
-          return Option.getOrElse(yield* file.readAlloc(maximumBytes).pipe(Effect.orDie), () => new Uint8Array())
-        }),
-      )
-    })
-    const readTextPageResolved = Effect.fn("FileSystem.readTextPageResolved")(function* (
-      target: ReadTarget,
-      page: TextPageInput = {},
-    ) {
-      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"))
-          if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
-            return yield* Effect.die(new Error("File changed after permission approval"))
-
-          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 true
-            }
-            if (lines.length >= limit) {
-              truncated = true
-              next = line
-              return false
-            }
-            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 false
-            }
-            lines.push(text)
-            bytes += size
-            line++
-            return true
-          }
-
-          let done = false
-          while (!done) {
-            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))
-            let text = decoder.decode(chunk.value, { 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)
-              if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) {
-                done = true
-                break
-              }
-            }
-          }
-          if (!done) {
-            const tail = decoder.decode()
-            if (!discard) pending += tail
-            if (pending && !append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)) done = true
-          }
-          if (!done && !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(target.real),
-            offset,
-            truncated,
-            ...(next === undefined ? {} : { next }),
-          })
-        }),
-      )
-    })
-    const readToolResolved = Effect.fn("FileSystem.readToolResolved")(function* (
-      target: ReadTarget,
-      page: TextPageInput = {},
-    ) {
+    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"))
-          if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
-            return yield* Effect.die(new Error("File changed after permission approval"))
 
           const first = Option.getOrElse(
             yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || READ_SAMPLE_BYTES)).pipe(Effect.orDie),
@@ -761,14 +604,11 @@ export const layer = Layer.effect(
 
     return Service.of({
       read: Effect.fn("FileSystem.read")(function* (input) {
-        return yield* readResolved(yield* resolveRead(input))
+        const target = yield* resolveFile(input)
+        return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie))
       }),
       resolveReadPath,
-      resolveRead,
-      readResolved,
-      readSampleResolved,
-      readTextPageResolved,
-      readToolResolved,
+      readTool,
       list: Effect.fn("FileSystem.list")(function* (input) {
         return yield* listResolved(yield* resolveList(input))
       }),

+ 9 - 24
packages/core/src/tool/read.ts

@@ -91,33 +91,20 @@ export const layer = Layer.effectDiscard(
     yield* registry.contribute((editor) =>
       editor.set(name, {
         tool: definition,
-        execute: ({ parameters, sessionID, assertPermission }) => {
+        execute: ({ parameters, assertPermission }) => {
           const input = parameters
           return Effect.gen(function* () {
             const resolved = yield* filesystem.resolveReadPath(input)
             if (resolved.type === "directory") {
-              const { offset, limit } = input
-              const target = resolved.target
-              yield* assertPermission({ action: name, resources: [target.resource], save: ["*"] })
-              const final = yield* filesystem.resolveReadPath(input)
-              if (
-                final.type !== "directory" ||
-                final.target.resource !== target.resource ||
-                final.target.real !== target.real
-              )
-                return yield* Effect.die(new Error("Directory changed after permission approval"))
-              return yield* filesystem.listPageResolved(final.target, { offset, limit })
+              yield* assertPermission({ action: name, resources: [resolved.resource], save: ["*"] })
+              return yield* filesystem.listPage(input)
             }
-            const target = resolved.target
             yield* assertPermission({
               action: name,
-              resources: [target.resource],
+              resources: [resolved.resource],
               save: ["*"],
             })
-            const final = yield* filesystem.resolveReadPath(input)
-            if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real)
-              return yield* Effect.die(new Error("File changed after permission approval"))
-            const content = yield* filesystem.readToolResolved(final.target, {
+            const content = yield* filesystem.readTool(input, {
               offset: input.offset,
               limit: input.limit,
             })
@@ -139,7 +126,7 @@ export const layer = Layer.effectDiscard(
               const photon = yield* loadPhoton
               const decoded = yield* Effect.try({
                 try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
-                catch: () => new ImageDecodeError(final.target.resource),
+                catch: () => new ImageDecodeError(resolved.resource),
               })
               try {
                 const width = decoded.get_width()
@@ -150,7 +137,7 @@ export const layer = Layer.effectDiscard(
                 if (!limits.autoResize)
                   return yield* Effect.die(
                     new ImageSizeError(
-                      final.target.resource,
+                      resolved.resource,
                       width,
                       height,
                       bytes,
@@ -199,7 +186,7 @@ export const layer = Layer.effectDiscard(
                 }
                 return yield* Effect.die(
                   new ImageSizeError(
-                    final.target.resource,
+                    resolved.resource,
                     width,
                     height,
                     bytes,
@@ -212,8 +199,7 @@ export const layer = Layer.effectDiscard(
                 decoded.free()
               }
             }
-            if (content.type === "binary")
-              return yield* Effect.die(new FileSystem.BinaryFileError(final.target.resource))
+            if (content.type === "binary") return yield* Effect.die(new FileSystem.BinaryFileError(resolved.resource))
             return content
           }).pipe(
             Effect.catchCause((cause) =>
@@ -221,7 +207,6 @@ export const layer = Layer.effectDiscard(
                 const error = Cause.squash(cause)
                 const message =
                   error instanceof FileSystem.BinaryFileError ||
-                  error instanceof FileSystem.ReadLimitError ||
                   error instanceof FileSystem.MediaIngestLimitError ||
                   error instanceof ImageDecodeError ||
                   error instanceof ImageSizeError

+ 35 - 69
packages/core/test/location-filesystem.test.ts

@@ -2,7 +2,7 @@ import fs from "fs/promises"
 import path from "path"
 import { fileURLToPath } from "url"
 import { describe, expect, test } from "bun:test"
-import { Effect, Exit, Fiber, Layer, Schema } from "effect"
+import { 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"
@@ -91,26 +91,9 @@ describe("FileSystem", () => {
           encoding: "base64",
           mime: "application/octet-stream",
         })
-        const binary = yield* service.resolveRead({ path: RelativePath.make("data.bin") })
-        expect(Exit.isFailure(yield* service.readTextPageResolved(binary).pipe(Effect.exit))).toBe(true)
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  it.live("revalidates file identity before sampled classification", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        const file = path.join(directory, "image.png")
-        yield* Effect.promise(() => fs.writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47])))
-        const service = yield* FileSystem.Service
-        const target = yield* service.resolveRead({ path: RelativePath.make("image.png") })
-
-        yield* Effect.promise(() => fs.rename(file, path.join(directory, "original.png")))
-        yield* Effect.promise(() => fs.writeFile(file, Buffer.from([0xff, 0xd8, 0xff])))
-
-        expect(
-          Exit.isFailure(yield* service.readSampleResolved(target, FileSystem.READ_SAMPLE_BYTES).pipe(Effect.exit)),
-        ).toBe(true)
+        expect(Exit.isFailure(yield* service.readTool({ path: RelativePath.make("data.bin") }).pipe(Effect.exit))).toBe(
+          true,
+        )
       }).pipe(provide(directory)),
     ),
   )
@@ -121,17 +104,18 @@ describe("FileSystem", () => {
         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 target = yield* service.resolveRead({ path: RelativePath.make("large.txt") })
+        const input = { path: RelativePath.make("large.txt") }
 
-        const first = yield* service.readTextPageResolved(target)
-        expect(first).toMatchObject({
+        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.readTextPageResolved(target, { offset: next, limit: 1 })).toEqual({
+        expect(yield* service.readTool(input, { offset: next, limit: 1 })).toEqual({
           type: "text-page",
           content: lines[next - 1],
           mime: "text/plain",
@@ -139,7 +123,7 @@ describe("FileSystem", () => {
           truncated: true,
           next: next + 1,
         })
-        expect(yield* service.readTextPageResolved(target, { offset: 30 })).toEqual({
+        expect(yield* service.readTool(input, { offset: 30 })).toEqual({
           type: "text-page",
           content: lines[29],
           mime: "text/plain",
@@ -161,8 +145,11 @@ describe("FileSystem", () => {
           ),
         )
         const service = yield* FileSystem.Service
-        const target = yield* service.resolveRead({ path: RelativePath.make("late-binary.txt") })
-        expect(Exit.isFailure(yield* service.readToolResolved(target, { limit: 1 }).pipe(Effect.exit))).toBe(true)
+        expect(
+          Exit.isFailure(
+            yield* service.readTool({ path: RelativePath.make("late-binary.txt") }, { limit: 1 }).pipe(Effect.exit),
+          ),
+        ).toBe(true)
       }).pipe(provide(directory)),
     ),
   )
@@ -178,8 +165,11 @@ describe("FileSystem", () => {
           ),
         )
         const service = yield* FileSystem.Service
-        const target = yield* service.resolveRead({ path: RelativePath.make("invalid-utf8.txt") })
-        expect(Exit.isFailure(yield* service.readToolResolved(target, { limit: 1 }).pipe(Effect.exit))).toBe(true)
+        expect(
+          Exit.isFailure(
+            yield* service.readTool({ path: RelativePath.make("invalid-utf8.txt") }, { limit: 1 }).pipe(Effect.exit),
+          ),
+        ).toBe(true)
       }).pipe(provide(directory)),
     ),
   )
@@ -194,11 +184,17 @@ describe("FileSystem", () => {
           fs.writeFile(large, Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(80_000)])),
         )
         const service = yield* FileSystem.Service
-        const smallTarget = yield* service.resolveRead({ path: RelativePath.make("small.pdf") })
-        const largeTarget = yield* service.resolveRead({ path: RelativePath.make("large.pdf") })
-        expect(Exit.isFailure(yield* service.readToolResolved(smallTarget).pipe(Effect.exit))).toBe(true)
-        expect(Exit.isFailure(yield* service.readToolResolved(largeTarget).pipe(Effect.exit))).toBe(true)
-        expect(Exit.isFailure(yield* service.readToolResolved(largeTarget, { limit: 1 }).pipe(Effect.exit))).toBe(true)
+        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)),
     ),
   )
@@ -217,42 +213,14 @@ describe("FileSystem", () => {
           }
         })
         const service = yield* FileSystem.Service
-        const target = yield* service.resolveRead({ path: RelativePath.make("huge.png") })
-        const exit = yield* service.readToolResolved(target).pipe(Effect.exit)
+        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("never mixes a sampled image with replacement-path content", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        const file = path.join(directory, "race.png")
-        const moved = path.join(directory, "original.png")
-        const original = Buffer.concat([
-          Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
-          Buffer.alloc(4 * 1024 * 1024, 0x11),
-        ])
-        const replacement = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.alloc(1024, 0x22)])
-        yield* Effect.promise(() => fs.writeFile(file, original))
-        const service = yield* FileSystem.Service
-        const target = yield* service.resolveRead({ path: RelativePath.make("race.png") })
-        const reading = yield* service.readToolResolved(target).pipe(Effect.forkChild)
-        yield* Effect.promise(async () => {
-          await fs.rename(file, moved)
-          await fs.writeFile(file, replacement)
-        })
-        const exit = yield* Fiber.join(reading).pipe(Effect.exit)
-        if (Exit.isSuccess(exit)) {
-          expect(exit.value).toMatchObject({ type: "binary", mime: "image/png" })
-          if (exit.value.type === "binary") expect(exit.value.content).toBe(original.toString("base64"))
-        }
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  it.live("closes validated descriptors after successful and failed reads", () =>
+  it.live("closes descriptors after successful and failed reads", () =>
     withTmp((directory) => {
       let active = 0
       const filesystem = Layer.effect(
@@ -280,10 +248,8 @@ describe("FileSystem", () => {
             ? undefined
             : yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
         for (let index = 0; index < 50; index++) {
-          yield* service.readToolResolved(yield* service.resolveRead({ path: RelativePath.make("text.txt") }))
-          yield* service
-            .readToolResolved(yield* service.resolveRead({ path: RelativePath.make("binary.pdf") }))
-            .pipe(Effect.exit)
+          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) {

+ 1 - 5
packages/core/test/tool-glob.test.ts

@@ -37,11 +37,7 @@ const filesystem = Layer.succeed(
   FileSystem.Service.of({
     read: () => Effect.die("unused"),
     resolveReadPath: () => Effect.die("unused"),
-    resolveRead: () => Effect.die("unused"),
-    readResolved: () => Effect.die("unused"),
-    readSampleResolved: () => Effect.die("unused"),
-    readTextPageResolved: () => Effect.die("unused"),
-    readToolResolved: () => Effect.die("unused"),
+    readTool: () => Effect.die("unused"),
     list: () => Effect.die("unused"),
     resolveRoot: (input = {}) =>
       Effect.sync(() => {

+ 1 - 5
packages/core/test/tool-grep.test.ts

@@ -32,11 +32,7 @@ const filesystem = Layer.succeed(
   FileSystem.Service.of({
     read: () => Effect.die("unused"),
     resolveReadPath: () => Effect.die("unused"),
-    resolveRead: () => Effect.die("unused"),
-    readResolved: () => Effect.die("unused"),
-    readSampleResolved: () => Effect.die("unused"),
-    readTextPageResolved: () => Effect.die("unused"),
-    readToolResolved: () => Effect.die("unused"),
+    readTool: () => Effect.die("unused"),
     list: () => Effect.die("unused"),
     resolveRoot: (input = {}) =>
       Effect.succeed(

+ 66 - 365
packages/core/test/tool-read.test.ts

@@ -1,4 +1,4 @@
-import { describe, expect } from "bun:test"
+import { beforeEach, describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { Config } from "@opencode-ai/core/config"
 import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
@@ -7,28 +7,21 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ReadTool } from "@opencode-ai/core/tool/read"
-import { RelativePath } from "@opencode-ai/core/schema"
 import { testEffect } from "./lib/effect"
 
 const assertions: PermissionV2.AssertInput[] = []
-const reads: FileSystem.ReadInput[] = []
-const samples: number[] = []
-const textPageInputs: FileSystem.TextPageInput[] = []
-const pages: FileSystem.ListTarget[] = []
-const pageInputs: Pick<FileSystem.ListPageInput, "offset" | "limit">[] = []
-let resolvedInput: FileSystem.ReadInput | undefined
+const readCalls: {
+  input: FileSystem.ReadInput & FileSystem.TextPageInput
+  page: FileSystem.TextPageInput
+}[] = []
+const listCalls: FileSystem.ListPageInput[] = []
+let resolvedType: "file" | "directory" = "file"
 let resolveFailure: unknown
-let listResolveFailure: unknown = new Error("not a directory")
-let listReal = "/project/src"
-let size = 5
-let real = "/project/README.md"
-let afterApproval = () => {}
-let readContent: FileSystem.Content = new FileSystem.TextContent({
+let readResult: FileSystem.Content | FileSystem.TextPage = new FileSystem.TextContent({
   type: "text",
   content: "hello",
   mime: "text/plain",
 })
-let sample = new TextEncoder().encode("hello")
 let readFailure: unknown
 let configEntries: Config.Entry[] = []
 const filesystem = Layer.succeed(
@@ -37,121 +30,29 @@ const filesystem = Layer.succeed(
     read: () => Effect.die("unused"),
     resolveReadPath: (input) =>
       resolveFailure === undefined
-        ? Effect.succeed({
-            type: "file" as const,
-            target: new FileSystem.ReadTarget({
-              real,
+        ? Effect.succeed(
+            new FileSystem.ReadPath({
+              type: resolvedType,
               resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
-              size,
-              dev: 1,
             }),
-          })
-        : listResolveFailure === undefined
-          ? Effect.succeed({
-              type: "directory" as const,
-              target: new FileSystem.ListTarget({
-                absolute: `/project/${input.path ?? "."}`,
-                real: listReal,
-                directory: "/project",
-                root: "/project",
-                resource: input.path ?? ".",
-              }),
-            })
-          : Effect.die(resolveFailure),
-    resolveRead: (input) =>
-      Effect.sync(() => {
-        resolvedInput = input
-      }).pipe(
-        Effect.andThen(
-          resolveFailure === undefined
-            ? Effect.succeed(
-                new FileSystem.ReadTarget({
-                  real,
-                  resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
-                  size,
-                  dev: 1,
-                }),
-              )
-            : Effect.die(resolveFailure),
-        ),
-      ),
-    readResolved: () =>
-      readFailure === undefined
-        ? Effect.sync(() => {
-            reads.push({ path: RelativePath.make("README.md") })
-            return readContent
-          })
-        : Effect.die(readFailure),
-    readSampleResolved: (_target, maximumBytes) =>
-      Effect.sync(() => {
-        samples.push(maximumBytes)
-        return sample.slice(0, maximumBytes)
-      }),
-    readTextPageResolved: (_target, page = {}) =>
-      readFailure === undefined
-        ? Effect.sync(() => {
-            textPageInputs.push(page)
-            return new FileSystem.TextPage({
-              type: "text-page",
-              content: "hello",
-              mime: "text/plain",
-              offset: page.offset ?? 1,
-              truncated: true,
-              next: (page.offset ?? 1) + 1,
-            })
-          })
-        : Effect.die(readFailure),
-    readToolResolved: (_target, page = {}) => {
-      samples.push(FileSystem.READ_SAMPLE_BYTES)
+          )
+        : Effect.die(resolveFailure),
+    readTool: (input, page = {}) => {
+      readCalls.push({ input, page })
       if (readFailure !== undefined) return Effect.die(readFailure)
-      if (sample[0] === 0x89 && sample[1] === 0x50 && sample[2] === 0x4e && sample[3] === 0x47)
-        return Effect.succeed(
-          readContent.type === "binary"
-            ? new FileSystem.BinaryContent({ ...readContent, mime: "image/png" })
-            : readContent,
-        )
-      if (FileSystem.isBinary(real.split("/").at(-1) ?? real, sample))
-        return Effect.die(new FileSystem.BinaryFileError(real.split("/").at(-1) ?? real))
-      if (size > FileSystem.MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined)
-        return Effect.sync(() => {
-          textPageInputs.push(page)
-          return new FileSystem.TextPage({
-            type: "text-page",
-            content: "hello",
-            mime: "text/plain",
-            offset: page.offset ?? 1,
-            truncated: true,
-            next: (page.offset ?? 1) + 1,
-          })
-        })
-      return Effect.sync(() => {
-        reads.push({ path: RelativePath.make("README.md") })
-        return readContent
-      })
+      return Effect.succeed(readResult)
     },
     resolveRoot: () => Effect.die("unused"),
     revalidateRoot: Effect.succeed,
     list: () => Effect.die("unused"),
-    resolveList: (input = {}) =>
-      listResolveFailure === undefined
-        ? Effect.succeed(
-            new FileSystem.ListTarget({
-              absolute: `/project/${input.path ?? "."}`,
-              real: listReal,
-              directory: "/project",
-              root: "/project",
-              resource: input.path ?? ".",
-            }),
-          )
-        : Effect.die(listResolveFailure),
+    resolveList: () => Effect.die("unused"),
     listResolved: () => Effect.die("unused"),
-    listPage: () => Effect.die("unused"),
-    listPageResolved: (target, page = {}) =>
+    listPage: (input = {}) =>
       Effect.sync(() => {
-        pages.push(target)
-        pageInputs.push(page)
+        listCalls.push(input)
         return new FileSystem.ListPage({ entries: [], truncated: false })
       }),
+    listPageResolved: () => Effect.die("unused"),
     find: () => Effect.die("unused"),
     grep: () => Effect.die("unused"),
     isIgnored: () => false,
@@ -164,7 +65,6 @@ const permission = Layer.succeed(
     assert: (input) =>
       Effect.sync(() => {
         assertions.push(input)
-        if (allow) afterApproval()
       }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
     ask: () => Effect.die("unused"),
     reply: () => Effect.die("unused"),
@@ -185,21 +85,20 @@ const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, r
 const sessionID = SessionV2.ID.make("ses_read_tool_test")
 
 describe("ReadTool", () => {
+  beforeEach(() => {
+    assertions.length = 0
+    readCalls.length = 0
+    listCalls.length = 0
+    allow = true
+    resolvedType = "file"
+    resolveFailure = undefined
+    readResult = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
+    readFailure = undefined
+    configEntries = []
+  })
+
   it.effect("registers, authorizes, and reads through the location filesystem", () =>
     Effect.gen(function* () {
-      assertions.length = 0
-      reads.length = 0
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = 5
-      real = "/project/README.md"
-      afterApproval = () => {}
-      readContent = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
-      sample = new TextEncoder().encode("hello")
-      readFailure = undefined
-      configEntries = []
-      resolvedInput = undefined
       const registry = yield* ToolRegistry.Service
 
       expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
@@ -210,30 +109,19 @@ describe("ReadTool", () => {
         }),
       ).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
       expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
-      expect(reads).toEqual([{ path: RelativePath.make("README.md") }])
+      expect(readCalls).toEqual([{ input: { path: "README.md" }, page: {} }])
     }),
   )
 
   it.effect("returns a small PNG as native media instead of durable base64 text", () =>
     Effect.gen(function* () {
       const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
-      reads.length = 0
-      samples.length = 0
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = Buffer.from(png, "base64").length
-      real = "/project/pixel.png"
-      afterApproval = () => {}
-      sample = Buffer.from(png, "base64")
-      readContent = new FileSystem.BinaryContent({
+      readResult = new FileSystem.BinaryContent({
         type: "binary",
         content: png,
         encoding: "base64",
         mime: "image/png",
       })
-      readFailure = undefined
-      configEntries = []
       const registry = yield* ToolRegistry.Service
 
       expect(
@@ -248,8 +136,7 @@ describe("ReadTool", () => {
           { type: "media", mediaType: "image/png", data: png, filename: "pixel.png" },
         ],
       })
-      expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
-      expect(reads).toHaveLength(0)
+      expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }])
 
       const settled = yield* registry.settle({
         sessionID,
@@ -264,23 +151,14 @@ describe("ReadTool", () => {
     }),
   )
 
-  it.effect("rejects invalid or truncated image data after signature classification", () =>
+  it.effect("rejects invalid image data returned by the filesystem", () =>
     Effect.gen(function* () {
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = 8
-      real = "/project/truncated.png"
-      afterApproval = () => {}
-      sample = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
-      readContent = new FileSystem.BinaryContent({
+      readResult = new FileSystem.BinaryContent({
         type: "binary",
-        content: Buffer.from(sample).toString("base64"),
+        content: "iVBORw0KGgo=",
         encoding: "base64",
         mime: "image/png",
       })
-      readFailure = undefined
-      configEntries = []
       const registry = yield* ToolRegistry.Service
 
       expect(
@@ -298,20 +176,12 @@ 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()
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = Buffer.from(base64, "base64").length
-      real = "/project/wide.png"
-      afterApproval = () => {}
-      sample = Buffer.from(base64, "base64")
-      readContent = new FileSystem.BinaryContent({
+      readResult = new FileSystem.BinaryContent({
         type: "binary",
         content: base64,
         encoding: "base64",
         mime: "image/png",
       })
-      readFailure = undefined
       configEntries = [
         new Config.Document({
           type: "document",
@@ -339,20 +209,12 @@ 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()
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = Buffer.from(base64, "base64").length
-      real = "/project/wide.png"
-      afterApproval = () => {}
-      sample = Buffer.from(base64, "base64")
-      readContent = new FileSystem.BinaryContent({
+      readResult = new FileSystem.BinaryContent({
         type: "binary",
         content: base64,
         encoding: "base64",
         mime: "image/png",
       })
-      readFailure = undefined
       configEntries = [
         new Config.Document({
           type: "document",
@@ -382,20 +244,12 @@ describe("ReadTool", () => {
   it.effect("enforces max base64 bytes after resize attempts", () =>
     Effect.gen(function* () {
       const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = Buffer.from(png, "base64").length
-      real = "/project/pixel.png"
-      afterApproval = () => {}
-      sample = Buffer.from(png, "base64")
-      readContent = new FileSystem.BinaryContent({
+      readResult = new FileSystem.BinaryContent({
         type: "binary",
         content: png,
         encoding: "base64",
         mime: "image/png",
       })
-      readFailure = undefined
       configEntries = [
         new Config.Document({
           type: "document",
@@ -417,24 +271,15 @@ describe("ReadTool", () => {
     }),
   )
 
-  it.effect("classifies supported image contents before a misleading binary extension", () =>
+  it.effect("returns supported image contents despite a misleading binary extension", () =>
     Effect.gen(function* () {
       const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = Buffer.from(png, "base64").length
-      real = "/project/pixel.bin"
-      afterApproval = () => {}
-      sample = Buffer.from(png, "base64")
-      readContent = new FileSystem.BinaryContent({
+      readResult = new FileSystem.BinaryContent({
         type: "binary",
         content: png,
         encoding: "base64",
-        mime: "application/octet-stream",
+        mime: "image/png",
       })
-      readFailure = undefined
-      configEntries = []
       const registry = yield* ToolRegistry.Service
 
       expect(
@@ -449,19 +294,9 @@ describe("ReadTool", () => {
     }),
   )
 
-  it.effect("rejects unsupported binary before direct reads or paging", () =>
+  it.effect("preserves unsupported binary errors from the filesystem", () =>
     Effect.gen(function* () {
-      reads.length = 0
-      textPageInputs.length = 0
-      samples.length = 0
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = FileSystem.MAX_READ_BYTES + 1
-      real = "/project/archive.dat"
-      afterApproval = () => {}
-      sample = new Uint8Array([0, 1, 2, 3])
-      readFailure = undefined
+      readFailure = new FileSystem.BinaryFileError("archive.dat")
       const registry = yield* ToolRegistry.Service
 
       expect(
@@ -475,23 +310,15 @@ describe("ReadTool", () => {
           },
         }),
       ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
-      expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
-      expect(reads).toEqual([])
-      expect(textPageInputs).toEqual([])
+      expect(readCalls).toEqual([
+        { input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } },
+      ])
     }),
   )
 
   it.effect("does not read when permission is denied", () =>
     Effect.gen(function* () {
-      assertions.length = 0
-      reads.length = 0
       allow = false
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = 5
-      real = "/project/README.md"
-      afterApproval = () => {}
-      resolvedInput = undefined
       const registry = yield* ToolRegistry.Service
 
       expect(
@@ -500,20 +327,13 @@ describe("ReadTool", () => {
           call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
         }),
       ).toEqual({ type: "error", value: "Unable to read README.md" })
-      expect(reads).toEqual([])
+      expect(readCalls).toEqual([])
     }),
   )
 
   it.effect("lists a bounded directory page through read", () =>
     Effect.gen(function* () {
-      assertions.length = 0
-      pages.length = 0
-      pageInputs.length = 0
-      allow = true
-      resolveFailure = new Error("Path is not a file")
-      listResolveFailure = undefined
-      listReal = "/project/src"
-      afterApproval = () => {}
+      resolvedType = "directory"
       const registry = yield* ToolRegistry.Service
 
       expect(
@@ -528,18 +348,14 @@ describe("ReadTool", () => {
         }),
       ).toEqual({ type: "json", value: { entries: [], truncated: false } })
       expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
-      expect(pageInputs).toEqual([{ offset: 2, limit: 10 }])
+      expect(listCalls).toEqual([{ path: "src", offset: 2, limit: 10 }])
     }),
   )
 
   it.effect("does not list a directory when permission is denied", () =>
     Effect.gen(function* () {
-      pages.length = 0
       allow = false
-      resolveFailure = new Error("Path is not a file")
-      listResolveFailure = undefined
-      listReal = "/project/src"
-      afterApproval = () => {}
+      resolvedType = "directory"
       const registry = yield* ToolRegistry.Service
 
       expect(
@@ -548,43 +364,12 @@ describe("ReadTool", () => {
           call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
         }),
       ).toEqual({ type: "error", value: "Unable to read src" })
-      expect(pages).toEqual([])
-    }),
-  )
-
-  it.effect("does not list when the directory changes after permission approval", () =>
-    Effect.gen(function* () {
-      pages.length = 0
-      allow = true
-      resolveFailure = new Error("Path is not a file")
-      listResolveFailure = undefined
-      listReal = "/project/src"
-      afterApproval = () => {
-        listReal = "/outside/src"
-      }
-      const registry = yield* ToolRegistry.Service
-
-      expect(
-        yield* registry.execute({
-          sessionID,
-          call: { type: "tool-call", id: "call-read-directory-swapped", name: "read", input: { path: "src" } },
-        }),
-      ).toEqual({ type: "error", value: "Unable to read src" })
-      expect(pages).toEqual([])
+      expect(listCalls).toEqual([])
     }),
   )
 
   it.effect("authorizes project references with their canonical identity", () =>
     Effect.gen(function* () {
-      assertions.length = 0
-      reads.length = 0
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = 5
-      real = "/project/README.md"
-      afterApproval = () => {}
-      resolvedInput = undefined
       const registry = yield* ToolRegistry.Service
 
       yield* registry.execute({
@@ -598,14 +383,9 @@ describe("ReadTool", () => {
 
   it.effect("settles missing files as typed tool errors", () =>
     Effect.gen(function* () {
-      allow = true
-      reads.length = 0
-      real = "/project/README.md"
-      afterApproval = () => {}
       const registry = yield* ToolRegistry.Service
 
       resolveFailure = new Error("missing")
-      listResolveFailure = new Error("missing")
       expect(
         yield* registry.execute({
           sessionID,
@@ -613,21 +393,20 @@ describe("ReadTool", () => {
         }),
       ).toEqual({ type: "error", value: "Unable to read missing.txt" })
 
-      expect(reads).toEqual([])
+      expect(readCalls).toEqual([])
     }),
   )
 
-  it.effect("reads large UTF-8 text files as bounded pages with continuation", () =>
+  it.effect("forwards pagination and returns bounded text pages with continuation", () =>
     Effect.gen(function* () {
-      textPageInputs.length = 0
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = FileSystem.MAX_READ_BYTES + 1
-      real = "/project/large.txt"
-      afterApproval = () => {}
-      sample = new TextEncoder().encode("hello")
-      readFailure = undefined
+      readResult = new FileSystem.TextPage({
+        type: "text-page",
+        content: "hello",
+        mime: "text/plain",
+        offset: 2,
+        truncated: true,
+        next: 3,
+      })
       const registry = yield* ToolRegistry.Service
 
       expect(
@@ -644,66 +423,13 @@ describe("ReadTool", () => {
         type: "json",
         value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
       })
-      expect(textPageInputs).toEqual([{ offset: 2, limit: 1 }])
-    }),
-  )
-
-  it.effect("preserves safe read limit errors", () =>
-    Effect.gen(function* () {
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = 5
-      real = "/project/changed.txt"
-      afterApproval = () => {}
-      sample = new TextEncoder().encode("hello")
-      readFailure = new FileSystem.ReadLimitError("changed.txt", FileSystem.MAX_READ_BYTES)
-      const registry = yield* ToolRegistry.Service
-
-      expect(
-        yield* registry.execute({
-          sessionID,
-          call: { type: "tool-call", id: "call-read-limit", name: "read", input: { path: "changed.txt" } },
-        }),
-      ).toEqual({
-        type: "error",
-        value: `File exceeds ${FileSystem.MAX_READ_BYTES} byte read limit: changed.txt`,
-      })
-    }),
-  )
-
-  it.effect("preserves binary errors discovered after the sample", () =>
-    Effect.gen(function* () {
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = FileSystem.MAX_READ_BYTES + 1
-      real = "/project/late-binary"
-      afterApproval = () => {}
-      sample = new TextEncoder().encode("text prefix")
-      readFailure = new FileSystem.BinaryFileError("late-binary")
-      const registry = yield* ToolRegistry.Service
-
-      expect(
-        yield* registry.execute({
-          sessionID,
-          call: { type: "tool-call", id: "call-late-binary", name: "read", input: { path: "late-binary" } },
-        }),
-      ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
+      expect(readCalls).toEqual([{ input: { path: "large.txt", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }])
     }),
   )
 
   it.effect("rejects unsupported binary discovered by a direct read", () =>
     Effect.gen(function* () {
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = 5
-      real = "/project/late-binary"
-      afterApproval = () => {}
-      sample = new TextEncoder().encode("text prefix")
-      readFailure = undefined
-      readContent = new FileSystem.BinaryContent({
+      readResult = new FileSystem.BinaryContent({
         type: "binary",
         content: "AAECAw==",
         encoding: "base64",
@@ -719,29 +445,4 @@ describe("ReadTool", () => {
       ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
     }),
   )
-
-  it.effect("does not read when the file changes after permission approval", () =>
-    Effect.gen(function* () {
-      assertions.length = 0
-      reads.length = 0
-      allow = true
-      resolveFailure = undefined
-      listResolveFailure = new Error("not a directory")
-      size = 5
-      real = "/project/README.md"
-      sample = new TextEncoder().encode("hello")
-      readFailure = undefined
-      afterApproval = () => {
-        real = "/outside/README.md"
-      }
-      const registry = yield* ToolRegistry.Service
-      expect(
-        yield* registry.execute({
-          sessionID,
-          call: { type: "tool-call", id: "call-swapped", name: "read", input: { path: "README.md" } },
-        }),
-      ).toEqual({ type: "error", value: "Unable to read README.md" })
-      expect(reads).toEqual([])
-    }),
-  )
 })