From eaa196d574b95d51722169d1608a6137e45d2a1a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 6 Aug 2026 17:43:44 -0400 Subject: [PATCH] fix(core): route hosted read tool through the workspace environment --- packages/client/src/effect/api/api.ts | 4 +- .../client/src/promise/generated/client.ts | 2 +- .../client/src/promise/generated/types.ts | 4 +- .../20260806150823_remove_workspace.ts | 3 + packages/core/src/location-services.ts | 1 + packages/core/src/tool/plugin/read.ts | 177 +++++++++--------- packages/core/src/tool/read-filesystem.ts | 147 ++++++++++++++- packages/core/test/workspace-mutation.test.ts | 36 +++- .../core/test/workspace-session-e2e.test.ts | 5 + packages/protocol/src/groups/session.ts | 1 + packages/server/src/handlers/session.ts | 10 +- .../test/workspace-modal-session.test.ts | 7 + 12 files changed, 288 insertions(+), 109 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 0b8265b1dd..03dcab902a 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1551,9 +1551,7 @@ export interface WebsearchApi { readonly query: WebsearchQueryOperation } -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 export type ConfigGetOperation = (input?: Endpoint29_0Input) => Effect.Effect diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 4f3347bae1..77dc485c3d 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -472,7 +472,7 @@ export function make(options: ClientOptions) { location: input?.["location"], }, successStatus: 200, - declaredStatuses: [401, 400], + declaredStatuses: [400, 401], empty: false, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index d89106ffa8..a5b9c00c60 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 diff --git a/packages/core/src/database/migration/20260806150823_remove_workspace.ts b/packages/core/src/database/migration/20260806150823_remove_workspace.ts index 061d78bd17..f029d174d4 100644 --- a/packages/core/src/database/migration/20260806150823_remove_workspace.ts +++ b/packages/core/src/database/migration/20260806150823_remove_workspace.ts @@ -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\`;`) }) }, diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 8aed66dcd6..9f3bcd4f0a 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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)]], diff --git a/packages/core/src/tool/plugin/read.ts b/packages/core/src/tool/plugin/read.ts index ce56f73245..de7e3fc485 100644 --- a/packages/core/src/tool/plugin/read.ts +++ b/packages/core/src/tool/plugin/read.ts @@ -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 : . 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 : . 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) diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index 3b7a3fed73..c4519213f5 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -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()("@opencode/ReadToolFileSystem") {} +/** The filesystem surface `read` consumes; FSUtil satisfies it structurally. */ +export interface ReadSource { + readonly realPath: (path: string) => Effect.Effect + 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, 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()), + } + 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() + 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], +}) diff --git a/packages/core/test/workspace-mutation.test.ts b/packages/core/test/workspace-mutation.test.ts index 66f009ac6f..630e4237f6 100644 --- a/packages/core/test/workspace-mutation.test.ts +++ b/packages/core/test/workspace-mutation.test.ts @@ -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" } }) + }), + ) }) diff --git a/packages/core/test/workspace-session-e2e.test.ts b/packages/core/test/workspace-session-e2e.test.ts index 4a2ca312cb..63a04bd1c2 100644 --- a/packages/core/test/workspace-session-e2e.test.ts +++ b/packages/core/test/workspace-session-e2e.test.ts @@ -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) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 7a246b76c8..64ee57fb15 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -153,6 +153,7 @@ export const makeSessionGroup = (sessionLo location: Location.Ref.pipe(Schema.optional), }), success: Schema.Struct({ data: Session.Info }), + error: InvalidRequestError, }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.create", diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index ac95f29003..c173ec5853 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -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, + ), } }), ) diff --git a/packages/server/test/workspace-modal-session.test.ts b/packages/server/test/workspace-modal-session.test.ts index 68b84f5f11..df248b8df4 100644 --- a/packages/server/test/workspace-modal-session.test.ts +++ b/packages/server/test/workspace-modal-session.test.ts @@ -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