refactor(tool): Tool.Def.execute returns Effect, rename defineEffect → define (#21961)

This commit is contained in:
Kit Langton 2026-04-10 22:36:02 -04:00 committed by GitHub
commit c5fb6281f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 674 additions and 721 deletions

View file

@ -1,5 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { basename } from "path" import { basename } from "path"
import { Effect } from "effect"
import { Agent } from "../../../agent/agent" import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider" import { Provider } from "../../../provider/provider"
import { Session } from "../../../session" import { Session } from "../../../session"
@ -158,13 +159,15 @@ async function createToolContext(agent: Agent.Info) {
abort: new AbortController().signal, abort: new AbortController().signal,
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) { ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) { return Effect.sync(() => {
const rule = Permission.evaluate(req.permission, pattern, ruleset) for (const pattern of req.patterns) {
if (rule.action === "deny") { const rule = Permission.evaluate(req.permission, pattern, ruleset)
throw new Permission.DeniedError({ ruleset }) if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
}
} }
} })
}, },
} }
} }

View file

@ -34,7 +34,7 @@ export namespace FileTime {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void> readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined> readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void> readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T> readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
@ -103,8 +103,8 @@ export namespace FileTime {
) )
}) })
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) { const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1)) return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
}) })
return Service.of({ read, get, assert, withLock }) return Service.of({ read, get, assert, withLock })
@ -128,6 +128,6 @@ export namespace FileTime {
} }
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> { export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromise((s) => s.withLock(filepath, fn)) return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
} }
} }

View file

@ -103,6 +103,13 @@ export namespace SessionPrompt {
const state = yield* SessionRunState.Service const state = yield* SessionRunState.Service
const revert = yield* SessionRevert.Service const revert = yield* SessionRevert.Service
const run = {
promise: <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))),
fork: <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))),
}
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
yield* elog.info("cancel", { sessionID }) yield* elog.info("cancel", { sessionID })
yield* state.cancel(sessionID) yield* state.cancel(sessionID)
@ -358,7 +365,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
agent: input.agent.name, agent: input.agent.name,
messages: input.messages, messages: input.messages,
metadata: (val) => metadata: (val) =>
Effect.runPromise( run.promise(
input.processor.updateToolCall(options.toolCallId, (match) => { input.processor.updateToolCall(options.toolCallId, (match) => {
if (!["running", "pending"].includes(match.state.status)) return match if (!["running", "pending"].includes(match.state.status)) return match
return { return {
@ -374,14 +381,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}), }),
), ),
ask: (req) => ask: (req) =>
Effect.runPromise( permission
permission.ask({ .ask({
...req, ...req,
sessionID: input.session.id, sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId }, tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
}), })
), .pipe(Effect.orDie),
}) })
for (const item of yield* registry.tools({ for (const item of yield* registry.tools({
@ -395,7 +402,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
description: item.description, description: item.description,
inputSchema: jsonSchema(schema as any), inputSchema: jsonSchema(schema as any),
execute(args, options) { execute(args, options) {
return Effect.runPromise( return run.promise(
Effect.gen(function* () { Effect.gen(function* () {
const ctx = context(args, options) const ctx = context(args, options)
yield* plugin.trigger( yield* plugin.trigger(
@ -403,7 +410,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args }, { args },
) )
const result = yield* Effect.promise(() => item.execute(args, ctx)) const result = yield* item.execute(args, ctx)
const output = { const output = {
...result, ...result,
attachments: result.attachments?.map((attachment) => ({ attachments: result.attachments?.map((attachment) => ({
@ -436,7 +443,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const transformed = ProviderTransform.schema(input.model, schema) const transformed = ProviderTransform.schema(input.model, schema)
item.inputSchema = jsonSchema(transformed) item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) => item.execute = (args, opts) =>
Effect.runPromise( run.promise(
Effect.gen(function* () { Effect.gen(function* () {
const ctx = context(args, opts) const ctx = context(args, opts)
yield* plugin.trigger( yield* plugin.trigger(
@ -444,7 +451,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args }, { args },
) )
yield* Effect.promise(() => ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })) yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.promise(() => const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.promise(() =>
execute(args, opts), execute(args, opts),
) )
@ -576,45 +583,46 @@ NOTE: At any point in time through this workflow you should feel free to ask the
} }
let error: Error | undefined let error: Error | undefined
const result = yield* Effect.promise((signal) => const taskAbort = new AbortController()
taskTool const result = yield* taskTool
.execute(taskArgs, { .execute(taskArgs, {
agent: task.agent, agent: task.agent,
messageID: assistantMessage.id, messageID: assistantMessage.id,
sessionID, sessionID,
abort: signal, abort: taskAbort.signal,
callID: part.callID, callID: part.callID,
extra: { bypassAgentCheck: true, promptOps }, extra: { bypassAgentCheck: true, promptOps },
messages: msgs, messages: msgs,
metadata(val: { title?: string; metadata?: Record<string, any> }) { metadata(val: { title?: string; metadata?: Record<string, any> }) {
return Effect.runPromise( return run.promise(
Effect.gen(function* () { Effect.gen(function* () {
part = yield* sessions.updatePart({ part = yield* sessions.updatePart({
...part, ...part,
type: "tool", type: "tool",
state: { ...part.state, ...val }, state: { ...part.state, ...val },
} satisfies MessageV2.ToolPart) } satisfies MessageV2.ToolPart)
}), }),
) )
}, },
ask(req: any) { ask: (req: any) =>
return Effect.runPromise( permission
permission.ask({ .ask({
...req, ...req,
sessionID, sessionID,
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
}), })
) .pipe(Effect.orDie),
}, })
}) .pipe(
.catch((e) => { Effect.catchCause((cause) => {
error = e instanceof Error ? e : new Error(String(e)) const defect = Cause.squash(cause)
error = defect instanceof Error ? defect : new Error(String(defect))
log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined return Effect.void
}), }),
).pipe( Effect.onInterrupt(() =>
Effect.onInterrupt(() => Effect.gen(function* () {
Effect.gen(function* () { taskAbort.abort()
assistantMessage.finish = "tool-calls" assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now() assistantMessage.time.completed = Date.now()
yield* sessions.updateMessage(assistantMessage) yield* sessions.updateMessage(assistantMessage)
@ -630,9 +638,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}, },
} satisfies MessageV2.ToolPart) } satisfies MessageV2.ToolPart)
} }
}), })),
), )
)
const attachments = result?.attachments?.map((attachment) => ({ const attachments = result?.attachments?.map((attachment) => ({
...attachment, ...attachment,
@ -855,7 +862,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
output += chunk output += chunk
if (part.state.status === "running") { if (part.state.status === "running") {
part.state.metadata = { output, description: "" } part.state.metadata = { output, description: "" }
void Effect.runFork(sessions.updatePart(part)) void run.fork(sessions.updatePart(part))
} }
}), }),
) )
@ -1037,19 +1044,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory" if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
const { read } = yield* registry.named() const { read } = yield* registry.named()
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
Effect.promise((signal: AbortSignal) => const controller = new AbortController()
read.execute(args, { return read
.execute(args, {
sessionID: input.sessionID, sessionID: input.sessionID,
abort: signal, abort: controller.signal,
agent: input.agent!, agent: input.agent!,
messageID: info.id, messageID: info.id,
extra: { bypassCwdCheck: true, ...extra }, extra: { bypassCwdCheck: true, ...extra },
messages: [], messages: [],
metadata: async () => {}, metadata: () => {},
ask: async () => {}, ask: () => Effect.void,
}), })
) .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
}
if (part.mime === "text/plain") { if (part.mime === "text/plain") {
let offset: number | undefined let offset: number | undefined
@ -1655,9 +1664,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) })
const promptOps: TaskPromptOps = { const promptOps: TaskPromptOps = {
cancel: (sessionID) => Effect.runFork(cancel(sessionID)), cancel: (sessionID) => run.fork(cancel(sessionID)),
resolvePromptParts: (template) => Effect.runPromise(resolvePromptParts(template)), resolvePromptParts: (template) => run.promise(resolvePromptParts(template)),
prompt: (input) => Effect.runPromise(prompt(input)), prompt: (input) => run.promise(prompt(input)),
} }
return Service.of({ return Service.of({

View file

@ -19,12 +19,13 @@ const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"), patchText: z.string().describe("The full patch text that describes all changes to be made"),
}) })
export const ApplyPatchTool = Tool.defineEffect( export const ApplyPatchTool = Tool.define(
"apply_patch", "apply_patch",
Effect.gen(function* () { Effect.gen(function* () {
const lsp = yield* LSP.Service const lsp = yield* LSP.Service
const afs = yield* AppFileSystem.Service const afs = yield* AppFileSystem.Service
const format = yield* Format.Service const format = yield* Format.Service
const bus = yield* Bus.Service
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) { const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
if (!params.patchText) { if (!params.patchText) {
@ -178,18 +179,16 @@ export const ApplyPatchTool = Tool.defineEffect(
// Check permissions if needed // Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "edit",
permission: "edit", patterns: relativePaths,
patterns: relativePaths, always: ["*"],
always: ["*"], metadata: {
metadata: { filepath: relativePaths.join(", "),
filepath: relativePaths.join(", "), diff: totalDiff,
diff: totalDiff, files,
files, },
}, })
}),
)
// Apply the changes // Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
@ -228,13 +227,13 @@ export const ApplyPatchTool = Tool.defineEffect(
if (edited) { if (edited) {
yield* format.file(edited) yield* format.file(edited)
Bus.publish(File.Event.Edited, { file: edited }) yield* bus.publish(File.Event.Edited, { file: edited })
} }
} }
// Publish file change events // Publish file change events
for (const update of updates) { for (const update of updates) {
Bus.publish(FileWatcher.Event.Updated, update) yield* bus.publish(FileWatcher.Event.Updated, update)
} }
// Notify LSP of file changes and collect diagnostics // Notify LSP of file changes and collect diagnostics
@ -281,9 +280,7 @@ export const ApplyPatchTool = Tool.defineEffect(
return { return {
description: DESCRIPTION, description: DESCRIPTION,
parameters: PatchParams, parameters: PatchParams,
async execute(params: z.infer<typeof PatchParams>, ctx) { execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
} }
}), }),
) )

View file

@ -226,25 +226,21 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
return path.join(dir, "*") return path.join(dir, "*")
}) })
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "external_directory",
permission: "external_directory", patterns: globs,
patterns: globs, always: globs,
always: globs, metadata: {},
metadata: {}, })
}),
)
} }
if (scan.patterns.size === 0) return if (scan.patterns.size === 0) return
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "bash",
permission: "bash", patterns: Array.from(scan.patterns),
patterns: Array.from(scan.patterns), always: Array.from(scan.always),
always: Array.from(scan.always), metadata: {},
metadata: {}, })
}),
)
}) })
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
@ -294,7 +290,7 @@ const parser = lazy(async () => {
}) })
// TODO: we may wanna rename this tool so it works better on other shells // TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.defineEffect( export const BashTool = Tool.define(
"bash", "bash",
Effect.gen(function* () { Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner const spawner = yield* ChildProcessSpawner
@ -504,7 +500,7 @@ export const BashTool = Tool.defineEffect(
}, },
ctx, ctx,
) )
}).pipe(Effect.orDie, Effect.runPromise), }),
} }
} }
}), }),

View file

@ -5,7 +5,7 @@ import { Tool } from "./tool"
import * as McpExa from "./mcp-exa" import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./codesearch.txt" import DESCRIPTION from "./codesearch.txt"
export const CodeSearchTool = Tool.defineEffect( export const CodeSearchTool = Tool.define(
"codesearch", "codesearch",
Effect.gen(function* () { Effect.gen(function* () {
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
@ -29,17 +29,15 @@ export const CodeSearchTool = Tool.defineEffect(
}), }),
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "codesearch",
permission: "codesearch", patterns: [params.query],
patterns: [params.query], always: ["*"],
always: ["*"], metadata: {
metadata: { query: params.query,
query: params.query, tokensNum: params.tokensNum,
tokensNum: params.tokensNum, },
}, })
}),
)
const result = yield* McpExa.call( const result = yield* McpExa.call(
http, http,
@ -59,7 +57,7 @@ export const CodeSearchTool = Tool.defineEffect(
title: `Code search: ${params.query}`, title: `Code search: ${params.query}`,
metadata: {}, metadata: {},
} }
}).pipe(Effect.runPromise), }).pipe(Effect.orDie),
} }
}), }),
) )

View file

@ -19,6 +19,7 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
function normalizeLineEndings(text: string): string { function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n") return text.replaceAll("\r\n", "\n")
@ -40,11 +41,14 @@ const Parameters = z.object({
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}) })
export const EditTool = Tool.defineEffect( export const EditTool = Tool.define(
"edit", "edit",
Effect.gen(function* () { Effect.gen(function* () {
const lsp = yield* LSP.Service const lsp = yield* LSP.Service
const filetime = yield* FileTime.Service const filetime = yield* FileTime.Service
const afs = yield* AppFileSystem.Service
const format = yield* Format.Service
const bus = yield* Bus.Service
return { return {
description: DESCRIPTION, description: DESCRIPTION,
@ -67,12 +71,53 @@ export const EditTool = Tool.defineEffect(
let diff = "" let diff = ""
let contentOld = "" let contentOld = ""
let contentNew = "" let contentNew = ""
yield* filetime.withLock(filePath, async () => { yield* filetime.withLock(filePath, () =>
if (params.oldString === "") { Effect.gen(function* () {
const existed = await Filesystem.exists(filePath) if (params.oldString === "") {
contentNew = params.newString const existed = yield* afs.existsSafe(filePath)
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) contentNew = params.newString
await ctx.ask({ diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
yield* afs.writeWithDirs(filePath, params.newString)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
yield* filetime.read(ctx.sessionID, filePath)
return
}
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) throw new Error(`File ${filePath} not found`)
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
yield* filetime.assert(ctx.sessionID, filePath)
contentOld = yield* afs.readFileString(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
yield* ctx.ask({
permission: "edit", permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)], patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"], always: ["*"],
@ -81,65 +126,26 @@ export const EditTool = Tool.defineEffect(
diff, diff,
}, },
}) })
await Filesystem.write(filePath, params.newString)
await Format.file(filePath) yield* afs.writeWithDirs(filePath, contentNew)
Bus.publish(File.Event.Edited, { file: filePath }) yield* format.file(filePath)
await Bus.publish(FileWatcher.Event.Updated, { yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath, file: filePath,
event: existed ? "change" : "add", event: "change",
}) })
await FileTime.read(ctx.sessionID, filePath) contentNew = yield* afs.readFileString(filePath)
return diff = trimDiff(
} createTwoFilesPatch(
filePath,
const stats = Filesystem.stat(filePath) filePath,
if (!stats) throw new Error(`File ${filePath} not found`) normalizeLineEndings(contentOld),
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) normalizeLineEndings(contentNew),
await FileTime.assert(ctx.sessionID, filePath) ),
contentOld = await Filesystem.readText(filePath) )
yield* filetime.read(ctx.sessionID, filePath)
const ending = detectLineEnding(contentOld) }).pipe(Effect.orDie),
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) )
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, contentNew)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
await FileTime.read(ctx.sessionID, filePath)
})
const filediff: Snapshot.FileDiff = { const filediff: Snapshot.FileDiff = {
file: filePath, file: filePath,
@ -176,7 +182,7 @@ export const EditTool = Tool.defineEffect(
title: `${path.relative(Instance.worktree, filePath)}`, title: `${path.relative(Instance.worktree, filePath)}`,
output, output,
} }
}).pipe(Effect.orDie, Effect.runPromise), }),
} }
}), }),
) )

View file

@ -11,7 +11,11 @@ type Options = {
kind?: Kind kind?: Kind
} }
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) { export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
if (!target) return if (!target) return
if (options?.bypass) return if (options?.bypass) return
@ -26,7 +30,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
? AppFileSystem.normalizePathPattern(path.join(dir, "*")) ? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/") : path.join(dir, "*").replaceAll("\\", "/")
await ctx.ask({ yield* ctx.ask({
permission: "external_directory", permission: "external_directory",
patterns: [glob], patterns: [glob],
always: [glob], always: [glob],
@ -35,12 +39,8 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
parentDir: dir, parentDir: dir,
}, },
}) })
}
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
yield* Effect.promise(() => assertExternalDirectory(ctx, target, options))
}) })
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options))
}

View file

@ -9,7 +9,7 @@ import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem" import { AppFileSystem } from "../filesystem"
export const GlobTool = Tool.defineEffect( export const GlobTool = Tool.define(
"glob", "glob",
Effect.gen(function* () { Effect.gen(function* () {
const rg = yield* Ripgrep.Service const rg = yield* Ripgrep.Service
@ -28,17 +28,15 @@ export const GlobTool = Tool.defineEffect(
}), }),
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "glob",
permission: "glob", patterns: [params.pattern],
patterns: [params.pattern], always: ["*"],
always: ["*"], metadata: {
metadata: { pattern: params.pattern,
pattern: params.pattern, path: params.path,
path: params.path, },
}, })
}),
)
let search = params.path ?? Instance.directory let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
@ -90,7 +88,7 @@ export const GlobTool = Tool.defineEffect(
}, },
output: output.join("\n"), output: output.join("\n"),
} }
}).pipe(Effect.orDie, Effect.runPromise), }).pipe(Effect.orDie),
} }
}), }),
) )

View file

@ -14,7 +14,7 @@ import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_LINE_LENGTH = 2000 const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.defineEffect( export const GrepTool = Tool.define(
"grep", "grep",
Effect.gen(function* () { Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner const spawner = yield* ChildProcessSpawner
@ -32,18 +32,16 @@ export const GrepTool = Tool.defineEffect(
throw new Error("pattern is required") throw new Error("pattern is required")
} }
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "grep",
permission: "grep", patterns: [params.pattern],
patterns: [params.pattern], always: ["*"],
always: ["*"], metadata: {
metadata: { pattern: params.pattern,
pattern: params.pattern, path: params.path,
path: params.path, include: params.include,
include: params.include, },
}, })
}),
)
let searchPath = params.path ?? Instance.directory let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
@ -171,7 +169,7 @@ export const GrepTool = Tool.defineEffect(
}, },
output: outputLines.join("\n"), output: outputLines.join("\n"),
} }
}).pipe(Effect.orDie, Effect.runPromise), }).pipe(Effect.orDie),
} }
}), }),
) )

View file

@ -1,17 +1,20 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
export const InvalidTool = Tool.define("invalid", { export const InvalidTool = Tool.define(
description: "Do not use", "invalid",
parameters: z.object({ Effect.succeed({
tool: z.string(), description: "Do not use",
error: z.string(), parameters: z.object({
tool: z.string(),
error: z.string(),
}),
execute: (params: { tool: string; error: string }) =>
Effect.succeed({
title: "Invalid Tool",
output: `The arguments provided to the tool are invalid: ${params.error}`,
metadata: {},
}),
}), }),
async execute(params) { )
return {
title: "Invalid Tool",
output: `The arguments provided to the tool are invalid: ${params.error}`,
metadata: {},
}
},
})

View file

@ -37,7 +37,7 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100 const LIMIT = 100
export const ListTool = Tool.defineEffect( export const ListTool = Tool.define(
"list", "list",
Effect.gen(function* () { Effect.gen(function* () {
const rg = yield* Ripgrep.Service const rg = yield* Ripgrep.Service
@ -56,16 +56,14 @@ export const ListTool = Tool.defineEffect(
const searchPath = path.resolve(Instance.directory, params.path || ".") const searchPath = path.resolve(Instance.directory, params.path || ".")
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "list",
permission: "list", patterns: [searchPath],
patterns: [searchPath], always: ["*"],
always: ["*"], metadata: {
metadata: { path: searchPath,
path: searchPath, },
}, })
}),
)
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe( const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
@ -130,7 +128,7 @@ export const ListTool = Tool.defineEffect(
}, },
output, output,
} }
}).pipe(Effect.orDie, Effect.runPromise), }).pipe(Effect.orDie),
} }
}), }),
) )

View file

@ -21,7 +21,7 @@ const operations = [
"outgoingCalls", "outgoingCalls",
] as const ] as const
export const LspTool = Tool.defineEffect( export const LspTool = Tool.define(
"lsp", "lsp",
Effect.gen(function* () { Effect.gen(function* () {
const lsp = yield* LSP.Service const lsp = yield* LSP.Service
@ -42,7 +42,7 @@ export const LspTool = Tool.defineEffect(
Effect.gen(function* () { Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file) yield* assertExternalDirectoryEffect(ctx, file)
yield* Effect.promise(() => ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })) yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
const uri = pathToFileURL(file).href const uri = pathToFileURL(file).href
const position = { file, line: args.line - 1, character: args.character - 1 } const position = { file, line: args.line - 1, character: args.character - 1 }
@ -85,7 +85,7 @@ export const LspTool = Tool.defineEffect(
metadata: { result }, metadata: { result },
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2), output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
} }
}).pipe(Effect.runPromise), }),
} }
}), }),
) )

View file

@ -6,7 +6,7 @@ import DESCRIPTION from "./multiedit.txt"
import path from "path" import path from "path"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
export const MultiEditTool = Tool.defineEffect( export const MultiEditTool = Tool.define(
"multiedit", "multiedit",
Effect.gen(function* () { Effect.gen(function* () {
const editInfo = yield* EditTool const editInfo = yield* EditTool
@ -37,16 +37,14 @@ export const MultiEditTool = Tool.defineEffect(
Effect.gen(function* () { Effect.gen(function* () {
const results = [] const results = []
for (const [, entry] of params.edits.entries()) { for (const [, entry] of params.edits.entries()) {
const result = yield* Effect.promise(() => const result = yield* edit.execute(
edit.execute( {
{ filePath: params.filePath,
filePath: params.filePath, oldString: entry.oldString,
oldString: entry.oldString, newString: entry.newString,
newString: entry.newString, replaceAll: entry.replaceAll,
replaceAll: entry.replaceAll, },
}, ctx,
ctx,
),
) )
results.push(result) results.push(result)
} }
@ -57,7 +55,7 @@ export const MultiEditTool = Tool.defineEffect(
}, },
output: results.at(-1)!.output, output: results.at(-1)!.output,
} }
}).pipe(Effect.orDie, Effect.runPromise), }),
} }
}), }),
) )

View file

@ -17,7 +17,7 @@ function getLastModel(sessionID: SessionID) {
return undefined return undefined
} }
export const PlanExitTool = Tool.defineEffect( export const PlanExitTool = Tool.define(
"plan_exit", "plan_exit",
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
@ -74,7 +74,7 @@ export const PlanExitTool = Tool.defineEffect(
output: "User approved switching to build agent. Wait for further instructions.", output: "User approved switching to build agent. Wait for further instructions.",
metadata: {}, metadata: {},
} }
}).pipe(Effect.runPromise), }).pipe(Effect.orDie),
} }
}), }),
) )

View file

@ -12,7 +12,7 @@ type Metadata = {
answers: Question.Answer[] answers: Question.Answer[]
} }
export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>( export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
"question", "question",
Effect.gen(function* () { Effect.gen(function* () {
const question = yield* Question.Service const question = yield* Question.Service
@ -39,7 +39,7 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
answers, answers,
}, },
} }
}).pipe(Effect.runPromise), }).pipe(Effect.orDie),
} }
}), }),
) )

View file

@ -25,7 +25,7 @@ const parameters = z.object({
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(), limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
}) })
export const ReadTool = Tool.defineEffect( export const ReadTool = Tool.define(
"read", "read",
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
@ -106,14 +106,12 @@ export const ReadTool = Tool.defineEffect(
kind: stat?.type === "Directory" ? "directory" : "file", kind: stat?.type === "Directory" ? "directory" : "file",
}) })
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "read",
permission: "read", patterns: [filepath],
patterns: [filepath], always: ["*"],
always: ["*"], metadata: {},
metadata: {}, })
}),
)
if (!stat) return yield* miss(filepath) if (!stat) return yield* miss(filepath)
@ -218,9 +216,7 @@ export const ReadTool = Tool.defineEffect(
return { return {
description: DESCRIPTION, description: DESCRIPTION,
parameters, parameters,
async execute(params: z.infer<typeof parameters>, ctx) { execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
} }
}), }),
) )

View file

@ -44,6 +44,7 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction" import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem" import { AppFileSystem } from "../filesystem"
import { Bus } from "../bus"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Permission } from "@/permission" import { Permission } from "@/permission"
@ -89,10 +90,12 @@ export namespace ToolRegistry {
| FileTime.Service | FileTime.Service
| Instruction.Service | Instruction.Service
| AppFileSystem.Service | AppFileSystem.Service
| Bus.Service
| HttpClient.HttpClient | HttpClient.HttpClient
| ChildProcessSpawner | ChildProcessSpawner
| Ripgrep.Service | Ripgrep.Service
| Format.Service | Format.Service
| Truncate.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@ -100,7 +103,9 @@ export namespace ToolRegistry {
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const agents = yield* Agent.Service const agents = yield* Agent.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
const truncate = yield* Truncate.Service
const invalid = yield* InvalidTool
const task = yield* TaskTool const task = yield* TaskTool
const read = yield* ReadTool const read = yield* ReadTool
const question = yield* QuestionTool const question = yield* QuestionTool
@ -127,23 +132,26 @@ export namespace ToolRegistry {
id, id,
parameters: z.object(def.args), parameters: z.object(def.args),
description: def.description, description: def.description,
execute: async (args, toolCtx) => { execute: (args, toolCtx) =>
const pluginCtx: PluginToolContext = { Effect.gen(function* () {
...toolCtx, const pluginCtx: PluginToolContext = {
directory: ctx.directory, ...toolCtx,
worktree: ctx.worktree, ask: (req) => Effect.runPromise(toolCtx.ask(req)),
} directory: ctx.directory,
const result = await def.execute(args as any, pluginCtx) worktree: ctx.worktree,
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent)) }
return { const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
title: "", const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent))
output: out.truncated ? out.content : result, const out = yield* truncate.output(result, {}, agent)
metadata: { return {
truncated: out.truncated, title: "",
outputPath: out.truncated ? out.outputPath : undefined, output: out.truncated ? out.content : result,
}, metadata: {
} truncated: out.truncated,
}, outputPath: out.truncated ? out.outputPath : undefined,
},
}
}),
} }
} }
@ -174,7 +182,7 @@ export namespace ToolRegistry {
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
const tool = yield* Effect.all({ const tool = yield* Effect.all({
invalid: Tool.init(InvalidTool), invalid: Tool.init(invalid),
bash: Tool.init(bash), bash: Tool.init(bash),
read: Tool.init(read), read: Tool.init(read),
glob: Tool.init(globtool), glob: Tool.init(globtool),
@ -328,10 +336,12 @@ export namespace ToolRegistry {
Layer.provide(FileTime.defaultLayer), Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer), Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Truncate.defaultLayer),
), ),
) )

View file

@ -11,7 +11,7 @@ const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"), name: z.string().describe("The name of the skill from available_skills"),
}) })
export const SkillTool = Tool.defineEffect( export const SkillTool = Tool.define(
"skill", "skill",
Effect.gen(function* () { Effect.gen(function* () {
const skill = yield* Skill.Service const skill = yield* Skill.Service
@ -51,14 +51,12 @@ export const SkillTool = Tool.defineEffect(
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
} }
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "skill",
permission: "skill", patterns: [params.name],
patterns: [params.name], always: [params.name],
always: [params.name], metadata: {},
metadata: {}, })
}),
)
const dir = path.dirname(info.location) const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href const base = pathToFileURL(dir).href
@ -94,7 +92,7 @@ export const SkillTool = Tool.defineEffect(
dir, dir,
}, },
} }
}).pipe(Effect.orDie, Effect.runPromise), }).pipe(Effect.orDie),
} }
} }
}), }),

View file

@ -31,7 +31,7 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(), command: z.string().describe("The command that triggered this task").optional(),
}) })
export const TaskTool = Tool.defineEffect( export const TaskTool = Tool.define(
id, id,
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service const agent = yield* Agent.Service
@ -41,17 +41,15 @@ export const TaskTool = Tool.defineEffect(
const cfg = yield* config.get() const cfg = yield* config.get()
if (!ctx.extra?.bypassAgentCheck) { if (!ctx.extra?.bypassAgentCheck) {
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: id,
permission: id, patterns: [params.subagent_type],
patterns: [params.subagent_type], always: ["*"],
always: ["*"], metadata: {
metadata: { description: params.description,
description: params.description, subagent_type: params.subagent_type,
subagent_type: params.subagent_type, },
}, })
}),
)
} }
const next = yield* agent.get(params.subagent_type) const next = yield* agent.get(params.subagent_type)
@ -178,9 +176,7 @@ export const TaskTool = Tool.defineEffect(
return { return {
description: DESCRIPTION, description: DESCRIPTION,
parameters, parameters,
async execute(params: z.infer<typeof parameters>, ctx) { execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
return Effect.runPromise(run(params, ctx))
},
} }
}), }),
) )

View file

@ -12,7 +12,7 @@ type Metadata = {
todos: Todo.Info[] todos: Todo.Info[]
} }
export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>( export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
"todowrite", "todowrite",
Effect.gen(function* () { Effect.gen(function* () {
const todo = yield* Todo.Service const todo = yield* Todo.Service
@ -20,29 +20,28 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
return { return {
description: DESCRIPTION_WRITE, description: DESCRIPTION_WRITE,
parameters, parameters,
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) { execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
await ctx.ask({ Effect.gen(function* () {
permission: "todowrite", yield* ctx.ask({
patterns: ["*"], permission: "todowrite",
always: ["*"], patterns: ["*"],
metadata: {}, always: ["*"],
}) metadata: {},
})
await todo yield* todo.update({
.update({
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
todos: params.todos, todos: params.todos,
}) })
.pipe(Effect.runPromise)
return { return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`, title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2), output: JSON.stringify(params.todos, null, 2),
metadata: { metadata: {
todos: params.todos, todos: params.todos,
}, },
} }
}, }),
} satisfies Tool.DefWithoutID<typeof parameters, Metadata> } satisfies Tool.DefWithoutID<typeof parameters, Metadata>
}), }),
) )

View file

@ -23,22 +23,21 @@ export namespace Tool {
extra?: { [key: string]: any } extra?: { [key: string]: any }
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): void metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void> ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}
export interface ExecuteResult<M extends Metadata = Metadata> {
title: string
metadata: M
output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
} }
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> { export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string id: string
description: string description: string
parameters: Parameters parameters: Parameters
execute( execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
args: z.infer<Parameters>,
ctx: Context,
): Promise<{
title: string
metadata: M
output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}>
formatValidationError?(error: z.ZodError): string formatValidationError?(error: z.ZodError): string
} }
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit< export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
@ -74,48 +73,41 @@ export namespace Tool {
return async () => { return async () => {
const toolInfo = init instanceof Function ? await init() : { ...init } const toolInfo = init instanceof Function ? await init() : { ...init }
const execute = toolInfo.execute const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => { toolInfo.execute = (args, ctx) =>
try { Effect.gen(function* () {
toolInfo.parameters.parse(args) yield* Effect.try({
} catch (error) { try: () => toolInfo.parameters.parse(args),
if (error instanceof z.ZodError && toolInfo.formatValidationError) { catch: (error) => {
throw new Error(toolInfo.formatValidationError(error), { cause: error }) if (error instanceof z.ZodError && toolInfo.formatValidationError) {
return new Error(toolInfo.formatValidationError(error), { cause: error })
}
return new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
)
},
})
const result = yield* execute(args, ctx)
if (result.metadata.truncated !== undefined) {
return result
} }
throw new Error( const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
{ cause: error }, return {
) ...result,
} output: truncated.content,
const result = await execute(args, ctx) metadata: {
if (result.metadata.truncated !== undefined) { ...result.metadata,
return result truncated: truncated.truncated,
} ...(truncated.truncated && { outputPath: truncated.outputPath }),
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent)) },
return { }
...result, }).pipe(Effect.orDie)
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
}
}
return toolInfo return toolInfo
} }
} }
export function define<Parameters extends z.ZodType, Result extends Metadata, ID extends string = string>( export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
): Info<Parameters, Result> & { id: ID } {
return {
id,
init: wrap(id, init),
}
}
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID, id: ID,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>, init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } { ): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {

View file

@ -18,7 +18,7 @@ const parameters = z.object({
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
}) })
export const WebFetchTool = Tool.defineEffect( export const WebFetchTool = Tool.define(
"webfetch", "webfetch",
Effect.gen(function* () { Effect.gen(function* () {
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
@ -33,18 +33,16 @@ export const WebFetchTool = Tool.defineEffect(
throw new Error("URL must start with http:// or https://") throw new Error("URL must start with http:// or https://")
} }
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "webfetch",
permission: "webfetch", patterns: [params.url],
patterns: [params.url], always: ["*"],
always: ["*"], metadata: {
metadata: { url: params.url,
url: params.url, format: params.format,
format: params.format, timeout: params.timeout,
timeout: params.timeout, },
}, })
}),
)
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
@ -153,7 +151,7 @@ export const WebFetchTool = Tool.defineEffect(
default: default:
return { output: content, title, metadata: {} } return { output: content, title, metadata: {} }
} }
}).pipe(Effect.runPromise), }).pipe(Effect.orDie),
} }
}), }),
) )

View file

@ -24,7 +24,7 @@ const Parameters = z.object({
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"), .describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}) })
export const WebSearchTool = Tool.defineEffect( export const WebSearchTool = Tool.define(
"websearch", "websearch",
Effect.gen(function* () { Effect.gen(function* () {
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
@ -36,20 +36,18 @@ export const WebSearchTool = Tool.defineEffect(
parameters: Parameters, parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "websearch",
permission: "websearch", patterns: [params.query],
patterns: [params.query], always: ["*"],
always: ["*"], metadata: {
metadata: { query: params.query,
query: params.query, numResults: params.numResults,
numResults: params.numResults, livecrawl: params.livecrawl,
livecrawl: params.livecrawl, type: params.type,
type: params.type, contextMaxCharacters: params.contextMaxCharacters,
contextMaxCharacters: params.contextMaxCharacters, },
}, })
}),
)
const result = yield* McpExa.call( const result = yield* McpExa.call(
http, http,
@ -70,7 +68,7 @@ export const WebSearchTool = Tool.defineEffect(
title: `Web search: ${params.query}`, title: `Web search: ${params.query}`,
metadata: {}, metadata: {},
} }
}).pipe(Effect.runPromise), }).pipe(Effect.orDie),
} }
}), }),
) )

View file

@ -17,12 +17,14 @@ import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_PROJECT_DIAGNOSTICS_FILES = 5 const MAX_PROJECT_DIAGNOSTICS_FILES = 5
export const WriteTool = Tool.defineEffect( export const WriteTool = Tool.define(
"write", "write",
Effect.gen(function* () { Effect.gen(function* () {
const lsp = yield* LSP.Service const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const filetime = yield* FileTime.Service const filetime = yield* FileTime.Service
const bus = yield* Bus.Service
const format = yield* Format.Service
return { return {
description: DESCRIPTION, description: DESCRIPTION,
@ -42,27 +44,23 @@ export const WriteTool = Tool.defineEffect(
if (exists) yield* filetime.assert(ctx.sessionID, filepath) if (exists) yield* filetime.assert(ctx.sessionID, filepath)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
yield* Effect.promise(() => yield* ctx.ask({
ctx.ask({ permission: "edit",
permission: "edit", patterns: [path.relative(Instance.worktree, filepath)],
patterns: [path.relative(Instance.worktree, filepath)], always: ["*"],
always: ["*"], metadata: {
metadata: { filepath,
filepath, diff,
diff, },
}, })
}),
)
yield* fs.writeWithDirs(filepath, params.content) yield* fs.writeWithDirs(filepath, params.content)
yield* Effect.promise(() => Format.file(filepath)) yield* format.file(filepath)
Bus.publish(File.Event.Edited, { file: filepath }) yield* bus.publish(File.Event.Edited, { file: filepath })
yield* Effect.promise(() => yield* bus.publish(FileWatcher.Event.Updated, {
Bus.publish(FileWatcher.Event.Updated, { file: filepath,
file: filepath, event: exists ? "change" : "add",
event: exists ? "change" : "add", })
}),
)
yield* filetime.read(ctx.sessionID, filepath) yield* filetime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully." let output = "Wrote file successfully."
@ -92,7 +90,7 @@ export const WriteTool = Tool.defineEffect(
}, },
output, output,
} }
}).pipe(Effect.orDie, Effect.runPromise), }).pipe(Effect.orDie),
} }
}), }),
) )

View file

@ -144,7 +144,7 @@ const filetime = Layer.succeed(
read: () => Effect.void, read: () => Effect.void,
get: () => Effect.succeed(undefined), get: () => Effect.succeed(undefined),
assert: () => Effect.void, assert: () => Effect.void,
withLock: (_filepath, fn) => Effect.promise(fn), withLock: (_filepath, fn) => fn(),
}), }),
) )
@ -735,19 +735,12 @@ it.live(
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const { task } = yield* registry.named() const { task } = yield* registry.named()
const original = task.execute const original = task.execute
task.execute = async (_args, ctx) => { task.execute = (_args, ctx) =>
ready.resolve() Effect.callback<never>((resume) => {
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) ready.resolve()
await new Promise<void>(() => {}) ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
return { return Effect.sync(() => aborted.resolve())
title: "", })
metadata: {
sessionId: SessionID.make("task"),
model: ref,
},
output: "",
}
}
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
const { prompt, chat } = yield* boot() const { prompt, chat } = yield* boot()
@ -1393,11 +1386,10 @@ function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
const ready = defer<void>() const ready = defer<void>()
const aborted = defer<void>() const aborted = defer<void>()
const original = tool.execute const original = tool.execute
tool.execute = async (_args: any, ctx: any) => { tool.execute = (_args: any, ctx: any) => {
ready.resolve() ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {}) return Effect.callback<never>(() => {})
return { title: "", metadata: {}, output: "" }
} }
const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original))) const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
return { ready, aborted, restore } return { ready, aborted, restore }

View file

@ -107,7 +107,7 @@ const filetime = Layer.succeed(
read: () => Effect.void, read: () => Effect.void,
get: () => Effect.succeed(undefined), get: () => Effect.succeed(undefined),
assert: () => Effect.void, assert: () => Effect.void,
withLock: (_filepath, fn) => Effect.promise(fn), withLock: (_filepath, fn) => fn(),
}), }),
) )

View file

@ -7,10 +7,11 @@ import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp" import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Bus } from "../../src/bus"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer)) const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer))
const baseCtx = { const baseCtx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
@ -42,22 +43,21 @@ type AskInput = {
} }
type ToolCtx = typeof baseCtx & { type ToolCtx = typeof baseCtx & {
ask: (input: AskInput) => Promise<void> ask: (input: AskInput) => Effect.Effect<void>
} }
const execute = async (params: { patchText: string }, ctx: ToolCtx) => { const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
const info = await runtime.runPromise(ApplyPatchTool) const info = await runtime.runPromise(ApplyPatchTool)
const tool = await info.init() const tool = await info.init()
return tool.execute(params, ctx) return Effect.runPromise(tool.execute(params, ctx))
} }
const makeCtx = () => { const makeCtx = () => {
const calls: AskInput[] = [] const calls: AskInput[] = []
const ctx: ToolCtx = { const ctx: ToolCtx = {
...baseCtx, ...baseCtx,
ask: async (input) => { ask: (input) =>
calls.push(input) Effect.sync(() => { calls.push(input) }),
},
} }
return { ctx, calls } return { ctx, calls }

View file

@ -30,7 +30,7 @@ const ctx = {
abort: AbortSignal.any([]), abort: AbortSignal.any([]),
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
ask: async () => {}, ask: () => Effect.void,
} }
Shell.acceptable.reset() Shell.acceptable.reset()
@ -109,10 +109,11 @@ const each = (name: string, fn: (item: { label: string; shell: string }) => Prom
const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({ const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
...ctx, ...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => { ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
requests.push(req) Effect.sync(() => {
if (stop) throw stop requests.push(req)
}, if (stop) throw stop
}),
}) })
const mustTruncate = (result: { const mustTruncate = (result: {
@ -131,13 +132,13 @@ describe("tool.bash", () => {
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const result = await bash.execute( const result = await Effect.runPromise(bash.execute(
{ {
command: "echo test", command: "echo test",
description: "Echo test message", description: "Echo test message",
}, },
ctx, ctx,
) ))
expect(result.metadata.exit).toBe(0) expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("test") expect(result.metadata.output).toContain("test")
}, },
@ -153,13 +154,13 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: "echo hello", command: "echo hello",
description: "Echo hello", description: "Echo hello",
}, },
capture(requests), capture(requests),
) ))
expect(requests.length).toBe(1) expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("bash") expect(requests[0].permission).toBe("bash")
expect(requests[0].patterns).toContain("echo hello") expect(requests[0].patterns).toContain("echo hello")
@ -174,13 +175,13 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: "echo foo && echo bar", command: "echo foo && echo bar",
description: "Echo twice", description: "Echo twice",
}, },
capture(requests), capture(requests),
) ))
expect(requests.length).toBe(1) expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("bash") expect(requests[0].permission).toBe("bash")
expect(requests[0].patterns).toContain("echo foo") expect(requests[0].patterns).toContain("echo foo")
@ -198,13 +199,13 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: "Write-Host foo; if ($?) { Write-Host bar }", command: "Write-Host foo; if ($?) { Write-Host bar }",
description: "Check PowerShell conditional", description: "Check PowerShell conditional",
}, },
capture(requests), capture(requests),
) ))
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined() expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("Write-Host foo") expect(bashReq!.patterns).toContain("Write-Host foo")
@ -226,13 +227,13 @@ describe("tool.bash permissions", () => {
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: `cat ${file}`, command: `cat ${file}`,
description: "Read wildcard path", description: "Read wildcard path",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
@ -257,13 +258,13 @@ describe("tool.bash permissions", () => {
const bash = await initBash() const bash = await initBash()
const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: `echo $(cat "${file}")`, command: `echo $(cat "${file}")`,
description: "Read nested bash file", description: "Read nested bash file",
}, },
capture(requests), capture(requests),
) ))
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
@ -289,13 +290,13 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
description: "Copy Windows ini", description: "Copy Windows ini",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
@ -316,13 +317,13 @@ describe("tool.bash permissions", () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: `Write-Output $(Get-Content ${file})`, command: `Write-Output $(Get-Content ${file})`,
description: "Read nested PowerShell file", description: "Read nested PowerShell file",
}, },
capture(requests), capture(requests),
) ))
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
@ -347,13 +348,13 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: 'Get-Content "C:../outside.txt"', command: 'Get-Content "C:../outside.txt"',
description: "Read drive-relative file", description: "Read drive-relative file",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
expect(requests[0]?.permission).toBe("external_directory") expect(requests[0]?.permission).toBe("external_directory")
if (requests[0]?.permission !== "external_directory") return if (requests[0]?.permission !== "external_directory") return
@ -375,13 +376,13 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: 'Get-Content "$HOME/.ssh/config"', command: 'Get-Content "$HOME/.ssh/config"',
description: "Read home config", description: "Read home config",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
expect(requests[0]?.permission).toBe("external_directory") expect(requests[0]?.permission).toBe("external_directory")
if (requests[0]?.permission !== "external_directory") return if (requests[0]?.permission !== "external_directory") return
@ -404,13 +405,13 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: 'Get-Content "$PWD/../outside.txt"', command: 'Get-Content "$PWD/../outside.txt"',
description: "Read pwd-relative file", description: "Read pwd-relative file",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
expect(requests[0]?.permission).toBe("external_directory") expect(requests[0]?.permission).toBe("external_directory")
if (requests[0]?.permission !== "external_directory") return if (requests[0]?.permission !== "external_directory") return
@ -432,13 +433,13 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: 'Get-Content "$PSHOME/outside.txt"', command: 'Get-Content "$PSHOME/outside.txt"',
description: "Read pshome file", description: "Read pshome file",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
expect(requests[0]?.permission).toBe("external_directory") expect(requests[0]?.permission).toBe("external_directory")
if (requests[0]?.permission !== "external_directory") return if (requests[0]?.permission !== "external_directory") return
@ -465,13 +466,13 @@ describe("tool.bash permissions", () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
description: "Read Windows ini with missing env", description: "Read Windows ini with missing env",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
@ -495,13 +496,13 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: "Get-Content $env:WINDIR/win.ini", command: "Get-Content $env:WINDIR/win.ini",
description: "Read Windows ini from env", description: "Read Windows ini from env",
}, },
capture(requests), capture(requests),
) ))
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain( expect(extDirReq!.patterns).toContain(
@ -524,13 +525,13 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
description: "Read Windows ini from FileSystem provider", description: "Read Windows ini from FileSystem provider",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
expect(requests[0]?.permission).toBe("external_directory") expect(requests[0]?.permission).toBe("external_directory")
if (requests[0]?.permission !== "external_directory") return if (requests[0]?.permission !== "external_directory") return
@ -554,13 +555,13 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: "Get-Content ${env:WINDIR}/win.ini", command: "Get-Content ${env:WINDIR}/win.ini",
description: "Read Windows ini from braced env", description: "Read Windows ini from braced env",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
expect(requests[0]?.permission).toBe("external_directory") expect(requests[0]?.permission).toBe("external_directory")
if (requests[0]?.permission !== "external_directory") return if (requests[0]?.permission !== "external_directory") return
@ -582,13 +583,13 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: "Set-Location C:/Windows", command: "Set-Location C:/Windows",
description: "Change location", description: "Change location",
}, },
capture(requests), capture(requests),
) ))
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
@ -611,13 +612,13 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: "Write-Output ('a' * 3)", command: "Write-Output ('a' * 3)",
description: "Write repeated text", description: "Write repeated text",
}, },
capture(requests), capture(requests),
) ))
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined() expect(bashReq).toBeDefined()
expect(bashReq!.patterns).not.toContain("a * 3") expect(bashReq!.patterns).not.toContain("a * 3")
@ -638,13 +639,13 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: "cd ../", command: "cd ../",
description: "Change to parent directory", description: "Change to parent directory",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
@ -661,14 +662,14 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: "echo ok", command: "echo ok",
workdir: os.tmpdir(), workdir: os.tmpdir(),
description: "Echo from temp dir", description: "Echo from temp dir",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined() expect(extDirReq).toBeDefined()
@ -691,14 +692,14 @@ describe("tool.bash permissions", () => {
for (const dir of forms(outerTmp.path)) { for (const dir of forms(outerTmp.path)) {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: "echo ok", command: "echo ok",
workdir: dir, workdir: dir,
description: "Echo from external dir", description: "Echo from external dir",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
@ -724,14 +725,14 @@ describe("tool.bash permissions", () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: "echo ok", command: "echo ok",
workdir: "/tmp", workdir: "/tmp",
description: "Echo from Git Bash tmp", description: "Echo from Git Bash tmp",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
expect(requests[0]).toMatchObject({ expect(requests[0]).toMatchObject({
permission: "external_directory", permission: "external_directory",
@ -754,13 +755,13 @@ describe("tool.bash permissions", () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: "cat /tmp/opencode-does-not-exist", command: "cat /tmp/opencode-does-not-exist",
description: "Read Git Bash tmp file", description: "Read Git Bash tmp file",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
expect(requests[0]).toMatchObject({ expect(requests[0]).toMatchObject({
permission: "external_directory", permission: "external_directory",
@ -789,13 +790,13 @@ describe("tool.bash permissions", () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const filepath = path.join(outerTmp.path, "outside.txt") const filepath = path.join(outerTmp.path, "outside.txt")
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ {
command: `cat ${filepath}`, command: `cat ${filepath}`,
description: "Read external file", description: "Read external file",
}, },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
const expected = glob(path.join(outerTmp.path, "*")) const expected = glob(path.join(outerTmp.path, "*"))
@ -817,13 +818,13 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: `rm -rf ${path.join(tmp.path, "nested")}`, command: `rm -rf ${path.join(tmp.path, "nested")}`,
description: "Remove nested dir", description: "Remove nested dir",
}, },
capture(requests), capture(requests),
) ))
const extDirReq = requests.find((r) => r.permission === "external_directory") const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeUndefined() expect(extDirReq).toBeUndefined()
}, },
@ -837,13 +838,13 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: "git log --oneline -5", command: "git log --oneline -5",
description: "Git log", description: "Git log",
}, },
capture(requests), capture(requests),
) ))
expect(requests.length).toBe(1) expect(requests.length).toBe(1)
expect(requests[0].always.length).toBeGreaterThan(0) expect(requests[0].always.length).toBeGreaterThan(0)
expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true) expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true)
@ -858,13 +859,13 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await Effect.runPromise(bash.execute(
{ {
command: "cd .", command: "cd .",
description: "Stay in current directory", description: "Stay in current directory",
}, },
capture(requests), capture(requests),
) ))
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeUndefined() expect(bashReq).toBeUndefined()
}, },
@ -880,10 +881,10 @@ describe("tool.bash permissions", () => {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
bash.execute( Effect.runPromise(bash.execute(
{ command: "echo test > output.txt", description: "Redirect test output" }, { command: "echo test > output.txt", description: "Redirect test output" },
capture(requests, err), capture(requests, err),
), )),
).rejects.toThrow(err.message) ).rejects.toThrow(err.message)
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined() expect(bashReq).toBeDefined()
@ -899,7 +900,7 @@ describe("tool.bash permissions", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute({ command: "ls -la", description: "List" }, capture(requests)) await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests)))
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined() expect(bashReq).toBeDefined()
expect(bashReq!.always[0]).toBe("ls *") expect(bashReq!.always[0]).toBe("ls *")
@ -916,7 +917,7 @@ describe("tool.bash abort", () => {
const bash = await initBash() const bash = await initBash()
const controller = new AbortController() const controller = new AbortController()
const collected: string[] = [] const collected: string[] = []
const result = bash.execute( const res = await Effect.runPromise(bash.execute(
{ {
command: `echo before && sleep 30`, command: `echo before && sleep 30`,
description: "Long running command", description: "Long running command",
@ -932,8 +933,7 @@ describe("tool.bash abort", () => {
} }
}, },
}, },
) ))
const res = await result
expect(res.output).toContain("before") expect(res.output).toContain("before")
expect(res.output).toContain("User aborted the command") expect(res.output).toContain("User aborted the command")
expect(collected.length).toBeGreaterThan(0) expect(collected.length).toBeGreaterThan(0)
@ -946,14 +946,14 @@ describe("tool.bash abort", () => {
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const result = await bash.execute( const result = await Effect.runPromise(bash.execute(
{ {
command: `echo started && sleep 60`, command: `echo started && sleep 60`,
description: "Timeout test", description: "Timeout test",
timeout: 500, timeout: 500,
}, },
ctx, ctx,
) ))
expect(result.output).toContain("started") expect(result.output).toContain("started")
expect(result.output).toContain("bash tool terminated command after exceeding timeout") expect(result.output).toContain("bash tool terminated command after exceeding timeout")
}, },
@ -965,13 +965,13 @@ describe("tool.bash abort", () => {
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const result = await bash.execute( const result = await Effect.runPromise(bash.execute(
{ {
command: `echo stdout_msg && echo stderr_msg >&2`, command: `echo stdout_msg && echo stderr_msg >&2`,
description: "Stderr test", description: "Stderr test",
}, },
ctx, ctx,
) ))
expect(result.output).toContain("stdout_msg") expect(result.output).toContain("stdout_msg")
expect(result.output).toContain("stderr_msg") expect(result.output).toContain("stderr_msg")
expect(result.metadata.exit).toBe(0) expect(result.metadata.exit).toBe(0)
@ -984,13 +984,13 @@ describe("tool.bash abort", () => {
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const result = await bash.execute( const result = await Effect.runPromise(bash.execute(
{ {
command: `exit 42`, command: `exit 42`,
description: "Non-zero exit", description: "Non-zero exit",
}, },
ctx, ctx,
) ))
expect(result.metadata.exit).toBe(42) expect(result.metadata.exit).toBe(42)
}, },
}) })
@ -1002,7 +1002,7 @@ describe("tool.bash abort", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const updates: string[] = [] const updates: string[] = []
const result = await bash.execute( const result = await Effect.runPromise(bash.execute(
{ {
command: `echo first && sleep 0.1 && echo second`, command: `echo first && sleep 0.1 && echo second`,
description: "Streaming test", description: "Streaming test",
@ -1014,7 +1014,7 @@ describe("tool.bash abort", () => {
if (output) updates.push(output) if (output) updates.push(output)
}, },
}, },
) ))
expect(result.output).toContain("first") expect(result.output).toContain("first")
expect(result.output).toContain("second") expect(result.output).toContain("second")
expect(updates.length).toBeGreaterThan(1) expect(updates.length).toBeGreaterThan(1)
@ -1030,13 +1030,13 @@ describe("tool.bash truncation", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const lineCount = Truncate.MAX_LINES + 500 const lineCount = Truncate.MAX_LINES + 500
const result = await bash.execute( const result = await Effect.runPromise(bash.execute(
{ {
command: fill("lines", lineCount), command: fill("lines", lineCount),
description: "Generate lines exceeding limit", description: "Generate lines exceeding limit",
}, },
ctx, ctx,
) ))
mustTruncate(result) mustTruncate(result)
expect(result.output).toContain("truncated") expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated") expect(result.output).toContain("The tool call succeeded but the output was truncated")
@ -1050,13 +1050,13 @@ describe("tool.bash truncation", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const byteCount = Truncate.MAX_BYTES + 10000 const byteCount = Truncate.MAX_BYTES + 10000
const result = await bash.execute( const result = await Effect.runPromise(bash.execute(
{ {
command: fill("bytes", byteCount), command: fill("bytes", byteCount),
description: "Generate bytes exceeding limit", description: "Generate bytes exceeding limit",
}, },
ctx, ctx,
) ))
mustTruncate(result) mustTruncate(result)
expect(result.output).toContain("truncated") expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated") expect(result.output).toContain("The tool call succeeded but the output was truncated")
@ -1069,13 +1069,13 @@ describe("tool.bash truncation", () => {
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const result = await bash.execute( const result = await Effect.runPromise(bash.execute(
{ {
command: "echo hello", command: "echo hello",
description: "Echo hello", description: "Echo hello",
}, },
ctx, ctx,
) ))
expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
expect(result.output).toContain("hello") expect(result.output).toContain("hello")
}, },
@ -1088,13 +1088,13 @@ describe("tool.bash truncation", () => {
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await initBash()
const lineCount = Truncate.MAX_LINES + 100 const lineCount = Truncate.MAX_LINES + 100
const result = await bash.execute( const result = await Effect.runPromise(bash.execute(
{ {
command: fill("lines", lineCount), command: fill("lines", lineCount),
description: "Generate lines for file check", description: "Generate lines for file check",
}, },
ctx, ctx,
) ))
mustTruncate(result) mustTruncate(result)
const filepath = (result.metadata as { outputPath?: string }).outputPath const filepath = (result.metadata as { outputPath?: string }).outputPath

View file

@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time" import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp" import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const ctx = { const ctx = {
@ -17,7 +21,7 @@ const ctx = {
abort: AbortSignal.any([]), abort: AbortSignal.any([]),
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
ask: async () => {}, ask: () => Effect.void,
} }
afterEach(async () => { afterEach(async () => {
@ -29,7 +33,9 @@ async function touch(file: string, time: number) {
await fs.utimes(file, date, date) await fs.utimes(file, date, date)
} }
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer)) const runtime = ManagedRuntime.make(
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
)
afterAll(async () => { afterAll(async () => {
await runtime.dispose() await runtime.dispose()
@ -43,6 +49,12 @@ const resolve = () =>
}), }),
) )
const readFileTime = (sessionID: SessionID, filepath: string) =>
runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath)))
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
describe("tool.edit", () => { describe("tool.edit", () => {
describe("creating new files", () => { describe("creating new files", () => {
test("creates new file when oldString is empty", async () => { test("creates new file when oldString is empty", async () => {
@ -53,14 +65,14 @@ describe("tool.edit", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await resolve() const edit = await resolve()
const result = await edit.execute( const result = await Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "", oldString: "",
newString: "new content", newString: "new content",
}, },
ctx, ctx,
) ))
expect(result.metadata.diff).toContain("new content") expect(result.metadata.diff).toContain("new content")
@ -78,14 +90,14 @@ describe("tool.edit", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await resolve() const edit = await resolve()
await edit.execute( await Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "", oldString: "",
newString: "nested file", newString: "nested file",
}, },
ctx, ctx,
) ))
const content = await fs.readFile(filepath, "utf-8") const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("nested file") expect(content).toBe("nested file")
@ -100,22 +112,20 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file")
const { FileWatcher } = await import("../../src/file/watcher") const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = [] const events: string[] = []
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await resolve() const edit = await resolve()
await edit.execute( await Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "", oldString: "",
newString: "content", newString: "content",
}, },
ctx, ctx,
) ))
expect(events).toContain("updated") expect(events).toContain("updated")
unsubUpdated() unsubUpdated()
@ -133,17 +143,17 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const edit = await resolve() const edit = await resolve()
const result = await edit.execute( const result = await Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "old content", oldString: "old content",
newString: "new content", newString: "new content",
}, },
ctx, ctx,
) ))
expect(result.output).toContain("Edit applied successfully") expect(result.output).toContain("Edit applied successfully")
@ -160,18 +170,18 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const edit = await resolve() const edit = await resolve()
await expect( await expect(
edit.execute( Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "old", oldString: "old",
newString: "new", newString: "new",
}, },
ctx, ctx,
), )),
).rejects.toThrow("not found") ).rejects.toThrow("not found")
}, },
}) })
@ -187,14 +197,14 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
const edit = await resolve() const edit = await resolve()
await expect( await expect(
edit.execute( Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "same", oldString: "same",
newString: "same", newString: "same",
}, },
ctx, ctx,
), )),
).rejects.toThrow("identical") ).rejects.toThrow("identical")
}, },
}) })
@ -208,18 +218,18 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const edit = await resolve() const edit = await resolve()
await expect( await expect(
edit.execute( Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "not in file", oldString: "not in file",
newString: "replacement", newString: "replacement",
}, },
ctx, ctx,
), )),
).rejects.toThrow() ).rejects.toThrow()
}, },
}) })
@ -235,14 +245,14 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
const edit = await resolve() const edit = await resolve()
await expect( await expect(
edit.execute( Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "content", oldString: "content",
newString: "modified", newString: "modified",
}, },
ctx, ctx,
), )),
).rejects.toThrow("You must read file") ).rejects.toThrow("You must read file")
}, },
}) })
@ -258,7 +268,7 @@ describe("tool.edit", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// Read first // Read first
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
// Simulate external modification // Simulate external modification
await fs.writeFile(filepath, "modified externally", "utf-8") await fs.writeFile(filepath, "modified externally", "utf-8")
@ -267,14 +277,14 @@ describe("tool.edit", () => {
// Try to edit with the new content // Try to edit with the new content
const edit = await resolve() const edit = await resolve()
await expect( await expect(
edit.execute( Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "modified externally", oldString: "modified externally",
newString: "edited", newString: "edited",
}, },
ctx, ctx,
), )),
).rejects.toThrow("modified since it was last read") ).rejects.toThrow("modified since it was last read")
}, },
}) })
@ -288,10 +298,10 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const edit = await resolve() const edit = await resolve()
await edit.execute( await Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "foo", oldString: "foo",
@ -299,7 +309,7 @@ describe("tool.edit", () => {
replaceAll: true, replaceAll: true,
}, },
ctx, ctx,
) ))
const content = await fs.readFile(filepath, "utf-8") const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("qux bar qux baz qux") expect(content).toBe("qux bar qux baz qux")
@ -315,23 +325,22 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const { Bus } = await import("../../src/bus")
const { FileWatcher } = await import("../../src/file/watcher") const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = [] const events: string[] = []
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await resolve() const edit = await resolve()
await edit.execute( await Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "original", oldString: "original",
newString: "modified", newString: "modified",
}, },
ctx, ctx,
) ))
expect(events).toContain("updated") expect(events).toContain("updated")
unsubUpdated() unsubUpdated()
@ -349,17 +358,17 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const edit = await resolve() const edit = await resolve()
await edit.execute( await Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "line2", oldString: "line2",
newString: "new line 2\nextra line", newString: "new line 2\nextra line",
}, },
ctx, ctx,
) ))
const content = await fs.readFile(filepath, "utf-8") const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("line1\nnew line 2\nextra line\nline3") expect(content).toBe("line1\nnew line 2\nextra line\nline3")
@ -375,17 +384,17 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const edit = await resolve() const edit = await resolve()
await edit.execute( await Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "old", oldString: "old",
newString: "new", newString: "new",
}, },
ctx, ctx,
) ))
const content = await fs.readFile(filepath, "utf-8") const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("line1\r\nnew\r\nline3") expect(content).toBe("line1\r\nnew\r\nline3")
@ -403,14 +412,14 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
const edit = await resolve() const edit = await resolve()
await expect( await expect(
edit.execute( Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "", oldString: "",
newString: "", newString: "",
}, },
ctx, ctx,
), )),
).rejects.toThrow("identical") ).rejects.toThrow("identical")
}, },
}) })
@ -424,18 +433,18 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, dirpath) await readFileTime(ctx.sessionID, dirpath)
const edit = await resolve() const edit = await resolve()
await expect( await expect(
edit.execute( Effect.runPromise(edit.execute(
{ {
filePath: dirpath, filePath: dirpath,
oldString: "old", oldString: "old",
newString: "new", newString: "new",
}, },
ctx, ctx,
), )),
).rejects.toThrow("directory") ).rejects.toThrow("directory")
}, },
}) })
@ -449,17 +458,17 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const edit = await resolve() const edit = await resolve()
const result = await edit.execute( const result = await Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "line2", oldString: "line2",
newString: "new line a\nnew line b", newString: "new line a\nnew line b",
}, },
ctx, ctx,
) ))
expect(result.metadata.filediff).toBeDefined() expect(result.metadata.filediff).toBeDefined()
expect(result.metadata.filediff.file).toBe(filepath) expect(result.metadata.filediff.file).toBe(filepath)
@ -520,8 +529,8 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
const edit = await resolve() const edit = await resolve()
const filePath = path.join(tmp.path, "test.txt") const filePath = path.join(tmp.path, "test.txt")
await FileTime.read(ctx.sessionID, filePath) await readFileTime(ctx.sessionID, filePath)
await edit.execute( await Effect.runPromise(edit.execute(
{ {
filePath, filePath,
oldString: input.oldString, oldString: input.oldString,
@ -529,7 +538,7 @@ describe("tool.edit", () => {
replaceAll: input.replaceAll, replaceAll: input.replaceAll,
}, },
ctx, ctx,
) ))
return await Bun.file(filePath).text() return await Bun.file(filePath).text()
}, },
}) })
@ -661,31 +670,31 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const edit = await resolve() const edit = await resolve()
// Two concurrent edits // Two concurrent edits
const promise1 = edit.execute( const promise1 = Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "0", oldString: "0",
newString: "1", newString: "1",
}, },
ctx, ctx,
) ))
// Need to read again since FileTime tracks per-session // Need to read again since FileTime tracks per-session
await FileTime.read(ctx.sessionID, filepath) await readFileTime(ctx.sessionID, filepath)
const promise2 = edit.execute( const promise2 = Effect.runPromise(edit.execute(
{ {
filePath: filepath, filePath: filepath,
oldString: "0", oldString: "0",
newString: "2", newString: "2",
}, },
ctx, ctx,
) ))
// Both should complete without error (though one might fail due to content mismatch) // Both should complete without error (though one might fail due to content mismatch)
const results = await Promise.allSettled([promise1, promise2]) const results = await Promise.allSettled([promise1, promise2])

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect"
import type { Tool } from "../../src/tool/tool" import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory" import { assertExternalDirectory } from "../../src/tool/external-directory"
@ -21,15 +22,18 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
const glob = (p: string) => const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
function makeCtx() {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: (req) => Effect.sync(() => { requests.push(req) }),
}
return { requests, ctx }
}
describe("tool.assertExternalDirectory", () => { describe("tool.assertExternalDirectory", () => {
test("no-ops for empty target", async () => { test("no-ops for empty target", async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const { requests, ctx } = makeCtx()
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
await Instance.provide({ await Instance.provide({
directory: "/tmp", directory: "/tmp",
@ -42,13 +46,7 @@ describe("tool.assertExternalDirectory", () => {
}) })
test("no-ops for paths inside Instance.directory", async () => { test("no-ops for paths inside Instance.directory", async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const { requests, ctx } = makeCtx()
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
await Instance.provide({ await Instance.provide({
directory: "/tmp/project", directory: "/tmp/project",
@ -61,13 +59,7 @@ describe("tool.assertExternalDirectory", () => {
}) })
test("asks with a single canonical glob", async () => { test("asks with a single canonical glob", async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const { requests, ctx } = makeCtx()
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
const directory = "/tmp/project" const directory = "/tmp/project"
const target = "/tmp/outside/file.txt" const target = "/tmp/outside/file.txt"
@ -87,13 +79,7 @@ describe("tool.assertExternalDirectory", () => {
}) })
test("uses target directory when kind=directory", async () => { test("uses target directory when kind=directory", async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const { requests, ctx } = makeCtx()
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
const directory = "/tmp/project" const directory = "/tmp/project"
const target = "/tmp/outside" const target = "/tmp/outside"
@ -113,13 +99,7 @@ describe("tool.assertExternalDirectory", () => {
}) })
test("skips prompting when bypass=true", async () => { test("skips prompting when bypass=true", async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const { requests, ctx } = makeCtx()
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
await Instance.provide({ await Instance.provide({
directory: "/tmp/project", directory: "/tmp/project",
@ -133,13 +113,7 @@ describe("tool.assertExternalDirectory", () => {
if (process.platform === "win32") { if (process.platform === "win32") {
test("normalizes Windows path variants to one glob", async () => { test("normalizes Windows path variants to one glob", async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const { requests, ctx } = makeCtx()
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
await using outerTmp = await tmpdir({ await using outerTmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
@ -169,13 +143,7 @@ describe("tool.assertExternalDirectory", () => {
}) })
test("uses drive root glob for root files", async () => { test("uses drive root glob for root files", async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const { requests, ctx } = makeCtx()
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const root = path.parse(tmp.path).root const root = path.parse(tmp.path).root

View file

@ -21,7 +21,7 @@ const ctx = {
abort: AbortSignal.any([]), abort: AbortSignal.any([]),
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
ask: async () => {}, ask: () => Effect.void,
} }
const projectRoot = path.join(__dirname, "../..") const projectRoot = path.join(__dirname, "../..")
@ -32,14 +32,14 @@ describe("tool.grep", () => {
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const grep = await initGrep() const grep = await initGrep()
const result = await grep.execute( const result = await Effect.runPromise(grep.execute(
{ {
pattern: "export", pattern: "export",
path: path.join(projectRoot, "src/tool"), path: path.join(projectRoot, "src/tool"),
include: "*.ts", include: "*.ts",
}, },
ctx, ctx,
) ))
expect(result.metadata.matches).toBeGreaterThan(0) expect(result.metadata.matches).toBeGreaterThan(0)
expect(result.output).toContain("Found") expect(result.output).toContain("Found")
}, },
@ -56,13 +56,13 @@ describe("tool.grep", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const grep = await initGrep() const grep = await initGrep()
const result = await grep.execute( const result = await Effect.runPromise(grep.execute(
{ {
pattern: "xyznonexistentpatternxyz123", pattern: "xyznonexistentpatternxyz123",
path: tmp.path, path: tmp.path,
}, },
ctx, ctx,
) ))
expect(result.metadata.matches).toBe(0) expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found") expect(result.output).toBe("No files found")
}, },
@ -81,13 +81,13 @@ describe("tool.grep", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const grep = await initGrep() const grep = await initGrep()
const result = await grep.execute( const result = await Effect.runPromise(grep.execute(
{ {
pattern: "line", pattern: "line",
path: tmp.path, path: tmp.path,
}, },
ctx, ctx,
) ))
expect(result.metadata.matches).toBeGreaterThan(0) expect(result.metadata.matches).toBeGreaterThan(0)
}, },
}) })

View file

@ -16,7 +16,7 @@ const ctx = {
abort: AbortSignal.any([]), abort: AbortSignal.any([]),
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
ask: async () => {}, ask: () => Effect.void,
} }
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
@ -49,7 +49,7 @@ describe("tool.question", () => {
}, },
] ]
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped) const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question) const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Red"]] }) yield* question.reply({ requestID: item.id, answers: [["Red"]] })
@ -73,7 +73,7 @@ describe("tool.question", () => {
}, },
] ]
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped) const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question) const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) yield* question.reply({ requestID: item.id, answers: [["Dog"]] })

View file

@ -30,7 +30,7 @@ const ctx = {
abort: AbortSignal.any([]), abort: AbortSignal.any([]),
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
ask: async () => {}, ask: () => Effect.void,
} }
const it = testEffect( const it = testEffect(
@ -54,7 +54,7 @@ const run = Effect.fn("ReadToolTest.run")(function* (
next: Tool.Context = ctx, next: Tool.Context = ctx,
) { ) {
const tool = yield* init() const tool = yield* init()
return yield* Effect.promise(() => tool.execute(args, next)) return yield* tool.execute(args, next)
}) })
const exec = Effect.fn("ReadToolTest.exec")(function* ( const exec = Effect.fn("ReadToolTest.exec")(function* (
@ -95,9 +95,8 @@ const asks = () => {
items, items,
next: { next: {
...ctx, ...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => { ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
items.push(req) Effect.sync(() => { items.push(req) }),
},
}, },
} }
} }
@ -226,17 +225,18 @@ describe("tool.read env file permissions", () => {
let asked = false let asked = false
const next = { const next = {
...ctx, ...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => { ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
for (const pattern of req.patterns) { Effect.sync(() => {
const rule = Permission.evaluate(req.permission, pattern, info.permission) for (const pattern of req.patterns) {
if (rule.action === "ask" && req.permission === "read") { const rule = Permission.evaluate(req.permission, pattern, info.permission)
asked = true if (rule.action === "ask" && req.permission === "read") {
asked = true
}
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset: info.permission })
}
} }
if (rule.action === "deny") { }),
throw new Permission.DeniedError({ ruleset: info.permission })
}
}
},
} }
yield* run({ filePath: path.join(dir, filename) }, next) yield* run({ filePath: path.join(dir, filename) }, next)

View file

@ -156,12 +156,11 @@ Use this skill.
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = { const ctx: Tool.Context = {
...baseCtx, ...baseCtx,
ask: async (req) => { ask: (req) =>
requests.push(req) Effect.sync(() => { requests.push(req) }),
},
} }
const result = await tool.execute({ name: "tool-skill" }, ctx) const result = await runtime.runPromise(tool.execute({ name: "tool-skill" }, ctx))
const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill") const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill")
const file = path.resolve(dir, "scripts", "demo.txt") const file = path.resolve(dir, "scripts", "demo.txt")

View file

@ -194,8 +194,7 @@ describe("tool.task", () => {
let seen: SessionPrompt.PromptInput | undefined let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
const result = yield* Effect.promise(() => const result = yield* def.execute(
def.execute(
{ {
description: "inspect bug", description: "inspect bug",
prompt: "look into the cache key path", prompt: "look into the cache key path",
@ -210,10 +209,9 @@ describe("tool.task", () => {
extra: { promptOps }, extra: { promptOps },
messages: [], messages: [],
metadata() {}, metadata() {},
ask: async () => {}, ask: () => Effect.void,
}, },
), )
)
const kids = yield* sessions.children(chat.id) const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1) expect(kids).toHaveLength(1)
@ -235,8 +233,7 @@ describe("tool.task", () => {
const promptOps = stubOps() const promptOps = stubOps()
const exec = (extra?: Record<string, any>) => const exec = (extra?: Record<string, any>) =>
Effect.promise(() => def.execute(
def.execute(
{ {
description: "inspect bug", description: "inspect bug",
prompt: "look into the cache key path", prompt: "look into the cache key path",
@ -250,12 +247,10 @@ describe("tool.task", () => {
extra: { promptOps, ...extra }, extra: { promptOps, ...extra },
messages: [], messages: [],
metadata() {}, metadata() {},
ask: async (input) => { ask: (input) =>
calls.push(input) Effect.sync(() => { calls.push(input) }),
},
}, },
), )
)
yield* exec() yield* exec()
yield* exec({ bypassAgentCheck: true }) yield* exec({ bypassAgentCheck: true })
@ -284,8 +279,7 @@ describe("tool.task", () => {
let seen: SessionPrompt.PromptInput | undefined let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
const result = yield* Effect.promise(() => const result = yield* def.execute(
def.execute(
{ {
description: "inspect bug", description: "inspect bug",
prompt: "look into the cache key path", prompt: "look into the cache key path",
@ -300,10 +294,9 @@ describe("tool.task", () => {
extra: { promptOps }, extra: { promptOps },
messages: [], messages: [],
metadata() {}, metadata() {},
ask: async () => {}, ask: () => Effect.void,
}, },
), )
)
const kids = yield* sessions.children(chat.id) const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1) expect(kids).toHaveLength(1)
@ -326,8 +319,7 @@ describe("tool.task", () => {
let seen: SessionPrompt.PromptInput | undefined let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
const result = yield* Effect.promise(() => const result = yield* def.execute(
def.execute(
{ {
description: "inspect bug", description: "inspect bug",
prompt: "look into the cache key path", prompt: "look into the cache key path",
@ -341,10 +333,9 @@ describe("tool.task", () => {
extra: { promptOps }, extra: { promptOps },
messages: [], messages: [],
metadata() {}, metadata() {},
ask: async () => {}, ask: () => Effect.void,
}, },
), )
)
const child = yield* sessions.get(result.metadata.sessionId) const child = yield* sessions.get(result.metadata.sessionId)
expect(child.parentID).toBe(chat.id) expect(child.parentID).toBe(chat.id)

View file

@ -1,4 +1,5 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
@ -8,9 +9,9 @@ function makeTool(id: string, executeFn?: () => void) {
return { return {
description: "test tool", description: "test tool",
parameters: params, parameters: params,
async execute() { execute() {
executeFn?.() executeFn?.()
return { title: "test", output: "ok", metadata: {} } return Effect.succeed({ title: "test", output: "ok", metadata: {} })
}, },
} }
} }
@ -20,29 +21,31 @@ describe("Tool.define", () => {
const original = makeTool("test") const original = makeTool("test")
const originalExecute = original.execute const originalExecute = original.execute
const tool = Tool.define("test-tool", original) const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
await tool.init() await info.init()
await tool.init() await info.init()
await tool.init() await info.init()
expect(original.execute).toBe(originalExecute) expect(original.execute).toBe(originalExecute)
}) })
test("function-defined tool returns fresh objects and is unaffected", async () => { test("function-defined tool returns fresh objects and is unaffected", async () => {
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test"))) const info = await Effect.runPromise(
Tool.define("test-fn-tool", Effect.succeed(() => Promise.resolve(makeTool("test")))),
)
const first = await tool.init() const first = await info.init()
const second = await tool.init() const second = await info.init()
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })
test("object-defined tool returns distinct objects per init() call", async () => { test("object-defined tool returns distinct objects per init() call", async () => {
const tool = Tool.define("test-copy", makeTool("test")) const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
const first = await tool.init() const first = await info.init()
const second = await tool.init() const second = await info.init()
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })

View file

@ -16,7 +16,7 @@ const ctx = {
abort: AbortSignal.any([]), abort: AbortSignal.any([]),
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
ask: async () => {}, ask: () => Effect.void,
} }
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) { async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
@ -42,10 +42,10 @@ describe("tool.webfetch", () => {
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await initTool() const webfetch = await initTool()
const result = await webfetch.execute( const result = await Effect.runPromise(webfetch.execute(
{ url: new URL("/image.png", url).toString(), format: "markdown" }, { url: new URL("/image.png", url).toString(), format: "markdown" },
ctx, ctx,
) ))
expect(result.output).toBe("Image fetched successfully") expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined() expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1) expect(result.attachments?.length).toBe(1)
@ -74,7 +74,7 @@ describe("tool.webfetch", () => {
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await initTool() const webfetch = await initTool()
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx) const result = await Effect.runPromise(webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx))
expect(result.output).toContain("<svg") expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
}, },
@ -95,7 +95,7 @@ describe("tool.webfetch", () => {
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await initTool() const webfetch = await initTool()
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx) const result = await Effect.runPromise(webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx))
expect(result.output).toBe("hello from webfetch") expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
}, },

View file

@ -7,6 +7,8 @@ import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp" import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { FileTime } from "../../src/file/time" import { FileTime } from "../../src/file/time"
import { Bus } from "../../src/bus"
import { Format } from "../../src/format"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@ -21,7 +23,7 @@ const ctx = {
abort: AbortSignal.any([]), abort: AbortSignal.any([]),
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
ask: async () => {}, ask: () => Effect.void,
} }
afterEach(async () => { afterEach(async () => {
@ -29,7 +31,7 @@ afterEach(async () => {
}) })
const it = testEffect( const it = testEffect(
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer), Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, Bus.layer, Format.defaultLayer, CrossSpawnSpawner.defaultLayer),
) )
const init = Effect.fn("WriteToolTest.init")(function* () { const init = Effect.fn("WriteToolTest.init")(function* () {
@ -42,7 +44,7 @@ const run = Effect.fn("WriteToolTest.run")(function* (
next: Tool.Context = ctx, next: Tool.Context = ctx,
) { ) {
const tool = yield* init() const tool = yield* init()
return yield* Effect.promise(() => tool.execute(args, next)) return yield* tool.execute(args, next)
}) })
const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) { const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {