fix(core): route hosted read tool through the workspace environment
This commit is contained in:
@@ -1551,9 +1551,7 @@ export interface WebsearchApi<E = never> {
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint29_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint29_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||
|
||||
|
||||
@@ -472,7 +472,7 @@ export function make(options: ClientOptions) {
|
||||
location: input?.["location"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -4632,9 +4632,7 @@ export type WebsearchQueryOutput = {
|
||||
}
|
||||
|
||||
export type ConfigGetInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
export type ConfigGetOutput = Array<ConfigEntry>
|
||||
|
||||
@@ -5,6 +5,9 @@ export default {
|
||||
id: "20260806150823_remove_workspace",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// Legacy control-plane workspace references would otherwise route these
|
||||
// sessions to hosted graphs whose workspace rows no longer exist.
|
||||
yield* tx.run(`UPDATE \`session_v2\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||
yield* tx.run(`DROP TABLE \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -135,6 +135,7 @@ export function buildLocationServiceMap(
|
||||
[FileSystem.node, FileSystem.hostedNode],
|
||||
[LocationMutation.node, LocationMutation.hostedNode],
|
||||
[FileMutation.node, FileMutation.hostedNode],
|
||||
[ReadToolFileSystem.node, ReadToolFileSystem.hostedNode],
|
||||
[Shell.node, Shell.hostedNode],
|
||||
]
|
||||
: [[Location.node, Location.boundNode(ref)]],
|
||||
|
||||
@@ -25,11 +25,7 @@ const LocationInput = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
export const Input = LocationInput
|
||||
const Output = Schema.Union([
|
||||
ReadToolFileSystem.FileContent,
|
||||
ReadToolFileSystem.TextPage,
|
||||
ReadToolFileSystem.ListPage,
|
||||
])
|
||||
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
@@ -43,99 +39,100 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const resource = target.resource
|
||||
const absolute = AbsolutePath.make(target.canonical)
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resource],
|
||||
save: ["*"],
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const type = yield* reader.inspect(absolute).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
|
||||
const resource = target.resource
|
||||
const absolute = AbsolutePath.make(target.canonical)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const type = yield* reader
|
||||
.inspect(absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)))
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
// Discovery walks the host filesystem; hosted instruction
|
||||
// discovery needs an environment-backed walk first.
|
||||
if (location.workspaceID !== undefined) return
|
||||
const resolved = yield* fs.resolve(target.canonical)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
)
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
const resolved = yield* fs.resolve(target.canonical)
|
||||
const root = yield* fs.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.catchDefect(() => Effect.void),
|
||||
)
|
||||
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
return content
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelContent(input.path, input.offset, output),
|
||||
})),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof ToolFailure) return error
|
||||
const message =
|
||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
|
||||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||
error instanceof ReadToolFileSystem.PathKindError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message, error })
|
||||
}),
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.catchDefect(() => Effect.void),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelContent(input.path, input.offset, output),
|
||||
})),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof ToolFailure) return error
|
||||
const message =
|
||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
|
||||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||
error instanceof ReadToolFileSystem.PathKindError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message, error })
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ export * as ReadToolFileSystem from "./read-filesystem"
|
||||
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Option, Schema, Scope } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||
import { WorkspaceEnvironment } from "../workspace/environment"
|
||||
|
||||
export const MAX_READ_LINES = 2_000
|
||||
export const MAX_READ_BYTES = 50 * 1024
|
||||
@@ -109,6 +111,25 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||
|
||||
/** The filesystem surface `read` consumes; FSUtil satisfies it structurally. */
|
||||
export interface ReadSource {
|
||||
readonly realPath: (path: string) => Effect.Effect<string, FSUtil.Error>
|
||||
readonly open: (
|
||||
path: string,
|
||||
options: { readonly flag: "r" },
|
||||
) => Effect.Effect<
|
||||
{
|
||||
readonly stat: Effect.Effect<
|
||||
{ readonly type: WorkspaceEnvironment.FileInfo["type"]; readonly size: bigint | number },
|
||||
FSUtil.Error
|
||||
>
|
||||
readonly readAlloc: (bytes: number) => Effect.Effect<Option.Option<Uint8Array>, FSUtil.Error>
|
||||
},
|
||||
FSUtil.Error,
|
||||
Scope.Scope
|
||||
>
|
||||
}
|
||||
|
||||
const extensions = new Set([
|
||||
".zip",
|
||||
".tar",
|
||||
@@ -169,7 +190,14 @@ const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array)
|
||||
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
|
||||
|
||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (
|
||||
fs: {
|
||||
readonly stat: (
|
||||
path: string,
|
||||
) => Effect.Effect<{ readonly type: WorkspaceEnvironment.FileInfo["type"] }, FSUtil.Error>
|
||||
},
|
||||
input: string,
|
||||
) {
|
||||
const info = yield* fs.stat(input)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
|
||||
@@ -177,7 +205,7 @@ export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Inter
|
||||
})
|
||||
|
||||
export const read = Effect.fn("ReadTool.read")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
fs: ReadSource,
|
||||
input: string,
|
||||
resource: string,
|
||||
page: PageInput = {},
|
||||
@@ -353,9 +381,16 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
||||
}),
|
||||
{ concurrency: 16 },
|
||||
)
|
||||
const visible = entries
|
||||
.filter((item): item is FileSystem.Entry => item !== undefined)
|
||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
|
||||
const visible = entries.filter((item): item is FileSystem.Entry => item !== undefined)
|
||||
return pageEntries(visible, { offset, limit })
|
||||
})
|
||||
|
||||
const pageEntries = (entries: FileSystem.Entry[], page: PageInput) => {
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const visible = [...entries].sort((a, b) =>
|
||||
a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1,
|
||||
)
|
||||
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
||||
const truncated = offset - 1 + selected.length < visible.length
|
||||
return new ListPage({
|
||||
@@ -364,7 +399,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
||||
truncated,
|
||||
...(truncated ? { next: offset + selected.length } : {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -379,3 +414,101 @@ const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
||||
// Reads through WorkspaceEnvironment.Files so hosted sessions never touch the
|
||||
// host filesystem. The environment has no ranged read, so file reads transfer
|
||||
// the whole file once and page in memory.
|
||||
const hostedLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const env = yield* WorkspaceEnvironment.Service
|
||||
|
||||
// Absence surfaces as a platform NotFound so the read tool's missing-file
|
||||
// handling stays backend-agnostic; other failures become filesystem errors.
|
||||
const mapEnv =
|
||||
(method: string) =>
|
||||
(error: WorkspaceEnvironment.Error | WorkspaceEnvironment.NotFoundError): FSUtil.Error =>
|
||||
error._tag === "WorkspaceEnvironment.NotFoundError"
|
||||
? systemError({ _tag: "NotFound", module: "FileSystem", method, pathOrDescriptor: error.path, cause: error })
|
||||
: new FSUtil.FileSystemError({ method, cause: error })
|
||||
|
||||
const source: ReadSource = {
|
||||
realPath: (target) => env.files.realPath(target).pipe(Effect.mapError(mapEnv("realPath"))),
|
||||
open: (target) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* env.files.stat(target).pipe(Effect.mapError(mapEnv("stat")))
|
||||
if (info.type !== "File")
|
||||
return {
|
||||
stat: Effect.succeed({ type: info.type, size: 0 }),
|
||||
readAlloc: () => Effect.succeed(Option.none<Uint8Array>()),
|
||||
}
|
||||
const bytes = yield* env.files.read(target).pipe(Effect.mapError(mapEnv("read")))
|
||||
let cursor = 0
|
||||
return {
|
||||
stat: Effect.succeed({ type: info.type, size: bytes.length }),
|
||||
readAlloc: (size: number) =>
|
||||
Effect.sync(() => {
|
||||
if (cursor >= bytes.length) return Option.none<Uint8Array>()
|
||||
const chunk = bytes.subarray(cursor, Math.min(cursor + size, bytes.length))
|
||||
cursor += chunk.length
|
||||
return Option.some(chunk)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
// Matches the local list semantics: symlinks resolve and escape-filter
|
||||
// against the listed directory. Non-symlink types come from the listing
|
||||
// itself, keeping provider round trips proportional to symlink count.
|
||||
const resolveSymlink = Effect.fnUntraced(function* (parent: string, name: string) {
|
||||
const target = yield* env.files
|
||||
.realPath(path.posix.join(parent, name))
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (target === undefined || !FSUtil.containsPosix(parent, target)) return undefined
|
||||
const info = yield* env.files.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (info?.type === "Directory") return "directory" as const
|
||||
if (info?.type === "File") return "file" as const
|
||||
return undefined
|
||||
})
|
||||
|
||||
const hostedList = Effect.fn("ReadTool.list")(function* (input: string, page: PageInput = {}) {
|
||||
const real = yield* source.realPath(input)
|
||||
const items = yield* env.files.list(real).pipe(Effect.mapError(mapEnv("list")))
|
||||
const entries = yield* Effect.forEach(
|
||||
items,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const type =
|
||||
item.type === "file" || item.type === "directory"
|
||||
? item.type
|
||||
: item.type === "symlink"
|
||||
? yield* resolveSymlink(real, item.name)
|
||||
: undefined
|
||||
if (!type) return
|
||||
return FileSystem.Entry.make({
|
||||
path: RelativePath.make(item.name + (type === "directory" ? "/" : "")),
|
||||
type,
|
||||
})
|
||||
}),
|
||||
{ concurrency: 4 },
|
||||
)
|
||||
return pageEntries(
|
||||
entries.filter((item): item is FileSystem.Entry => item !== undefined),
|
||||
page,
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
inspect: (target) =>
|
||||
inspect({ stat: (statTarget) => env.files.stat(statTarget).pipe(Effect.mapError(mapEnv("stat"))) }, target),
|
||||
read: (target, resource, page) => read(source, target, resource, page),
|
||||
list: (target, page) => hostedList(target, page),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const hostedNode = makeLocationNode({
|
||||
service: Service,
|
||||
layer: hostedLayer,
|
||||
deps: [WorkspaceEnvironment.node],
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -15,10 +17,13 @@ const memory = memoryEnvironment({
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.hostedNode, FileMutation.hostedNode]), [
|
||||
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
|
||||
[Location.node, hostedLocationLayer()],
|
||||
]),
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([LocationMutation.hostedNode, FileMutation.hostedNode, ReadToolFileSystem.hostedNode]),
|
||||
[
|
||||
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
|
||||
[Location.node, hostedLocationLayer()],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("hosted mutation", () => {
|
||||
@@ -64,4 +69,27 @@ describe("hosted mutation", () => {
|
||||
expect(memory.contents("/workspace/notes/todo.md")).toBe("- shipped\n")
|
||||
}),
|
||||
)
|
||||
|
||||
// The reader must consume the environment, never the host filesystem:
|
||||
// these paths exist only in the memory store.
|
||||
it.effect("reads files and directories through the environment", () =>
|
||||
Effect.gen(function* () {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
|
||||
expect(yield* reader.inspect(AbsolutePath.make("/workspace/README.md"))).toBe("file")
|
||||
expect(yield* reader.inspect(AbsolutePath.make("/workspace/src"))).toBe("directory")
|
||||
|
||||
const content = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md")
|
||||
expect(content).toMatchObject({ type: "file", content: "# hello\n", encoding: "utf8" })
|
||||
|
||||
const paged = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md", { limit: 1 })
|
||||
expect(paged).toMatchObject({ type: "text-page", content: "# hello", offset: 1, truncated: false })
|
||||
|
||||
const listed = yield* reader.list(AbsolutePath.make("/workspace/src"))
|
||||
expect(listed.entries.map((entry) => String(entry.path))).toEqual(["index.ts"])
|
||||
|
||||
const missing = yield* reader.inspect(AbsolutePath.make("/workspace/nope.txt")).pipe(Effect.flip)
|
||||
expect(missing).toMatchObject({ _tag: "PlatformError", reason: { _tag: "NotFound" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -134,6 +134,7 @@ describe("hosted workspace session", () => {
|
||||
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
|
||||
TestLLM.tool("call-glob", "glob", { pattern: "*.txt" }),
|
||||
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
|
||||
TestLLM.tool("call-read", "read", { path: "from-patch.txt" }),
|
||||
TestLLM.text("done", "text-1"),
|
||||
)
|
||||
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
|
||||
@@ -153,6 +154,7 @@ describe("hosted workspace session", () => {
|
||||
expect(advertised).toContain("patch")
|
||||
expect(advertised).toContain("glob")
|
||||
expect(advertised).toContain("grep")
|
||||
expect(advertised).toContain("read")
|
||||
expect(advertised).not.toContain("edit")
|
||||
expect(advertised).not.toContain("write")
|
||||
|
||||
@@ -174,6 +176,9 @@ describe("hosted workspace session", () => {
|
||||
expect(assistants.at(3)).toMatchObject({
|
||||
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
|
||||
})
|
||||
expect(assistants.at(4)).toMatchObject({
|
||||
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
|
||||
})
|
||||
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
|
||||
|
||||
expect(connects.count).toBe(1)
|
||||
|
||||
@@ -153,6 +153,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
location: Location.Ref.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: Session.Info }),
|
||||
error: InvalidRequestError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.create",
|
||||
|
||||
@@ -81,7 +81,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
model: ctx.payload.model,
|
||||
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
.pipe(
|
||||
Effect.catchTag("Workspace.NotFoundError", (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: `Workspace not found: ${error.id}` })),
|
||||
),
|
||||
Effect.catchTag("Session.WorkspaceDirectoryError", (error) =>
|
||||
Effect.fail(new InvalidRequestError({ message: error.message })),
|
||||
),
|
||||
Effect.orDie,
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -98,6 +98,9 @@ describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
|
||||
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
|
||||
TestLLM.tool("call-glob", "glob", { pattern: "*.txt" }),
|
||||
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
|
||||
// Read must go through the sandbox filesystem: the host has no
|
||||
// /workspace/from-patch.txt, so a host-backed reader would fail.
|
||||
TestLLM.tool("call-read", "read", { path: "from-patch.txt" }),
|
||||
TestLLM.text("done", "text-1"),
|
||||
)
|
||||
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
|
||||
@@ -109,6 +112,7 @@ describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
|
||||
expect(advertised).toContain("patch")
|
||||
expect(advertised).toContain("glob")
|
||||
expect(advertised).toContain("grep")
|
||||
expect(advertised).toContain("read")
|
||||
expect(advertised).not.toContain("edit")
|
||||
expect(advertised).not.toContain("write")
|
||||
|
||||
@@ -127,6 +131,9 @@ describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
|
||||
expect(assistants.at(3)).toMatchObject({
|
||||
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
|
||||
})
|
||||
expect(assistants.at(4)).toMatchObject({
|
||||
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
|
||||
})
|
||||
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
|
||||
|
||||
// Both tools executed inside the sandbox: read the files back through
|
||||
|
||||
Reference in New Issue
Block a user