refactor(core): simplify location filesystem (#31545)
This commit is contained in:
parent
0777cf1ccf
commit
132ef57272
73 changed files with 1048 additions and 1416 deletions
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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", () =>
|
||||
|
|
|
|||
|
|
@ -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 }])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) }))),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue