| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402 |
- import { describe, expect } from "bun:test"
- import { Effect, Layer } from "effect"
- import { FileSystem } from "@opencode-ai/core/filesystem"
- 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 { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
- import { RelativePath } from "@opencode-ai/core/schema"
- import { testEffect } from "./lib/effect"
- const assertions: PermissionV2.AssertInput[] = []
- const reads: FileSystem.ReadInput[] = []
- const textPageInputs: FileSystem.TextPageInput[] = []
- const pages: FileSystem.ListTarget[] = []
- const pageInputs: Pick<FileSystem.ListPageInput, "offset" | "limit">[] = []
- let resolvedInput: FileSystem.ReadInput | undefined
- 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 = () => {}
- const resourceReads: ToolOutputStore.ReadInput[] = []
- const filesystem = Layer.succeed(
- FileSystem.Service,
- FileSystem.Service.of({
- read: () => Effect.die("unused"),
- resolveReadPath: (input) =>
- resolveFailure === undefined
- ? Effect.succeed({
- type: "file" as const,
- target: new FileSystem.ReadTarget({
- real,
- resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
- 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 ? "README.md" : `${input.reference}:README.md`,
- size,
- dev: 1,
- }),
- )
- : Effect.die(resolveFailure),
- ),
- ),
- readResolved: () =>
- Effect.sync(() => {
- reads.push({ path: RelativePath.make("README.md") })
- return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
- }),
- readTextPageResolved: (_target, page = {}) =>
- 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,
- })
- }),
- 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),
- listResolved: () => Effect.die("unused"),
- listPage: () => Effect.die("unused"),
- listPageResolved: (target, page = {}) =>
- Effect.sync(() => {
- pages.push(target)
- pageInputs.push(page)
- return new FileSystem.ListPage({ entries: [], truncated: false })
- }),
- find: () => Effect.die("unused"),
- grep: () => Effect.die("unused"),
- isIgnored: () => false,
- }),
- )
- let allow = true
- const permission = Layer.succeed(
- PermissionV2.Service,
- PermissionV2.Service.of({
- 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"),
- get: () => Effect.die("unused"),
- forSession: () => Effect.die("unused"),
- list: () => Effect.die("unused"),
- }),
- )
- const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
- const resources = Layer.succeed(
- ToolOutputStore.Service,
- ToolOutputStore.Service.of({
- limits: () => Effect.die("unused"),
- write: () => Effect.die("unused"),
- truncate: () => Effect.die("unused"),
- cleanup: () => Effect.die("unused"),
- read: (input) =>
- Effect.sync(() => {
- resourceReads.push(input)
- return new ToolOutputStore.Page({
- resource: new ToolOutputStore.Resource({ uri: input.uri, mime: "text/plain", size: 5 }),
- content: "hello",
- offset: input.offset ?? 0,
- truncated: false,
- })
- }),
- }),
- )
- const read = ReadTool.layer.pipe(
- Layer.provide(registry),
- Layer.provide(filesystem),
- Layer.provide(permission),
- Layer.provide(resources),
- )
- const it = testEffect(Layer.mergeAll(registry, filesystem, permission, resources, read))
- const sessionID = SessionV2.ID.make("ses_read_tool_test")
- describe("ReadTool", () => {
- 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 = () => {}
- resolvedInput = undefined
- const registry = yield* ToolRegistry.Service
- expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
- expect(
- yield* registry.execute({
- sessionID,
- call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
- }),
- ).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") }])
- }),
- )
- 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(
- yield* registry.execute({
- sessionID,
- 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([])
- }),
- )
- it.effect("reads an opaque managed resource without treating it as a path", () =>
- Effect.gen(function* () {
- resourceReads.length = 0
- assertions.length = 0
- const registry = yield* ToolRegistry.Service
- expect(
- yield* registry.execute({
- sessionID,
- call: {
- type: "tool-call",
- id: "call-read-resource",
- name: "read",
- input: { resource: "tool-output://opaque", offset: 2, limit: 10 },
- },
- }),
- ).toEqual({
- type: "json",
- value: {
- resource: { uri: "tool-output://opaque", mime: "text/plain", size: 5 },
- content: "hello",
- offset: 2,
- truncated: false,
- },
- })
- expect(resourceReads).toEqual([{ sessionID, uri: "tool-output://opaque", offset: 2, limit: 10 }])
- expect(assertions).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 = () => {}
- const registry = yield* ToolRegistry.Service
- expect(
- yield* registry.execute({
- sessionID,
- call: {
- type: "tool-call",
- id: "call-read-directory",
- name: "read",
- input: { path: "src", offset: 2, limit: 10 },
- },
- }),
- ).toEqual({ type: "json", value: { entries: [], truncated: false } })
- expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
- expect(pageInputs).toEqual([{ 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 = () => {}
- const registry = yield* ToolRegistry.Service
- expect(
- yield* registry.execute({
- sessionID,
- 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([])
- }),
- )
- 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({
- sessionID,
- call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
- })
- expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
- }),
- )
- 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,
- call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
- }),
- ).toEqual({ type: "error", value: "Unable to read missing.txt" })
- expect(reads).toEqual([])
- }),
- )
- it.effect("reads large UTF-8 text files as bounded 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 = () => {}
- const registry = yield* ToolRegistry.Service
- expect(
- yield* registry.execute({
- sessionID,
- call: {
- type: "tool-call",
- id: "call-large",
- name: "read",
- input: { path: "large.txt", offset: 2, limit: 1 },
- },
- }),
- ).toEqual({
- 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("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"
- 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([])
- }),
- )
- })
|