refactor(core): simplify location filesystem (#31545)

This commit is contained in:
Dax 2026-06-09 14:28:45 -04:00 committed by GitHub
commit 132ef57272
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 1048 additions and 1416 deletions

View file

@ -63,7 +63,7 @@ describe("ApplicationTools", () => {
type: "content",
value: [
{ type: "text", text: "ONCE" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
],
})
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
@ -132,14 +132,14 @@ describe("ApplicationTools", () => {
type: "content",
value: [
{ type: "text", text: "HELLO" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
],
},
output: {
structured: { answer: "HELLO" },
content: [
{ type: "text", text: "HELLO" },
{ type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
],
},
})

View file

@ -48,7 +48,7 @@ describe("file.search", () => {
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
expect(results).toContain("README.md")
expect(results).toContainEqual({ path: "README.md", type: "file" })
}),
)
@ -63,9 +63,9 @@ describe("file.search", () => {
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" })
expect(results).toContain("README.md")
expect(results).toContain("src/")
expect(results).not.toContain("")
expect(results).toContainEqual({ path: "README.md", type: "file" })
expect(results).toContainEqual({ path: "src/", type: "directory" })
expect(results.map((item) => item.path)).not.toContain("")
}),
)
@ -80,7 +80,10 @@ describe("file.search", () => {
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "", limit: 10 })
expect(results?.slice(0, 2)).toEqual(["a.ts", "src/longer-name.ts"])
expect(results.slice(0, 2)).toEqual([
{ path: "a.ts", type: "file" },
{ path: "src/longer-name.ts", type: "file" },
])
}),
)
@ -163,7 +166,7 @@ describe("file.search", () => {
const search = yield* Search.Service
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
expect(results).toContain("alpha-target-two.ts")
expect(results).toContainEqual({ path: "alpha-target-two.ts", type: "file" })
// open() records the query->file association in fff's history db via the
// live picker. It must resolve a remembered file and run without error.

View file

@ -1,27 +1,25 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Search } from "@opencode-ai/core/filesystem/search"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
function provide(directory: string, filesystem = FSUtil.defaultLayer, data = Global.Path.data) {
function provide(directory: string, search = Search.defaultLayer) {
return Effect.provide(
FileSystem.layer.pipe(
Layer.provide(
Layer.mergeAll(
filesystem,
Ripgrep.defaultLayer,
FSUtil.defaultLayer,
search,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Global.layerWith({ data }),
),
),
),
@ -36,411 +34,121 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("FileSystem", () => {
it.live("accepts generated managed output paths and rejects other absolute paths", () =>
withTmp((directory) => {
const worktree = directory
const data = path.join(directory, "data")
return Effect.gen(function* () {
const managed = path.join(data, "tool-output")
const output = path.join(managed, "tool_123")
const unrelated = path.join(directory, "secret.txt")
yield* Effect.promise(() => fs.mkdir(managed, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(output, "failure here"))
yield* Effect.promise(() => fs.writeFile(unrelated, "secret"))
const service = yield* FileSystem.Service
expect(yield* service.read({ path: output })).toMatchObject({ type: "text", content: "failure here" })
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
}).pipe(provide(worktree, FSUtil.defaultLayer, data))
}),
)
it.live("reads text and binary files", () =>
it.live("reads complete text and binary files", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
const text = Array.from({ length: 3_000 }, (_, index) => `line-${index + 1}`).join("\n")
yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), text))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
const service = yield* FileSystem.Service
expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
type: "text",
content: "hello",
const textContent = yield* service.read({ path: RelativePath.make("large.txt") })
expect(textContent).toEqual({
uri: textContent.uri,
name: "large.txt",
content: text,
encoding: "utf8",
mime: "text/plain",
})
expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({
type: "binary",
expect(fileURLToPath(textContent.uri)).toBe(path.join(directory, "large.txt"))
const binaryContent = yield* service.read({ path: RelativePath.make("data.bin") })
expect(binaryContent).toEqual({
uri: binaryContent.uri,
name: "data.bin",
content: "AAEC",
encoding: "base64",
mime: "application/octet-stream",
})
expect(Exit.isFailure(yield* service.readTool({ path: RelativePath.make("data.bin") }).pipe(Effect.exit))).toBe(
true,
)
expect(fileURLToPath(binaryContent.uri)).toBe(path.join(directory, "data.bin"))
}).pipe(provide(directory)),
),
)
it.live("pages large UTF-8 text files by line with continuation", () =>
withTmp((directory) =>
Effect.gen(function* () {
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 input = { path: RelativePath.make("large.txt") }
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.readTool(input, { offset: next, limit: 1 })).toEqual({
type: "text-page",
content: lines[next - 1],
mime: "text/plain",
offset: next,
truncated: true,
next: next + 1,
})
expect(yield* service.readTool(input, { offset: 30 })).toEqual({
type: "text-page",
content: lines[29],
mime: "text/plain",
offset: 30,
truncated: false,
})
}).pipe(provide(directory)),
),
)
it.live("rejects paged text when a late NUL appears after the requested page", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "late-binary.txt")
yield* Effect.promise(() =>
fs.writeFile(
file,
Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0])]),
),
)
const service = yield* FileSystem.Service
expect(
Exit.isFailure(
yield* service.readTool({ path: RelativePath.make("late-binary.txt") }, { limit: 1 }).pipe(Effect.exit),
),
).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("rejects paged text when invalid UTF-8 appears near EOF", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "invalid-utf8.txt")
yield* Effect.promise(() =>
fs.writeFile(
file,
Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0xc3, 0x28])]),
),
)
const service = yield* FileSystem.Service
expect(
Exit.isFailure(
yield* service.readTool({ path: RelativePath.make("invalid-utf8.txt") }, { limit: 1 }).pipe(Effect.exit),
),
).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("rejects PDFs for direct, large, and paged reads", () =>
withTmp((directory) =>
Effect.gen(function* () {
const small = path.join(directory, "small.pdf")
const large = path.join(directory, "large.pdf")
yield* Effect.promise(() => fs.writeFile(small, "%PDF-1.7\nsmall"))
yield* Effect.promise(() =>
fs.writeFile(large, Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(80_000)])),
)
const service = yield* FileSystem.Service
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)),
),
)
it.live("rejects signature-bearing media beyond the ingestion cap before loading", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "huge.png")
yield* Effect.promise(async () => {
const handle = await fs.open(file, "w")
try {
await handle.write(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 0, 8, 0)
await handle.truncate(FileSystem.MAX_MEDIA_INGEST_BYTES + 1)
} finally {
await handle.close()
}
})
const service = yield* FileSystem.Service
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("closes descriptors after successful and failed reads", () =>
withTmp((directory) => {
let active = 0
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
open: (target, options) =>
Effect.acquireRelease(
service.open(target, options).pipe(Effect.tap(() => Effect.sync(() => active++))),
() => Effect.sync(() => active--),
),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
const text = path.join(directory, "text.txt")
const binary = path.join(directory, "binary.pdf")
yield* Effect.promise(() => fs.writeFile(text, "hello"))
yield* Effect.promise(() => fs.writeFile(binary, "%PDF-1.7"))
const service = yield* FileSystem.Service
const before =
process.platform === "win32"
? undefined
: yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
for (let index = 0; index < 50; index++) {
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) {
const after = yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
expect(after).toBeLessThanOrEqual(before + 2)
}
yield* Effect.promise(() => fs.rename(text, text + ".moved"))
yield* Effect.promise(() => fs.rename(binary, binary + ".moved"))
}).pipe(provide(directory, filesystem))
}),
)
it.live("lists direct children with relative paths and resolved URIs", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const service = yield* FileSystem.Service
const entries = yield* service.list()
const entries = yield* (yield* FileSystem.Service).list()
expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
{
path: RelativePath.make("src"),
type: "directory",
mime: "application/x-directory",
},
{
path: RelativePath.make("README.md"),
type: "file",
mime: "text/markdown",
},
{ path: RelativePath.make("src"), type: "directory", mime: "application/x-directory" },
{ path: RelativePath.make("README.md"), type: "file", mime: "text/markdown" },
])
expect(
yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
).toEqual(
yield* Effect.promise(() =>
Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
),
expect(yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri)))))).toEqual(
yield* Effect.promise(() => Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))])),
)
}).pipe(provide(directory)),
),
)
it.live("lists stable bounded pages", () =>
it.live("rejects lexical and symlink escapes", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "README.md"), "# Test")
})
const service = yield* FileSystem.Service
expect(yield* service.listPage({ limit: 1 })).toMatchObject({
entries: [{ path: "src", type: "directory" }],
truncated: true,
next: 2,
})
expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
entries: [{ path: "README.md", type: "file" }],
truncated: false,
})
expect((yield* service.resolveList()).resource).toBe(".")
}).pipe(provide(directory)),
),
)
it.live("materializes only the selected direct children for a page", () =>
withTmp((directory) => {
const realPaths: string[] = []
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
realPath: (target) =>
Effect.sync(() => realPaths.push(target)).pipe(Effect.andThen(service.realPath(target))),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "alpha.txt"), "alpha")
await fs.writeFile(path.join(directory, "beta.txt"), "beta")
})
const service = yield* FileSystem.Service
expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
entries: [{ path: "alpha.txt", type: "file" }],
truncated: true,
next: 3,
})
expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
}).pipe(provide(directory, filesystem))
}),
)
it.live("materializes selected page entries with at most 16 concurrent real path lookups", () =>
withTmp((directory) => {
let active = 0
let maximum = 0
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
realPath: (target) =>
target === directory
? service.realPath(target)
: Effect.acquireUseRelease(
Effect.sync(() => {
active++
maximum = Math.max(maximum, active)
}),
() => Effect.sleep("10 millis").pipe(Effect.andThen(service.realPath(target))),
() => Effect.sync(() => active--),
),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all(Array.from({ length: 32 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), ""))),
)
const service = yield* FileSystem.Service
expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
expect(maximum).toBe(16)
}).pipe(provide(directory, filesystem))
}),
)
it.live("caps direct list page service calls at 2000 entries", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all(
Array.from({ length: 2_001 }, (_, index) =>
fs.writeFile(path.join(directory, `${index.toString().padStart(4, "0")}.txt`), ""),
),
),
)
const service = yield* FileSystem.Service
const target = yield* service.resolveList()
expect((yield* service.listPageResolved(target, { limit: 2_001 })).entries).toHaveLength(2_000)
}).pipe(provide(directory)),
),
)
test("rejects page limits over 2000", () => {
const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
expect(() => decode({ limit: 2_001 })).toThrow()
})
it.live("rejects escaping list paths and omits escaping symlink children", () =>
withTmp((directory) =>
Effect.gen(function* () {
expect(Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit))).toBe(true)
if (process.platform === "win32") return
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.writeFile(path.join(outside, "secret.txt"), "secret")
await fs.symlink(outside, path.join(directory, "escape"))
})
const service = yield* FileSystem.Service
expect(
Exit.isFailure(yield* service.listPage({ path: RelativePath.make("../outside") }).pipe(Effect.exit)),
).toBe(true)
expect((yield* service.listPage()).entries).toEqual([])
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
const outside = `${directory}-outside.txt`
yield* Effect.promise(() => fs.writeFile(outside, "outside"))
yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "link.txt")))
expect(Exit.isFailure(yield* service.read({ path: RelativePath.make("link.txt") }).pipe(Effect.exit))).toBe(true)
yield* Effect.promise(() => fs.rm(outside, { force: true }))
}).pipe(provide(directory)),
),
)
it.live("paginates visible entries after omitting escaping symlink children", () =>
it.live("finds and greps files", () =>
withTmp((directory) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "a-escape"))
await fs.writeFile(path.join(directory, "b-visible.txt"), "visible")
})
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "src", "index.ts"), "const needle = true\n"))
const service = yield* FileSystem.Service
expect(yield* service.listPage({ limit: 1 })).toMatchObject({
entries: [{ path: "b-visible.txt", type: "file" }],
truncated: false,
})
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)),
expect((yield* service.find({ query: "index", type: "file" })).map((item) => item.path)).toEqual([
RelativePath.make(path.join("src", "index.ts")),
])
expect(yield* service.grep({ pattern: "needle" })).toMatchObject([
{ path: RelativePath.make(path.join("src", "index.ts")), line: 1, offset: 0 },
])
}).pipe(
provide(
directory,
Layer.effect(
Search.Service,
Effect.gen(function* () {
const search = yield* Search.Service
return Search.Service.of({
...search,
file: () => Effect.succeed([{ path: path.join("src", "index.ts"), type: "file" }]),
})
}),
).pipe(Layer.provide(Search.defaultLayer)),
),
),
),
)
it.live("rejects paths outside the location", () =>
it.live("uses the type supplied by Search file results", () =>
withTmp((directory) =>
Effect.gen(function* () {
const service = yield* FileSystem.Service
expect(
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
).toBe(true)
}).pipe(provide(directory)),
yield* Effect.promise(() => fs.writeFile(path.join(directory, "selected.ts"), "export {}\n"))
expect((yield* (yield* FileSystem.Service).find({ query: "ignored", limit: 1 }))[0]).toMatchObject({
path: RelativePath.make("selected.ts"),
type: "directory",
mime: "application/x-directory",
})
}).pipe(
provide(
directory,
Layer.effect(
Search.Service,
Effect.gen(function* () {
const search = yield* Search.Service
return Search.Service.of({
...search,
file: () => Effect.succeed([{ path: "selected.ts", type: "directory" }]),
})
}),
).pipe(Layer.provide(Search.defaultLayer)),
),
),
),
)
})

View file

@ -5,6 +5,7 @@ import { Cause, 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"
import { Search } from "@opencode-ai/core/filesystem/search"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { AppProcess } from "@opencode-ai/core/process"
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
@ -19,6 +20,7 @@ function provide(directory: string, data = Global.Path.data) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
Search.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Global.layerWith({ data }),

View file

@ -7,7 +7,7 @@ test("compaction describes tool media without embedding base64", () => {
{ type: "text", text: "Image read successfully" },
{
type: "file",
source: { type: "data", data: base64 },
uri: `data:image/png;base64,${base64}`,
mime: "image/png",
name: "pixel.png",
},

View file

@ -7,7 +7,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
@ -150,13 +149,13 @@ Recent work
status: "completed",
input: { path: "README.md" },
content: [
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
new ToolOutput.FileContent({
{ type: "text", text: "Hello" },
{
type: "file",
source: { type: "data", data: "aGVsbG8=" },
uri: "data:image/png;base64,aGVsbG8=",
mime: "image/png",
name: "hello.png",
}),
},
],
structured: {},
}),
@ -174,7 +173,7 @@ Recent work
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
content: [{ type: "text", text: "Found it" }],
structured: {},
}),
time: { created, completed: created },
@ -257,7 +256,7 @@ Recent work
type: "content",
value: [
{ type: "text", text: "Hello" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
],
},
},

View file

@ -57,14 +57,14 @@ const result = LLMEvent.toolResult({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: base64, filename: "pixel.png" },
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
],
},
output: {
structured: { type: "media", mime: "image/png" },
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", source: { type: "data", data: base64 }, mime: "image/png", name: "pixel.png" },
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
],
},
})
@ -83,7 +83,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
expect(success?.data).toMatchObject({
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", source: { type: "data", data: base64 }, mime: "image/png" },
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" },
],
})
})
@ -119,8 +119,8 @@ test("old success event data containing result still decodes", () => {
assistantMessageID: SessionMessage.ID.create(),
callID: "call-old",
structured: { type: "media", mime: "image/png" },
content: [{ type: "file", source: { type: "data", data: base64 }, mime: "image/png" }],
result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: base64 }] },
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
provider: { executed: false },
})
expect(decoded.result).toMatchObject({ type: "content" })

View file

@ -1687,7 +1687,7 @@ describe("SessionRunnerLLM", () => {
type: "content",
value: [
{ type: "text", text: "Hello" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
],
},
providerExecuted: true,
@ -1740,7 +1740,7 @@ describe("SessionRunnerLLM", () => {
structured: {},
content: [
{ type: "text", text: "Hello" },
{ type: "file", mime: "image/png", source: { type: "data", data: "aGVsbG8=" }, name: "hello.png" },
{ type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" },
],
},
},

View file

@ -14,7 +14,6 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
@ -24,7 +23,7 @@ const it = testEffect(Layer.mergeAll(database, events, projector))
const timestamp = DateTime.makeUnsafe(1)
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
const content = (text: string) => [ToolOutput.text({ type: "text", text })]
const content = (text: string) => [{ type: "text" as const, text }]
describe("Tool.Progress", () => {
it.effect("projects durable progress and keeps final settlements durable", () =>

View file

@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { RelativePath } from "@opencode-ai/core/schema"
@ -12,7 +11,6 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to
const sessionID = SessionV2.ID.make("ses_glob_tool_test")
const assertions: PermissionV2.AssertInput[] = []
const resolutions: FileSystem.ListInput[] = []
const searches: LocationSearch.FilesInput[] = []
let allow = true
let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
@ -32,34 +30,6 @@ const permission = Layer.succeed(
}),
)
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => Effect.die("unused"),
resolveReadPath: () => Effect.die("unused"),
readTool: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.sync(() => {
resolutions.push(input)
const relative = input.path ?? RelativePath.make(".")
return new FileSystem.RootTarget({
real: `/project/${relative}`,
root: "/project",
resource: relative,
type: "directory",
})
}),
resolveList: () => Effect.die("unused"),
listResolved: () => Effect.die("unused"),
listPage: () => Effect.die("unused"),
listPageResolved: () => Effect.die("unused"),
find: () => Effect.die("unused"),
grep: () => Effect.die("unused"),
isIgnored: () => false,
}),
)
const search = Layer.succeed(
LocationSearch.Service,
LocationSearch.Service.of({
@ -76,14 +46,12 @@ const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const glob = GlobTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(filesystem),
Layer.provide(search),
)
const it = testEffect(Layer.mergeAll(registry, permission, filesystem, search, glob))
const it = testEffect(Layer.mergeAll(registry, permission, search, glob))
const reset = () => {
assertions.length = 0
resolutions.length = 0
searches.length = 0
allow = true
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
@ -123,7 +91,6 @@ describe("GlobTool", () => {
metadata: { root: "src", path: "src", limit: 12 },
},
])
expect(resolutions).toEqual([{ path: RelativePath.make("src") }])
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
}),
)

View file

@ -5,6 +5,7 @@ import { Effect, Exit, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Search } from "@opencode-ai/core/filesystem/search"
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { PermissionV2 } from "@opencode-ai/core/permission"
@ -26,31 +27,6 @@ let allow = true
let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
let searchFailure: Ripgrep.InvalidPatternError | undefined
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => Effect.die("unused"),
resolveReadPath: () => Effect.die("unused"),
readTool: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.succeed(
new FileSystem.RootTarget({
real: `/project/${input.path ?? "."}`,
root: "/project",
resource: input.path ?? ".",
type: "directory",
}),
),
resolveList: () => Effect.die("unused"),
listResolved: () => Effect.die("unused"),
listPage: () => Effect.die("unused"),
listPageResolved: () => Effect.die("unused"),
find: () => Effect.die("unused"),
grep: () => Effect.die("unused"),
isIgnored: () => false,
}),
)
const search = Layer.succeed(
LocationSearch.Service,
LocationSearch.Service.of({
@ -80,11 +56,10 @@ const permission = Layer.succeed(
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const grep = GrepTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(search),
Layer.provide(permission),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, search, permission, grep))
const it = testEffect(Layer.mergeAll(registry, search, permission, grep))
const sessionID = SessionV2.ID.make("ses_grep_tool_test")
const execute = (input: Record<string, unknown>) =>
@ -117,6 +92,7 @@ function provideLive(directory: string) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
Search.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
)

View file

@ -90,7 +90,7 @@ describe("ToolOutputStore", () => {
toolCallID: "call-file",
output: {
structured: { caption: "pixel" },
content: [{ type: "file", source: { type: "data", data }, mime: "image/png", name: "pixel.png" }],
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
},
})
expect(result.outputPaths).toEqual([])
@ -98,7 +98,7 @@ describe("ToolOutputStore", () => {
expect(result.output.content).toHaveLength(1)
expect(result.output.content[0]).toEqual({
type: "file",
source: { type: "data", data },
uri: `data:image/png;base64,${data}`,
mime: "image/png",
name: "pixel.png",
})
@ -112,7 +112,7 @@ describe("ToolOutputStore", () => {
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
const media = {
type: "file" as const,
source: { type: "data" as const, data: "aGVsbG8=" },
uri: "data:image/png;base64,aGVsbG8=",
mime: "image/png",
name: "pixel.png",
}

View file

@ -3,60 +3,54 @@ import { Effect, Exit, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { Image } from "@opencode-ai/core/image"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { location } from "./fixture/location"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const assertions: PermissionV2.AssertInput[] = []
const readCalls: {
input: FileSystem.ReadInput & FileSystem.TextPageInput
page: FileSystem.TextPageInput
input: AbsolutePath
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: FileSystem.ListPageInput[] = []
const listCalls: ReadToolFileSystem.PageInput[] = []
let resolvedType: "file" | "directory" = "file"
let resolveFailure: unknown
let readResult: FileSystem.Content | FileSystem.TextPage = new FileSystem.TextContent({
type: "text",
let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = {
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
})
}
let readFailure: unknown
let configEntries: Config.Entry[] = []
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => Effect.die("unused"),
resolveReadPath: (input) =>
const reader = Layer.succeed(
ReadToolFileSystem.Service,
ReadToolFileSystem.Service.of({
inspect: () =>
resolveFailure === undefined
? Effect.succeed(
new FileSystem.ReadPath({
type: resolvedType,
resource: input.path,
}),
)
? Effect.succeed(resolvedType)
: Effect.die(resolveFailure),
readTool: (input, page = {}) => {
read: (input, _resource, page = {}) => {
readCalls.push({ input, page })
if (readFailure !== undefined) return Effect.die(readFailure)
return Effect.succeed(readResult)
},
resolveRoot: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveList: () => Effect.die("unused"),
listResolved: () => Effect.die("unused"),
listPage: (input = {}) =>
list: (_path, input = {}) =>
Effect.sync(() => {
listCalls.push(input)
return new FileSystem.ListPage({ entries: [], truncated: false })
return new ReadToolFileSystem.ListPage({ entries: [], truncated: false })
}),
listPageResolved: () => Effect.die("unused"),
find: () => Effect.die("unused"),
grep: () => Effect.die("unused"),
isIgnored: () => false,
}),
)
let allow = true
@ -77,27 +71,40 @@ const permission = Layer.succeed(
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
const image = Image.layer.pipe(Layer.provide(config))
const testFileSystem = Layer.effect(
FSUtil.Service,
FSUtil.Service.use((fs) =>
Effect.succeed(FSUtil.Service.of({ ...fs, realPath: (path) => Effect.succeed(path) })),
),
).pipe(Layer.provide(FSUtil.defaultLayer))
const infrastructure = Layer.mergeAll(
testFileSystem,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
Global.layerWith({ data: Global.Path.data }),
)
const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const read = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(image),
Layer.provide(infrastructure),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, image, read))
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
Layer.provide(infrastructure),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, filesystem, permission, config, unavailableImage, unavailableRead),
Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead),
)
const sessionID = SessionV2.ID.make("ses_read_tool_test")
@ -109,7 +116,13 @@ describe("ReadTool", () => {
allow = true
resolvedType = "file"
resolveFailure = undefined
readResult = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
readResult = {
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
}
readFailure = undefined
configEntries = []
})
@ -126,21 +139,31 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
).toEqual({
type: "json",
value: {
uri: "file:///README.md",
name: "README.md",
content: "hello",
encoding: "utf8",
mime: "text/plain",
},
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(readCalls).toEqual([{ input: { path: "README.md" }, page: {} }])
expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/README.md`), page: {} }])
}),
)
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///pixel.png",
name: "pixel.png",
content: png,
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
expect(
@ -153,20 +176,25 @@ describe("ReadTool", () => {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "pixel.png" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
],
})
expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }])
expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/pixel.png`), page: {} }])
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(settled.output?.structured).toMatchObject({
uri: "file:///pixel.png",
name: "pixel.png",
mime: "image/png",
encoding: "base64",
})
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", source: { type: "data", data: png } },
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
])
}),
)
@ -179,12 +207,13 @@ describe("ReadTool", () => {
const png = Buffer.from(source.get_bytes()).toString("base64")
source.free()
expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024)
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///large.png",
name: "large.png",
content: png,
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
@ -194,12 +223,17 @@ describe("ReadTool", () => {
})
expect(settled.outputPaths).toBeUndefined()
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(settled.output?.structured).toMatchObject({
uri: "file:///large.png",
name: "large.png",
mime: "image/png",
encoding: "base64",
})
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "large.png" },
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
],
})
}),
@ -208,12 +242,13 @@ describe("ReadTool", () => {
itWithoutResizer.effect("returns the original image when the resizer is unavailable", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///pixel.png",
name: "pixel.png",
content: png,
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
expect(
@ -224,19 +259,20 @@ describe("ReadTool", () => {
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "media", mediaType: "image/png", data: png }],
value: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }],
})
}),
)
it.effect("rejects invalid image data returned by the filesystem", () =>
Effect.gen(function* () {
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///truncated.png",
name: "truncated.png",
content: "iVBORw0KGgo=",
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
expect(
@ -255,12 +291,13 @@ 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()
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///wide.png",
name: "wide.png",
content: base64,
encoding: "base64",
mime: "image/png",
})
}
configEntries = [
new Config.Document({
type: "document",
@ -289,12 +326,13 @@ 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()
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///wide.png",
name: "wide.png",
content: base64,
encoding: "base64",
mime: "image/png",
})
}
configEntries = [
new Config.Document({
type: "document",
@ -313,9 +351,9 @@ describe("ReadTool", () => {
expect(result.type).toBe("content")
if (result.type !== "content") return
const media = result.value[1]
expect(media?.type).toBe("media")
if (media?.type !== "media") return
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.data, "base64"))
expect(media?.type).toBe("file")
if (media?.type !== "file") return
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.uri.split(",")[1] ?? "", "base64"))
expect(resized.get_width()).toBeLessThanOrEqual(4)
expect(resized.get_height()).toBeLessThanOrEqual(2_000)
resized.free()
@ -325,12 +363,13 @@ describe("ReadTool", () => {
it.effect("enforces max base64 bytes after resize attempts", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///pixel.png",
name: "pixel.png",
content: png,
encoding: "base64",
mime: "image/png",
})
}
configEntries = [
new Config.Document({
type: "document",
@ -356,12 +395,13 @@ describe("ReadTool", () => {
it.effect("returns supported image contents despite a misleading binary extension", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///pixel.bin",
name: "pixel.bin",
content: png,
encoding: "base64",
mime: "image/png",
})
}
const registry = yield* ToolRegistry.Service
expect(
@ -372,14 +412,14 @@ describe("ReadTool", () => {
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "media", mediaType: "image/png", filename: "pixel.bin" }],
value: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }],
})
}),
)
it.effect("preserves unexpected filesystem defects", () =>
Effect.gen(function* () {
readFailure = new FileSystem.BinaryFileError("archive.dat")
readFailure = new ReadToolFileSystem.BinaryFileError("archive.dat")
const registry = yield* ToolRegistry.Service
expect(
@ -397,7 +437,7 @@ describe("ReadTool", () => {
),
).toBe(true)
expect(readCalls).toEqual([
{ input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } },
{ input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } },
])
}),
)
@ -436,7 +476,7 @@ describe("ReadTool", () => {
}),
).toEqual({ type: "json", value: { entries: [], truncated: false } })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(listCalls).toEqual([{ path: "src", offset: 2, limit: 10 }])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}),
)
@ -478,7 +518,7 @@ describe("ReadTool", () => {
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
Effect.gen(function* () {
readResult = new FileSystem.TextPage({
readResult = new ReadToolFileSystem.TextPage({
type: "text-page",
content: "hello",
mime: "text/plain",
@ -503,18 +543,21 @@ describe("ReadTool", () => {
type: "json",
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
})
expect(readCalls).toEqual([{ input: { path: "large.txt", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }])
expect(readCalls).toEqual([
{ input: AbsolutePath.make(`${process.cwd()}/large.txt`), page: { offset: 2, limit: 1 } },
])
}),
)
it.effect("rejects unsupported binary discovered by a direct read", () =>
Effect.gen(function* () {
readResult = new FileSystem.BinaryContent({
type: "binary",
readResult = {
uri: "file:///late-binary",
name: "late-binary",
content: "AAECAw==",
encoding: "base64",
mime: "application/octet-stream",
})
}
const registry = yield* ToolRegistry.Service
expect(