diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 458f92547437..fde64e53f89f 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -1,5 +1,6 @@ import { EOL } from "os" import { basename } from "path" +import { Effect } from "effect" import { Agent } from "../../../agent/agent" import { Provider } from "../../../provider/provider" import { Session } from "../../../session" @@ -158,13 +159,15 @@ async function createToolContext(agent: Agent.Info) { abort: new AbortController().signal, messages: [], metadata: () => {}, - async ask(req: Omit) { - for (const pattern of req.patterns) { - const rule = Permission.evaluate(req.permission, pattern, ruleset) - if (rule.action === "deny") { - throw new Permission.DeniedError({ ruleset }) + ask(req: Omit) { + return Effect.sync(() => { + for (const pattern of req.patterns) { + const rule = Permission.evaluate(req.permission, pattern, ruleset) + if (rule.action === "deny") { + throw new Permission.DeniedError({ ruleset }) + } } - } + }) }, } } diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index d5ca3db85342..6af71e91a176 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -34,7 +34,7 @@ export namespace FileTime { readonly read: (sessionID: SessionID, file: string) => Effect.Effect readonly get: (sessionID: SessionID, file: string) => Effect.Effect readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect - readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect + readonly withLock: (filepath: string, fn: () => Effect.Effect) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/FileTime") {} @@ -103,8 +103,8 @@ export namespace FileTime { ) }) - const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Promise) { - return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1)) + const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Effect.Effect) { + return yield* fn().pipe((yield* getLock(filepath)).withPermits(1)) }) return Service.of({ read, get, assert, withLock }) @@ -128,6 +128,6 @@ export namespace FileTime { } export async function withLock(filepath: string, fn: () => Promise): Promise { - return runPromise((s) => s.withLock(filepath, fn)) + return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn))) } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 50923d78b546..6c0c55e2b422 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -103,6 +103,13 @@ export namespace SessionPrompt { const state = yield* SessionRunState.Service const revert = yield* SessionRevert.Service + const run = { + promise: (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))), + fork: (effect: Effect.Effect) => + Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))), + } + const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* elog.info("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, messages: input.messages, metadata: (val) => - Effect.runPromise( + run.promise( input.processor.updateToolCall(options.toolCallId, (match) => { if (!["running", "pending"].includes(match.state.status)) return match return { @@ -374,14 +381,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ), ask: (req) => - Effect.runPromise( - permission.ask({ + permission + .ask({ ...req, sessionID: input.session.id, tool: { messageID: input.processor.message.id, callID: options.toolCallId }, ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), - }), - ), + }) + .pipe(Effect.orDie), }) 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, inputSchema: jsonSchema(schema as any), execute(args, options) { - return Effect.runPromise( + return run.promise( Effect.gen(function* () { const ctx = context(args, options) 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 }, { args }, ) - const result = yield* Effect.promise(() => item.execute(args, ctx)) + const result = yield* item.execute(args, ctx) const output = { ...result, 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) item.inputSchema = jsonSchema(transformed) item.execute = (args, opts) => - Effect.runPromise( + run.promise( Effect.gen(function* () { const ctx = context(args, opts) 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 }, { args }, ) - yield* Effect.promise(() => ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })) + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) const result: Awaited>> = yield* Effect.promise(() => 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 - const result = yield* Effect.promise((signal) => - taskTool - .execute(taskArgs, { - agent: task.agent, - messageID: assistantMessage.id, - sessionID, - abort: signal, - callID: part.callID, - extra: { bypassAgentCheck: true, promptOps }, - messages: msgs, - metadata(val: { title?: string; metadata?: Record }) { - return Effect.runPromise( - Effect.gen(function* () { - part = yield* sessions.updatePart({ - ...part, - type: "tool", - state: { ...part.state, ...val }, - } satisfies MessageV2.ToolPart) - }), - ) - }, - ask(req: any) { - return Effect.runPromise( - permission.ask({ - ...req, - sessionID, - ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), - }), - ) - }, - }) - .catch((e) => { - error = e instanceof Error ? e : new Error(String(e)) + const taskAbort = new AbortController() + const result = yield* taskTool + .execute(taskArgs, { + agent: task.agent, + messageID: assistantMessage.id, + sessionID, + abort: taskAbort.signal, + callID: part.callID, + extra: { bypassAgentCheck: true, promptOps }, + messages: msgs, + metadata(val: { title?: string; metadata?: Record }) { + return run.promise( + Effect.gen(function* () { + part = yield* sessions.updatePart({ + ...part, + type: "tool", + state: { ...part.state, ...val }, + } satisfies MessageV2.ToolPart) + }), + ) + }, + ask: (req: any) => + permission + .ask({ + ...req, + sessionID, + ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), + }) + .pipe(Effect.orDie), + }) + .pipe( + Effect.catchCause((cause) => { + 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 }) - return undefined + return Effect.void }), - ).pipe( - Effect.onInterrupt(() => - Effect.gen(function* () { + Effect.onInterrupt(() => + Effect.gen(function* () { + taskAbort.abort() assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() 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) } - }), - ), - ) + })), + ) const attachments = result?.attachments?.map((attachment) => ({ ...attachment, @@ -855,7 +862,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the output += chunk if (part.state.status === "running") { 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" const { read } = yield* registry.named() - const execRead = (args: Parameters[0], extra?: Tool.Context["extra"]) => - Effect.promise((signal: AbortSignal) => - read.execute(args, { + const execRead = (args: Parameters[0], extra?: Tool.Context["extra"]) => { + const controller = new AbortController() + return read + .execute(args, { sessionID: input.sessionID, - abort: signal, + abort: controller.signal, agent: input.agent!, messageID: info.id, extra: { bypassCwdCheck: true, ...extra }, messages: [], - metadata: async () => {}, - ask: async () => {}, - }), - ) + metadata: () => {}, + ask: () => Effect.void, + }) + .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort()))) + } if (part.mime === "text/plain") { 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 = { - cancel: (sessionID) => Effect.runFork(cancel(sessionID)), - resolvePromptParts: (template) => Effect.runPromise(resolvePromptParts(template)), - prompt: (input) => Effect.runPromise(prompt(input)), + cancel: (sessionID) => run.fork(cancel(sessionID)), + resolvePromptParts: (template) => run.promise(resolvePromptParts(template)), + prompt: (input) => run.promise(prompt(input)), } return Service.of({ diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 91adc5b927dc..fd38a9b22487 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -19,12 +19,13 @@ const PatchParams = z.object({ 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", Effect.gen(function* () { const lsp = yield* LSP.Service const afs = yield* AppFileSystem.Service const format = yield* Format.Service + const bus = yield* Bus.Service const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer, ctx: Tool.Context) { if (!params.patchText) { @@ -178,18 +179,16 @@ export const ApplyPatchTool = Tool.defineEffect( // Check permissions if needed const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) - yield* Effect.promise(() => - ctx.ask({ - permission: "edit", - patterns: relativePaths, - always: ["*"], - metadata: { - filepath: relativePaths.join(", "), - diff: totalDiff, - files, - }, - }), - ) + yield* ctx.ask({ + permission: "edit", + patterns: relativePaths, + always: ["*"], + metadata: { + filepath: relativePaths.join(", "), + diff: totalDiff, + files, + }, + }) // Apply the changes const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] @@ -228,13 +227,13 @@ export const ApplyPatchTool = Tool.defineEffect( if (edited) { yield* format.file(edited) - Bus.publish(File.Event.Edited, { file: edited }) + yield* bus.publish(File.Event.Edited, { file: edited }) } } // Publish file change events 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 @@ -281,9 +280,7 @@ export const ApplyPatchTool = Tool.defineEffect( return { description: DESCRIPTION, parameters: PatchParams, - async execute(params: z.infer, ctx) { - return Effect.runPromise(run(params, ctx).pipe(Effect.orDie)) - }, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index abcb9e327275..2f81e56ae907 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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, "*")) return path.join(dir, "*") }) - yield* Effect.promise(() => - ctx.ask({ - permission: "external_directory", - patterns: globs, - always: globs, - metadata: {}, - }), - ) + yield* ctx.ask({ + permission: "external_directory", + patterns: globs, + always: globs, + metadata: {}, + }) } if (scan.patterns.size === 0) return - yield* Effect.promise(() => - ctx.ask({ - permission: "bash", - patterns: Array.from(scan.patterns), - always: Array.from(scan.always), - metadata: {}, - }), - ) + yield* ctx.ask({ + permission: "bash", + patterns: Array.from(scan.patterns), + always: Array.from(scan.always), + metadata: {}, + }) }) 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 -export const BashTool = Tool.defineEffect( +export const BashTool = Tool.define( "bash", Effect.gen(function* () { const spawner = yield* ChildProcessSpawner @@ -504,7 +500,7 @@ export const BashTool = Tool.defineEffect( }, ctx, ) - }).pipe(Effect.orDie, Effect.runPromise), + }), } } }), diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index 7e167df55810..d4d5779bf306 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -5,7 +5,7 @@ import { Tool } from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./codesearch.txt" -export const CodeSearchTool = Tool.defineEffect( +export const CodeSearchTool = Tool.define( "codesearch", Effect.gen(function* () { const http = yield* HttpClient.HttpClient @@ -29,17 +29,15 @@ export const CodeSearchTool = Tool.defineEffect( }), execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => Effect.gen(function* () { - yield* Effect.promise(() => - ctx.ask({ - permission: "codesearch", - patterns: [params.query], - always: ["*"], - metadata: { - query: params.query, - tokensNum: params.tokensNum, - }, - }), - ) + yield* ctx.ask({ + permission: "codesearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + tokensNum: params.tokensNum, + }, + }) const result = yield* McpExa.call( http, @@ -59,7 +57,7 @@ export const CodeSearchTool = Tool.defineEffect( title: `Code search: ${params.query}`, metadata: {}, } - }).pipe(Effect.runPromise), + }).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 7988ff9430d9..a076d054f444 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -19,6 +19,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" +import { AppFileSystem } from "../filesystem" function normalizeLineEndings(text: string): string { 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)"), }) -export const EditTool = Tool.defineEffect( +export const EditTool = Tool.define( "edit", Effect.gen(function* () { const lsp = yield* LSP.Service const filetime = yield* FileTime.Service + const afs = yield* AppFileSystem.Service + const format = yield* Format.Service + const bus = yield* Bus.Service return { description: DESCRIPTION, @@ -67,12 +71,53 @@ export const EditTool = Tool.defineEffect( let diff = "" let contentOld = "" let contentNew = "" - yield* filetime.withLock(filePath, async () => { - if (params.oldString === "") { - const existed = await Filesystem.exists(filePath) - contentNew = params.newString - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - await ctx.ask({ + yield* filetime.withLock(filePath, () => + Effect.gen(function* () { + if (params.oldString === "") { + const existed = yield* afs.existsSafe(filePath) + contentNew = params.newString + 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", patterns: [path.relative(Instance.worktree, filePath)], always: ["*"], @@ -81,65 +126,26 @@ export const EditTool = Tool.defineEffect( diff, }, }) - await Filesystem.write(filePath, params.newString) - await Format.file(filePath) - Bus.publish(File.Event.Edited, { file: filePath }) - await Bus.publish(FileWatcher.Event.Updated, { + + yield* afs.writeWithDirs(filePath, contentNew) + yield* format.file(filePath) + yield* bus.publish(File.Event.Edited, { file: filePath }) + yield* bus.publish(FileWatcher.Event.Updated, { file: filePath, - event: existed ? "change" : "add", + event: "change", }) - await FileTime.read(ctx.sessionID, filePath) - return - } - - const stats = Filesystem.stat(filePath) - if (!stats) throw new Error(`File ${filePath} not found`) - if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) - await FileTime.assert(ctx.sessionID, filePath) - contentOld = await Filesystem.readText(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), - ), - ) - 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) - }) + contentNew = yield* afs.readFileString(filePath) + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) + yield* filetime.read(ctx.sessionID, filePath) + }).pipe(Effect.orDie), + ) const filediff: Snapshot.FileDiff = { file: filePath, @@ -176,7 +182,7 @@ export const EditTool = Tool.defineEffect( title: `${path.relative(Instance.worktree, filePath)}`, output, } - }).pipe(Effect.orDie, Effect.runPromise), + }), } }), ) diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index f11455cf5975..ed9d2af2fb26 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -11,7 +11,11 @@ type Options = { 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 (options?.bypass) return @@ -26,7 +30,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string ? AppFileSystem.normalizePathPattern(path.join(dir, "*")) : path.join(dir, "*").replaceAll("\\", "/") - await ctx.ask({ + yield* ctx.ask({ permission: "external_directory", patterns: [glob], always: [glob], @@ -35,12 +39,8 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string 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)) +} diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 973f14699236..a3ff5aef71be 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -9,7 +9,7 @@ import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "../filesystem" -export const GlobTool = Tool.defineEffect( +export const GlobTool = Tool.define( "glob", Effect.gen(function* () { const rg = yield* Ripgrep.Service @@ -28,17 +28,15 @@ export const GlobTool = Tool.defineEffect( }), execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => Effect.gen(function* () { - yield* Effect.promise(() => - ctx.ask({ - permission: "glob", - patterns: [params.pattern], - always: ["*"], - metadata: { - pattern: params.pattern, - path: params.path, - }, - }), - ) + yield* ctx.ask({ + permission: "glob", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + }, + }) let search = params.path ?? Instance.directory search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) @@ -90,7 +88,7 @@ export const GlobTool = Tool.defineEffect( }, output: output.join("\n"), } - }).pipe(Effect.orDie, Effect.runPromise), + }).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 8f53c2e21a57..b5ae6c35034c 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -14,7 +14,7 @@ import { assertExternalDirectoryEffect } from "./external-directory" const MAX_LINE_LENGTH = 2000 -export const GrepTool = Tool.defineEffect( +export const GrepTool = Tool.define( "grep", Effect.gen(function* () { const spawner = yield* ChildProcessSpawner @@ -32,18 +32,16 @@ export const GrepTool = Tool.defineEffect( throw new Error("pattern is required") } - yield* Effect.promise(() => - ctx.ask({ - permission: "grep", - patterns: [params.pattern], - always: ["*"], - metadata: { - pattern: params.pattern, - path: params.path, - include: params.include, - }, - }), - ) + yield* ctx.ask({ + permission: "grep", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + include: params.include, + }, + }) let searchPath = params.path ?? Instance.directory searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) @@ -171,7 +169,7 @@ export const GrepTool = Tool.defineEffect( }, output: outputLines.join("\n"), } - }).pipe(Effect.orDie, Effect.runPromise), + }).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index 728e9c89fffd..b9794ed5fdaa 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -1,17 +1,20 @@ import z from "zod" +import { Effect } from "effect" import { Tool } from "./tool" -export const InvalidTool = Tool.define("invalid", { - description: "Do not use", - parameters: z.object({ - tool: z.string(), - error: z.string(), +export const InvalidTool = Tool.define( + "invalid", + Effect.succeed({ + description: "Do not use", + 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: {}, - } - }, -}) +) diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index 2453b6e9cdf3..600a5532aa65 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -37,7 +37,7 @@ export const IGNORE_PATTERNS = [ const LIMIT = 100 -export const ListTool = Tool.defineEffect( +export const ListTool = Tool.define( "list", Effect.gen(function* () { const rg = yield* Ripgrep.Service @@ -56,16 +56,14 @@ export const ListTool = Tool.defineEffect( const searchPath = path.resolve(Instance.directory, params.path || ".") yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) - yield* Effect.promise(() => - ctx.ask({ - permission: "list", - patterns: [searchPath], - always: ["*"], - metadata: { - path: searchPath, - }, - }), - ) + yield* ctx.ask({ + permission: "list", + patterns: [searchPath], + always: ["*"], + metadata: { + path: searchPath, + }, + }) const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe( @@ -130,7 +128,7 @@ export const ListTool = Tool.defineEffect( }, output, } - }).pipe(Effect.orDie, Effect.runPromise), + }).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index ac0b4c6fcc7c..c5a5d6f8197e 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -21,7 +21,7 @@ const operations = [ "outgoingCalls", ] as const -export const LspTool = Tool.defineEffect( +export const LspTool = Tool.define( "lsp", Effect.gen(function* () { const lsp = yield* LSP.Service @@ -42,7 +42,7 @@ export const LspTool = Tool.defineEffect( Effect.gen(function* () { const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) 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 position = { file, line: args.line - 1, character: args.character - 1 } @@ -85,7 +85,7 @@ export const LspTool = Tool.defineEffect( metadata: { result }, output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2), } - }).pipe(Effect.runPromise), + }), } }), ) diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index f84ddaf03947..82d6988c2804 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -6,7 +6,7 @@ import DESCRIPTION from "./multiedit.txt" import path from "path" import { Instance } from "../project/instance" -export const MultiEditTool = Tool.defineEffect( +export const MultiEditTool = Tool.define( "multiedit", Effect.gen(function* () { const editInfo = yield* EditTool @@ -37,16 +37,14 @@ export const MultiEditTool = Tool.defineEffect( Effect.gen(function* () { const results = [] for (const [, entry] of params.edits.entries()) { - const result = yield* Effect.promise(() => - edit.execute( - { - filePath: params.filePath, - oldString: entry.oldString, - newString: entry.newString, - replaceAll: entry.replaceAll, - }, - ctx, - ), + const result = yield* edit.execute( + { + filePath: params.filePath, + oldString: entry.oldString, + newString: entry.newString, + replaceAll: entry.replaceAll, + }, + ctx, ) results.push(result) } @@ -57,7 +55,7 @@ export const MultiEditTool = Tool.defineEffect( }, output: results.at(-1)!.output, } - }).pipe(Effect.orDie, Effect.runPromise), + }), } }), ) diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index aa9c698842ac..1613821fe0bd 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -17,7 +17,7 @@ function getLastModel(sessionID: SessionID) { return undefined } -export const PlanExitTool = Tool.defineEffect( +export const PlanExitTool = Tool.define( "plan_exit", Effect.gen(function* () { 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.", metadata: {}, } - }).pipe(Effect.runPromise), + }).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index f7adbadcf7d7..8cfa700a5ac5 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -12,7 +12,7 @@ type Metadata = { answers: Question.Answer[] } -export const QuestionTool = Tool.defineEffect( +export const QuestionTool = Tool.define( "question", Effect.gen(function* () { const question = yield* Question.Service @@ -39,7 +39,7 @@ export const QuestionTool = Tool.defineEffect - ctx.ask({ - permission: "read", - patterns: [filepath], - always: ["*"], - metadata: {}, - }), - ) + yield* ctx.ask({ + permission: "read", + patterns: [filepath], + always: ["*"], + metadata: {}, + }) if (!stat) return yield* miss(filepath) @@ -218,9 +216,7 @@ export const ReadTool = Tool.defineEffect( return { description: DESCRIPTION, parameters, - async execute(params: z.infer, ctx) { - return Effect.runPromise(run(params, ctx).pipe(Effect.orDie)) - }, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f6324b3d7690..7ba99c0c9758 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -44,6 +44,7 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import { Instruction } from "../session/instruction" import { AppFileSystem } from "../filesystem" +import { Bus } from "../bus" import { Agent } from "../agent/agent" import { Skill } from "../skill" import { Permission } from "@/permission" @@ -89,10 +90,12 @@ export namespace ToolRegistry { | FileTime.Service | Instruction.Service | AppFileSystem.Service + | Bus.Service | HttpClient.HttpClient | ChildProcessSpawner | Ripgrep.Service | Format.Service + | Truncate.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -100,7 +103,9 @@ export namespace ToolRegistry { const plugin = yield* Plugin.Service const agents = yield* Agent.Service const skill = yield* Skill.Service + const truncate = yield* Truncate.Service + const invalid = yield* InvalidTool const task = yield* TaskTool const read = yield* ReadTool const question = yield* QuestionTool @@ -127,23 +132,26 @@ export namespace ToolRegistry { id, parameters: z.object(def.args), description: def.description, - execute: async (args, toolCtx) => { - const pluginCtx: PluginToolContext = { - ...toolCtx, - directory: ctx.directory, - worktree: ctx.worktree, - } - const result = await def.execute(args as any, pluginCtx) - const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent)) - return { - title: "", - output: out.truncated ? out.content : result, - metadata: { - truncated: out.truncated, - outputPath: out.truncated ? out.outputPath : undefined, - }, - } - }, + execute: (args, toolCtx) => + Effect.gen(function* () { + const pluginCtx: PluginToolContext = { + ...toolCtx, + ask: (req) => Effect.runPromise(toolCtx.ask(req)), + directory: ctx.directory, + worktree: ctx.worktree, + } + const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) + const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent)) + const out = yield* truncate.output(result, {}, agent) + return { + title: "", + 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 const tool = yield* Effect.all({ - invalid: Tool.init(InvalidTool), + invalid: Tool.init(invalid), bash: Tool.init(bash), read: Tool.init(read), glob: Tool.init(globtool), @@ -328,10 +336,12 @@ export namespace ToolRegistry { Layer.provide(FileTime.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Truncate.defaultLayer), ), ) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index f53f4e2bca26..22eac69cf824 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -11,7 +11,7 @@ const Parameters = z.object({ name: z.string().describe("The name of the skill from available_skills"), }) -export const SkillTool = Tool.defineEffect( +export const SkillTool = Tool.define( "skill", Effect.gen(function* () { 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"}`) } - yield* Effect.promise(() => - ctx.ask({ - permission: "skill", - patterns: [params.name], - always: [params.name], - metadata: {}, - }), - ) + yield* ctx.ask({ + permission: "skill", + patterns: [params.name], + always: [params.name], + metadata: {}, + }) const dir = path.dirname(info.location) const base = pathToFileURL(dir).href @@ -94,7 +92,7 @@ export const SkillTool = Tool.defineEffect( dir, }, } - }).pipe(Effect.orDie, Effect.runPromise), + }).pipe(Effect.orDie), } } }), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 440691e46d29..3829aeae119a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -31,7 +31,7 @@ const parameters = z.object({ command: z.string().describe("The command that triggered this task").optional(), }) -export const TaskTool = Tool.defineEffect( +export const TaskTool = Tool.define( id, Effect.gen(function* () { const agent = yield* Agent.Service @@ -41,17 +41,15 @@ export const TaskTool = Tool.defineEffect( const cfg = yield* config.get() if (!ctx.extra?.bypassAgentCheck) { - yield* Effect.promise(() => - ctx.ask({ - permission: id, - patterns: [params.subagent_type], - always: ["*"], - metadata: { - description: params.description, - subagent_type: params.subagent_type, - }, - }), - ) + yield* ctx.ask({ + permission: id, + patterns: [params.subagent_type], + always: ["*"], + metadata: { + description: params.description, + subagent_type: params.subagent_type, + }, + }) } const next = yield* agent.get(params.subagent_type) @@ -178,9 +176,7 @@ export const TaskTool = Tool.defineEffect( return { description: DESCRIPTION, parameters, - async execute(params: z.infer, ctx) { - return Effect.runPromise(run(params, ctx)) - }, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 92318164c667..253bcfa32ab8 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -12,7 +12,7 @@ type Metadata = { todos: Todo.Info[] } -export const TodoWriteTool = Tool.defineEffect( +export const TodoWriteTool = Tool.define( "todowrite", Effect.gen(function* () { const todo = yield* Todo.Service @@ -20,29 +20,28 @@ export const TodoWriteTool = Tool.defineEffect, ctx: Tool.Context) { - await ctx.ask({ - permission: "todowrite", - patterns: ["*"], - always: ["*"], - metadata: {}, - }) + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "todowrite", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) - await todo - .update({ + yield* todo.update({ sessionID: ctx.sessionID, todos: params.todos, }) - .pipe(Effect.runPromise) - return { - title: `${params.todos.filter((x) => x.status !== "completed").length} todos`, - output: JSON.stringify(params.todos, null, 2), - metadata: { - todos: params.todos, - }, - } - }, + return { + title: `${params.todos.filter((x) => x.status !== "completed").length} todos`, + output: JSON.stringify(params.todos, null, 2), + metadata: { + todos: params.todos, + }, + } + }), } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index ae347341cccc..254cdb911479 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -23,22 +23,21 @@ export namespace Tool { extra?: { [key: string]: any } messages: MessageV2.WithParts[] metadata(input: { title?: string; metadata?: M }): void - ask(input: Omit): Promise + ask(input: Omit): Effect.Effect + } + + export interface ExecuteResult { + title: string + metadata: M + output: string + attachments?: Omit[] } export interface Def { id: string description: string parameters: Parameters - execute( - args: z.infer, - ctx: Context, - ): Promise<{ - title: string - metadata: M - output: string - attachments?: Omit[] - }> + execute(args: z.infer, ctx: Context): Effect.Effect> formatValidationError?(error: z.ZodError): string } export type DefWithoutID = Omit< @@ -74,48 +73,41 @@ export namespace Tool { return async () => { const toolInfo = init instanceof Function ? await init() : { ...init } const execute = toolInfo.execute - toolInfo.execute = async (args, ctx) => { - try { - toolInfo.parameters.parse(args) - } catch (error) { - if (error instanceof z.ZodError && toolInfo.formatValidationError) { - throw new Error(toolInfo.formatValidationError(error), { cause: error }) + toolInfo.execute = (args, ctx) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => toolInfo.parameters.parse(args), + catch: (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( - `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, - { cause: error }, - ) - } - const result = await execute(args, ctx) - if (result.metadata.truncated !== undefined) { - return result - } - const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent)) - return { - ...result, - output: truncated.content, - metadata: { - ...result.metadata, - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - }, - } - } + const agent = yield* Effect.promise(() => Agent.get(ctx.agent)) + const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent)) + return { + ...result, + output: truncated.content, + metadata: { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + } + }).pipe(Effect.orDie) return toolInfo } } - export function define( - id: ID, - init: (() => Promise>) | DefWithoutID, - ): Info & { id: ID } { - return { - id, - init: wrap(id, init), - } - } - - export function defineEffect( + export function define( id: ID, init: Effect.Effect<(() => Promise>) | DefWithoutID, never, R>, ): Effect.Effect, never, R> & { id: ID } { diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 1c89d950a322..9339038b0ffa 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -18,7 +18,7 @@ const parameters = z.object({ timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), }) -export const WebFetchTool = Tool.defineEffect( +export const WebFetchTool = Tool.define( "webfetch", Effect.gen(function* () { const http = yield* HttpClient.HttpClient @@ -33,18 +33,16 @@ export const WebFetchTool = Tool.defineEffect( throw new Error("URL must start with http:// or https://") } - yield* Effect.promise(() => - ctx.ask({ - permission: "webfetch", - patterns: [params.url], - always: ["*"], - metadata: { - url: params.url, - format: params.format, - timeout: params.timeout, - }, - }), - ) + yield* ctx.ask({ + permission: "webfetch", + patterns: [params.url], + always: ["*"], + metadata: { + url: params.url, + format: params.format, + timeout: params.timeout, + }, + }) const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) @@ -153,7 +151,7 @@ export const WebFetchTool = Tool.defineEffect( default: return { output: content, title, metadata: {} } } - }).pipe(Effect.runPromise), + }).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index be7b9b399328..968e1e34b6e4 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -24,7 +24,7 @@ const Parameters = z.object({ .describe("Maximum characters for context string optimized for LLMs (default: 10000)"), }) -export const WebSearchTool = Tool.defineEffect( +export const WebSearchTool = Tool.define( "websearch", Effect.gen(function* () { const http = yield* HttpClient.HttpClient @@ -36,20 +36,18 @@ export const WebSearchTool = Tool.defineEffect( parameters: Parameters, execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { - yield* Effect.promise(() => - ctx.ask({ - permission: "websearch", - patterns: [params.query], - always: ["*"], - metadata: { - query: params.query, - numResults: params.numResults, - livecrawl: params.livecrawl, - type: params.type, - contextMaxCharacters: params.contextMaxCharacters, - }, - }), - ) + yield* ctx.ask({ + permission: "websearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + numResults: params.numResults, + livecrawl: params.livecrawl, + type: params.type, + contextMaxCharacters: params.contextMaxCharacters, + }, + }) const result = yield* McpExa.call( http, @@ -70,7 +68,7 @@ export const WebSearchTool = Tool.defineEffect( title: `Web search: ${params.query}`, metadata: {}, } - }).pipe(Effect.runPromise), + }).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 52e36ffd989a..7a9d82cf8b05 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -17,12 +17,14 @@ import { assertExternalDirectoryEffect } from "./external-directory" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 -export const WriteTool = Tool.defineEffect( +export const WriteTool = Tool.define( "write", Effect.gen(function* () { const lsp = yield* LSP.Service const fs = yield* AppFileSystem.Service const filetime = yield* FileTime.Service + const bus = yield* Bus.Service + const format = yield* Format.Service return { description: DESCRIPTION, @@ -42,27 +44,23 @@ export const WriteTool = Tool.defineEffect( if (exists) yield* filetime.assert(ctx.sessionID, filepath) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) - yield* Effect.promise(() => - ctx.ask({ - permission: "edit", - patterns: [path.relative(Instance.worktree, filepath)], - always: ["*"], - metadata: { - filepath, - diff, - }, - }), - ) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filepath)], + always: ["*"], + metadata: { + filepath, + diff, + }, + }) yield* fs.writeWithDirs(filepath, params.content) - yield* Effect.promise(() => Format.file(filepath)) - Bus.publish(File.Event.Edited, { file: filepath }) - yield* Effect.promise(() => - Bus.publish(FileWatcher.Event.Updated, { - file: filepath, - event: exists ? "change" : "add", - }), - ) + yield* format.file(filepath) + yield* bus.publish(File.Event.Edited, { file: filepath }) + yield* bus.publish(FileWatcher.Event.Updated, { + file: filepath, + event: exists ? "change" : "add", + }) yield* filetime.read(ctx.sessionID, filepath) let output = "Wrote file successfully." @@ -92,7 +90,7 @@ export const WriteTool = Tool.defineEffect( }, output, } - }).pipe(Effect.orDie, Effect.runPromise), + }).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index bd7548d2ad6d..ba33cb086e3d 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -144,7 +144,7 @@ const filetime = Layer.succeed( read: () => Effect.void, get: () => Effect.succeed(undefined), 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 { task } = yield* registry.named() const original = task.execute - task.execute = async (_args, ctx) => { - ready.resolve() - ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) - await new Promise(() => {}) - return { - title: "", - metadata: { - sessionId: SessionID.make("task"), - model: ref, - }, - output: "", - } - } + task.execute = (_args, ctx) => + Effect.callback((resume) => { + ready.resolve() + ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) + return Effect.sync(() => aborted.resolve()) + }) yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) const { prompt, chat } = yield* boot() @@ -1393,11 +1386,10 @@ function hangUntilAborted(tool: { execute: (...args: any[]) => any }) { const ready = defer() const aborted = defer() const original = tool.execute - tool.execute = async (_args: any, ctx: any) => { + tool.execute = (_args: any, ctx: any) => { ready.resolve() ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) - await new Promise(() => {}) - return { title: "", metadata: {}, output: "" } + return Effect.callback(() => {}) } const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original))) return { ready, aborted, restore } diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 4901c6f4f1ec..1c242128e3c5 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -107,7 +107,7 @@ const filetime = Layer.succeed( read: () => Effect.void, get: () => Effect.succeed(undefined), assert: () => Effect.void, - withLock: (_filepath, fn) => Effect.promise(fn), + withLock: (_filepath, fn) => fn(), }), ) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index d54d34b834c9..8ce1c5ecac55 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -7,10 +7,11 @@ import { Instance } from "../../src/project/instance" import { LSP } from "../../src/lsp" import { AppFileSystem } from "../../src/filesystem" import { Format } from "../../src/format" +import { Bus } from "../../src/bus" import { tmpdir } from "../fixture/fixture" 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 = { sessionID: SessionID.make("ses_test"), @@ -42,22 +43,21 @@ type AskInput = { } type ToolCtx = typeof baseCtx & { - ask: (input: AskInput) => Promise + ask: (input: AskInput) => Effect.Effect } const execute = async (params: { patchText: string }, ctx: ToolCtx) => { const info = await runtime.runPromise(ApplyPatchTool) const tool = await info.init() - return tool.execute(params, ctx) + return Effect.runPromise(tool.execute(params, ctx)) } const makeCtx = () => { const calls: AskInput[] = [] const ctx: ToolCtx = { ...baseCtx, - ask: async (input) => { - calls.push(input) - }, + ask: (input) => + Effect.sync(() => { calls.push(input) }), } return { ctx, calls } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 15518fa57443..54e615408b5f 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -30,7 +30,7 @@ const ctx = { abort: AbortSignal.any([]), messages: [], metadata: () => {}, - ask: async () => {}, + ask: () => Effect.void, } Shell.acceptable.reset() @@ -109,10 +109,11 @@ const each = (name: string, fn: (item: { label: string; shell: string }) => Prom const capture = (requests: Array>, stop?: Error) => ({ ...ctx, - ask: async (req: Omit) => { - requests.push(req) - if (stop) throw stop - }, + ask: (req: Omit) => + Effect.sync(() => { + requests.push(req) + if (stop) throw stop + }), }) const mustTruncate = (result: { @@ -131,13 +132,13 @@ describe("tool.bash", () => { directory: projectRoot, fn: async () => { const bash = await initBash() - const result = await bash.execute( + const result = await Effect.runPromise(bash.execute( { command: "echo test", description: "Echo test message", }, ctx, - ) + )) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") }, @@ -153,13 +154,13 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: "echo hello", description: "Echo hello", }, capture(requests), - ) + )) expect(requests.length).toBe(1) expect(requests[0].permission).toBe("bash") expect(requests[0].patterns).toContain("echo hello") @@ -174,13 +175,13 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: "echo foo && echo bar", description: "Echo twice", }, capture(requests), - ) + )) expect(requests.length).toBe(1) expect(requests[0].permission).toBe("bash") expect(requests[0].patterns).toContain("echo foo") @@ -198,13 +199,13 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: "Write-Host foo; if ($?) { Write-Host bar }", description: "Check PowerShell conditional", }, capture(requests), - ) + )) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() 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 want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: `cat ${file}`, description: "Read wildcard path", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() @@ -257,13 +258,13 @@ describe("tool.bash permissions", () => { const bash = await initBash() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: `echo $(cat "${file}")`, description: "Read nested bash file", }, capture(requests), - ) + )) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") expect(extDirReq).toBeDefined() @@ -289,13 +290,13 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, description: "Copy Windows ini", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() @@ -316,13 +317,13 @@ describe("tool.bash permissions", () => { const bash = await initBash() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` - await bash.execute( + await Effect.runPromise(bash.execute( { command: `Write-Output $(Get-Content ${file})`, description: "Read nested PowerShell file", }, capture(requests), - ) + )) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") expect(extDirReq).toBeDefined() @@ -347,13 +348,13 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: 'Get-Content "C:../outside.txt"', description: "Read drive-relative file", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return @@ -375,13 +376,13 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: 'Get-Content "$HOME/.ssh/config"', description: "Read home config", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return @@ -404,13 +405,13 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: 'Get-Content "$PWD/../outside.txt"', description: "Read pwd-relative file", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return @@ -432,13 +433,13 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: 'Get-Content "$PSHOME/outside.txt"', description: "Read pshome file", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return @@ -465,13 +466,13 @@ describe("tool.bash permissions", () => { const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, description: "Read Windows ini with missing env", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() @@ -495,13 +496,13 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: "Get-Content $env:WINDIR/win.ini", description: "Read Windows ini from env", }, capture(requests), - ) + )) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() expect(extDirReq!.patterns).toContain( @@ -524,13 +525,13 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, description: "Read Windows ini from FileSystem provider", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return @@ -554,13 +555,13 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: "Get-Content ${env:WINDIR}/win.ini", description: "Read Windows ini from braced env", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") if (requests[0]?.permission !== "external_directory") return @@ -582,13 +583,13 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: "Set-Location C:/Windows", description: "Change location", }, capture(requests), - ) + )) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") expect(extDirReq).toBeDefined() @@ -611,13 +612,13 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: "Write-Output ('a' * 3)", description: "Write repeated text", }, capture(requests), - ) + )) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).not.toContain("a * 3") @@ -638,13 +639,13 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: "cd ../", description: "Change to parent directory", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() @@ -661,14 +662,14 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: "echo ok", workdir: os.tmpdir(), description: "Echo from temp dir", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() @@ -691,14 +692,14 @@ describe("tool.bash permissions", () => { for (const dir of forms(outerTmp.path)) { const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: "echo ok", workdir: dir, description: "Echo from external dir", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") @@ -724,14 +725,14 @@ describe("tool.bash permissions", () => { const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: "echo ok", workdir: "/tmp", description: "Echo from Git Bash tmp", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) expect(requests[0]).toMatchObject({ permission: "external_directory", @@ -754,13 +755,13 @@ describe("tool.bash permissions", () => { const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: "cat /tmp/opencode-does-not-exist", description: "Read Git Bash tmp file", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) expect(requests[0]).toMatchObject({ permission: "external_directory", @@ -789,13 +790,13 @@ describe("tool.bash permissions", () => { const requests: Array> = [] const filepath = path.join(outerTmp.path, "outside.txt") await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: `cat ${filepath}`, description: "Read external file", }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") const expected = glob(path.join(outerTmp.path, "*")) @@ -817,13 +818,13 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: `rm -rf ${path.join(tmp.path, "nested")}`, description: "Remove nested dir", }, capture(requests), - ) + )) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeUndefined() }, @@ -837,13 +838,13 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: "git log --oneline -5", description: "Git log", }, capture(requests), - ) + )) expect(requests.length).toBe(1) expect(requests[0].always.length).toBeGreaterThan(0) expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true) @@ -858,13 +859,13 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - await bash.execute( + await Effect.runPromise(bash.execute( { command: "cd .", description: "Stay in current directory", }, capture(requests), - ) + )) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeUndefined() }, @@ -880,10 +881,10 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( + Effect.runPromise(bash.execute( { command: "echo test > output.txt", description: "Redirect test output" }, capture(requests, err), - ), + )), ).rejects.toThrow(err.message) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() @@ -899,7 +900,7 @@ describe("tool.bash permissions", () => { fn: async () => { const bash = await initBash() const requests: Array> = [] - 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") expect(bashReq).toBeDefined() expect(bashReq!.always[0]).toBe("ls *") @@ -916,7 +917,7 @@ describe("tool.bash abort", () => { const bash = await initBash() const controller = new AbortController() const collected: string[] = [] - const result = bash.execute( + const res = await Effect.runPromise(bash.execute( { command: `echo before && sleep 30`, 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("User aborted the command") expect(collected.length).toBeGreaterThan(0) @@ -946,14 +946,14 @@ describe("tool.bash abort", () => { directory: projectRoot, fn: async () => { const bash = await initBash() - const result = await bash.execute( + const result = await Effect.runPromise(bash.execute( { command: `echo started && sleep 60`, description: "Timeout test", timeout: 500, }, ctx, - ) + )) expect(result.output).toContain("started") expect(result.output).toContain("bash tool terminated command after exceeding timeout") }, @@ -965,13 +965,13 @@ describe("tool.bash abort", () => { directory: projectRoot, fn: async () => { const bash = await initBash() - const result = await bash.execute( + const result = await Effect.runPromise(bash.execute( { command: `echo stdout_msg && echo stderr_msg >&2`, description: "Stderr test", }, ctx, - ) + )) expect(result.output).toContain("stdout_msg") expect(result.output).toContain("stderr_msg") expect(result.metadata.exit).toBe(0) @@ -984,13 +984,13 @@ describe("tool.bash abort", () => { directory: projectRoot, fn: async () => { const bash = await initBash() - const result = await bash.execute( + const result = await Effect.runPromise(bash.execute( { command: `exit 42`, description: "Non-zero exit", }, ctx, - ) + )) expect(result.metadata.exit).toBe(42) }, }) @@ -1002,7 +1002,7 @@ describe("tool.bash abort", () => { fn: async () => { const bash = await initBash() const updates: string[] = [] - const result = await bash.execute( + const result = await Effect.runPromise(bash.execute( { command: `echo first && sleep 0.1 && echo second`, description: "Streaming test", @@ -1014,7 +1014,7 @@ describe("tool.bash abort", () => { if (output) updates.push(output) }, }, - ) + )) expect(result.output).toContain("first") expect(result.output).toContain("second") expect(updates.length).toBeGreaterThan(1) @@ -1030,13 +1030,13 @@ describe("tool.bash truncation", () => { fn: async () => { const bash = await initBash() const lineCount = Truncate.MAX_LINES + 500 - const result = await bash.execute( + const result = await Effect.runPromise(bash.execute( { command: fill("lines", lineCount), description: "Generate lines exceeding limit", }, ctx, - ) + )) mustTruncate(result) expect(result.output).toContain("truncated") expect(result.output).toContain("The tool call succeeded but the output was truncated") @@ -1050,13 +1050,13 @@ describe("tool.bash truncation", () => { fn: async () => { const bash = await initBash() const byteCount = Truncate.MAX_BYTES + 10000 - const result = await bash.execute( + const result = await Effect.runPromise(bash.execute( { command: fill("bytes", byteCount), description: "Generate bytes exceeding limit", }, ctx, - ) + )) mustTruncate(result) expect(result.output).toContain("truncated") expect(result.output).toContain("The tool call succeeded but the output was truncated") @@ -1069,13 +1069,13 @@ describe("tool.bash truncation", () => { directory: projectRoot, fn: async () => { const bash = await initBash() - const result = await bash.execute( + const result = await Effect.runPromise(bash.execute( { command: "echo hello", description: "Echo hello", }, ctx, - ) + )) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) expect(result.output).toContain("hello") }, @@ -1088,13 +1088,13 @@ describe("tool.bash truncation", () => { fn: async () => { const bash = await initBash() const lineCount = Truncate.MAX_LINES + 100 - const result = await bash.execute( + const result = await Effect.runPromise(bash.execute( { command: fill("lines", lineCount), description: "Generate lines for file check", }, ctx, - ) + )) mustTruncate(result) const filepath = (result.metadata as { outputPath?: string }).outputPath diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index feb0f592bcc7..21dc57e36862 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { FileTime } from "../../src/file/time" 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" const ctx = { @@ -17,7 +21,7 @@ const ctx = { abort: AbortSignal.any([]), messages: [], metadata: () => {}, - ask: async () => {}, + ask: () => Effect.void, } afterEach(async () => { @@ -29,7 +33,9 @@ async function touch(file: string, time: number) { 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 () => { 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 = (def: D, callback: () => unknown) => + runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback))) + describe("tool.edit", () => { describe("creating new files", () => { test("creates new file when oldString is empty", async () => { @@ -53,14 +65,14 @@ describe("tool.edit", () => { directory: tmp.path, fn: async () => { const edit = await resolve() - const result = await edit.execute( + const result = await Effect.runPromise(edit.execute( { filePath: filepath, oldString: "", newString: "new content", }, ctx, - ) + )) expect(result.metadata.diff).toContain("new content") @@ -78,14 +90,14 @@ describe("tool.edit", () => { directory: tmp.path, fn: async () => { const edit = await resolve() - await edit.execute( + await Effect.runPromise(edit.execute( { filePath: filepath, oldString: "", newString: "nested file", }, ctx, - ) + )) const content = await fs.readFile(filepath, "utf-8") expect(content).toBe("nested file") @@ -100,22 +112,20 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const { Bus } = await import("../../src/bus") - const { File } = await import("../../src/file") const { FileWatcher } = await import("../../src/file/watcher") 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() - await edit.execute( + await Effect.runPromise(edit.execute( { filePath: filepath, oldString: "", newString: "content", }, ctx, - ) + )) expect(events).toContain("updated") unsubUpdated() @@ -133,17 +143,17 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) const edit = await resolve() - const result = await edit.execute( + const result = await Effect.runPromise(edit.execute( { filePath: filepath, oldString: "old content", newString: "new content", }, ctx, - ) + )) expect(result.output).toContain("Edit applied successfully") @@ -160,18 +170,18 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) const edit = await resolve() await expect( - edit.execute( + Effect.runPromise(edit.execute( { filePath: filepath, oldString: "old", newString: "new", }, ctx, - ), + )), ).rejects.toThrow("not found") }, }) @@ -187,14 +197,14 @@ describe("tool.edit", () => { fn: async () => { const edit = await resolve() await expect( - edit.execute( + Effect.runPromise(edit.execute( { filePath: filepath, oldString: "same", newString: "same", }, ctx, - ), + )), ).rejects.toThrow("identical") }, }) @@ -208,18 +218,18 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) const edit = await resolve() await expect( - edit.execute( + Effect.runPromise(edit.execute( { filePath: filepath, oldString: "not in file", newString: "replacement", }, ctx, - ), + )), ).rejects.toThrow() }, }) @@ -235,14 +245,14 @@ describe("tool.edit", () => { fn: async () => { const edit = await resolve() await expect( - edit.execute( + Effect.runPromise(edit.execute( { filePath: filepath, oldString: "content", newString: "modified", }, ctx, - ), + )), ).rejects.toThrow("You must read file") }, }) @@ -258,7 +268,7 @@ describe("tool.edit", () => { directory: tmp.path, fn: async () => { // Read first - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) // Simulate external modification await fs.writeFile(filepath, "modified externally", "utf-8") @@ -267,14 +277,14 @@ describe("tool.edit", () => { // Try to edit with the new content const edit = await resolve() await expect( - edit.execute( + Effect.runPromise(edit.execute( { filePath: filepath, oldString: "modified externally", newString: "edited", }, ctx, - ), + )), ).rejects.toThrow("modified since it was last read") }, }) @@ -288,10 +298,10 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) const edit = await resolve() - await edit.execute( + await Effect.runPromise(edit.execute( { filePath: filepath, oldString: "foo", @@ -299,7 +309,7 @@ describe("tool.edit", () => { replaceAll: true, }, ctx, - ) + )) const content = await fs.readFile(filepath, "utf-8") expect(content).toBe("qux bar qux baz qux") @@ -315,23 +325,22 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, 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 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() - await edit.execute( + await Effect.runPromise(edit.execute( { filePath: filepath, oldString: "original", newString: "modified", }, ctx, - ) + )) expect(events).toContain("updated") unsubUpdated() @@ -349,17 +358,17 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) const edit = await resolve() - await edit.execute( + await Effect.runPromise(edit.execute( { filePath: filepath, oldString: "line2", newString: "new line 2\nextra line", }, ctx, - ) + )) const content = await fs.readFile(filepath, "utf-8") expect(content).toBe("line1\nnew line 2\nextra line\nline3") @@ -375,17 +384,17 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) const edit = await resolve() - await edit.execute( + await Effect.runPromise(edit.execute( { filePath: filepath, oldString: "old", newString: "new", }, ctx, - ) + )) const content = await fs.readFile(filepath, "utf-8") expect(content).toBe("line1\r\nnew\r\nline3") @@ -403,14 +412,14 @@ describe("tool.edit", () => { fn: async () => { const edit = await resolve() await expect( - edit.execute( + Effect.runPromise(edit.execute( { filePath: filepath, oldString: "", newString: "", }, ctx, - ), + )), ).rejects.toThrow("identical") }, }) @@ -424,18 +433,18 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, dirpath) + await readFileTime(ctx.sessionID, dirpath) const edit = await resolve() await expect( - edit.execute( + Effect.runPromise(edit.execute( { filePath: dirpath, oldString: "old", newString: "new", }, ctx, - ), + )), ).rejects.toThrow("directory") }, }) @@ -449,17 +458,17 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) const edit = await resolve() - const result = await edit.execute( + const result = await Effect.runPromise(edit.execute( { filePath: filepath, oldString: "line2", newString: "new line a\nnew line b", }, ctx, - ) + )) expect(result.metadata.filediff).toBeDefined() expect(result.metadata.filediff.file).toBe(filepath) @@ -520,8 +529,8 @@ describe("tool.edit", () => { fn: async () => { const edit = await resolve() const filePath = path.join(tmp.path, "test.txt") - await FileTime.read(ctx.sessionID, filePath) - await edit.execute( + await readFileTime(ctx.sessionID, filePath) + await Effect.runPromise(edit.execute( { filePath, oldString: input.oldString, @@ -529,7 +538,7 @@ describe("tool.edit", () => { replaceAll: input.replaceAll, }, ctx, - ) + )) return await Bun.file(filePath).text() }, }) @@ -661,31 +670,31 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) const edit = await resolve() // Two concurrent edits - const promise1 = edit.execute( + const promise1 = Effect.runPromise(edit.execute( { filePath: filepath, oldString: "0", newString: "1", }, ctx, - ) + )) // 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, oldString: "0", newString: "2", }, ctx, - ) + )) // Both should complete without error (though one might fail due to content mismatch) const results = await Promise.allSettled([promise1, promise2]) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index cf95eaf4b1ec..dd739b5f68a3 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { Effect } from "effect" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" @@ -21,15 +22,18 @@ const baseCtx: Omit = { const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") +function makeCtx() { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: (req) => Effect.sync(() => { requests.push(req) }), + } + return { requests, ctx } +} + describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await Instance.provide({ directory: "/tmp", @@ -42,13 +46,7 @@ describe("tool.assertExternalDirectory", () => { }) test("no-ops for paths inside Instance.directory", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await Instance.provide({ directory: "/tmp/project", @@ -61,13 +59,7 @@ describe("tool.assertExternalDirectory", () => { }) test("asks with a single canonical glob", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() const directory = "/tmp/project" const target = "/tmp/outside/file.txt" @@ -87,13 +79,7 @@ describe("tool.assertExternalDirectory", () => { }) test("uses target directory when kind=directory", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() const directory = "/tmp/project" const target = "/tmp/outside" @@ -113,13 +99,7 @@ describe("tool.assertExternalDirectory", () => { }) test("skips prompting when bypass=true", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await Instance.provide({ directory: "/tmp/project", @@ -133,13 +113,7 @@ describe("tool.assertExternalDirectory", () => { if (process.platform === "win32") { test("normalizes Windows path variants to one glob", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await using outerTmp = await tmpdir({ init: async (dir) => { @@ -169,13 +143,7 @@ describe("tool.assertExternalDirectory", () => { }) test("uses drive root glob for root files", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await using tmp = await tmpdir({ git: true }) const root = path.parse(tmp.path).root diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index a0cfb61c40a0..c35c5c08f207 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -21,7 +21,7 @@ const ctx = { abort: AbortSignal.any([]), messages: [], metadata: () => {}, - ask: async () => {}, + ask: () => Effect.void, } const projectRoot = path.join(__dirname, "../..") @@ -32,14 +32,14 @@ describe("tool.grep", () => { directory: projectRoot, fn: async () => { const grep = await initGrep() - const result = await grep.execute( + const result = await Effect.runPromise(grep.execute( { pattern: "export", path: path.join(projectRoot, "src/tool"), include: "*.ts", }, ctx, - ) + )) expect(result.metadata.matches).toBeGreaterThan(0) expect(result.output).toContain("Found") }, @@ -56,13 +56,13 @@ describe("tool.grep", () => { directory: tmp.path, fn: async () => { const grep = await initGrep() - const result = await grep.execute( + const result = await Effect.runPromise(grep.execute( { pattern: "xyznonexistentpatternxyz123", path: tmp.path, }, ctx, - ) + )) expect(result.metadata.matches).toBe(0) expect(result.output).toBe("No files found") }, @@ -81,13 +81,13 @@ describe("tool.grep", () => { directory: tmp.path, fn: async () => { const grep = await initGrep() - const result = await grep.execute( + const result = await Effect.runPromise(grep.execute( { pattern: "line", path: tmp.path, }, ctx, - ) + )) expect(result.metadata.matches).toBeGreaterThan(0) }, }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index f1d9492ca8c4..e02c57dcd7b0 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -16,7 +16,7 @@ const ctx = { abort: AbortSignal.any([]), messages: [], metadata: () => {}, - ask: async () => {}, + ask: () => Effect.void, } 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) 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) yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 12345266b318..888cc4225f62 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -30,7 +30,7 @@ const ctx = { abort: AbortSignal.any([]), messages: [], metadata: () => {}, - ask: async () => {}, + ask: () => Effect.void, } const it = testEffect( @@ -54,7 +54,7 @@ const run = Effect.fn("ReadToolTest.run")(function* ( next: Tool.Context = ctx, ) { 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* ( @@ -95,9 +95,8 @@ const asks = () => { items, next: { ...ctx, - ask: async (req: Omit) => { - items.push(req) - }, + ask: (req: Omit) => + Effect.sync(() => { items.push(req) }), }, } } @@ -226,17 +225,18 @@ describe("tool.read env file permissions", () => { let asked = false const next = { ...ctx, - ask: async (req: Omit) => { - for (const pattern of req.patterns) { - const rule = Permission.evaluate(req.permission, pattern, info.permission) - if (rule.action === "ask" && req.permission === "read") { - asked = true + ask: (req: Omit) => + Effect.sync(() => { + for (const pattern of req.patterns) { + const rule = Permission.evaluate(req.permission, pattern, info.permission) + 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) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 1c97ee4afc9e..bfde5835f091 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -156,12 +156,11 @@ Use this skill. const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, - ask: async (req) => { - requests.push(req) - }, + ask: (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 file = path.resolve(dir, "scripts", "demo.txt") diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index c019052a5e60..f7288ad61032 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -194,8 +194,7 @@ describe("tool.task", () => { let seen: SessionPrompt.PromptInput | undefined const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) - const result = yield* Effect.promise(() => - def.execute( + const result = yield* def.execute( { description: "inspect bug", prompt: "look into the cache key path", @@ -210,10 +209,9 @@ describe("tool.task", () => { extra: { promptOps }, messages: [], metadata() {}, - ask: async () => {}, + ask: () => Effect.void, }, - ), - ) + ) const kids = yield* sessions.children(chat.id) expect(kids).toHaveLength(1) @@ -235,8 +233,7 @@ describe("tool.task", () => { const promptOps = stubOps() const exec = (extra?: Record) => - Effect.promise(() => - def.execute( + def.execute( { description: "inspect bug", prompt: "look into the cache key path", @@ -250,12 +247,10 @@ describe("tool.task", () => { extra: { promptOps, ...extra }, messages: [], metadata() {}, - ask: async (input) => { - calls.push(input) - }, + ask: (input) => + Effect.sync(() => { calls.push(input) }), }, - ), - ) + ) yield* exec() yield* exec({ bypassAgentCheck: true }) @@ -284,8 +279,7 @@ describe("tool.task", () => { let seen: SessionPrompt.PromptInput | undefined const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) - const result = yield* Effect.promise(() => - def.execute( + const result = yield* def.execute( { description: "inspect bug", prompt: "look into the cache key path", @@ -300,10 +294,9 @@ describe("tool.task", () => { extra: { promptOps }, messages: [], metadata() {}, - ask: async () => {}, + ask: () => Effect.void, }, - ), - ) + ) const kids = yield* sessions.children(chat.id) expect(kids).toHaveLength(1) @@ -326,8 +319,7 @@ describe("tool.task", () => { let seen: SessionPrompt.PromptInput | undefined const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) - const result = yield* Effect.promise(() => - def.execute( + const result = yield* def.execute( { description: "inspect bug", prompt: "look into the cache key path", @@ -341,10 +333,9 @@ describe("tool.task", () => { extra: { promptOps }, messages: [], metadata() {}, - ask: async () => {}, + ask: () => Effect.void, }, - ), - ) + ) const child = yield* sessions.get(result.metadata.sessionId) expect(child.parentID).toBe(chat.id) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 2ea6d56a51e8..4b44e17b095b 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect } from "bun:test" +import { Effect } from "effect" import z from "zod" import { Tool } from "../../src/tool/tool" @@ -8,9 +9,9 @@ function makeTool(id: string, executeFn?: () => void) { return { description: "test tool", parameters: params, - async execute() { + execute() { 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 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 tool.init() - await tool.init() + await info.init() + await info.init() + await info.init() expect(original.execute).toBe(originalExecute) }) 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 second = await tool.init() + const first = await info.init() + const second = await info.init() expect(first).not.toBe(second) }) 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 second = await tool.init() + const first = await info.init() + const second = await info.init() expect(first).not.toBe(second) }) diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index a26be24ae001..8e9f96808c0a 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -16,7 +16,7 @@ const ctx = { abort: AbortSignal.any([]), messages: [], metadata: () => {}, - ask: async () => {}, + ask: () => Effect.void, } async function withFetch(fetch: (req: Request) => Response | Promise, fn: (url: URL) => Promise) { @@ -42,10 +42,10 @@ describe("tool.webfetch", () => { directory: projectRoot, fn: async () => { 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" }, ctx, - ) + )) expect(result.output).toBe("Image fetched successfully") expect(result.attachments).toBeDefined() expect(result.attachments?.length).toBe(1) @@ -74,7 +74,7 @@ describe("tool.webfetch", () => { directory: projectRoot, fn: async () => { 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(" { directory: projectRoot, fn: async () => { 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.attachments).toBeUndefined() }, diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 8289646ebe4c..57d2cd6a76e3 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -7,6 +7,8 @@ import { Instance } from "../../src/project/instance" import { LSP } from "../../src/lsp" import { AppFileSystem } from "../../src/filesystem" import { FileTime } from "../../src/file/time" +import { Bus } from "../../src/bus" +import { Format } from "../../src/format" import { Tool } from "../../src/tool/tool" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" @@ -21,7 +23,7 @@ const ctx = { abort: AbortSignal.any([]), messages: [], metadata: () => {}, - ask: async () => {}, + ask: () => Effect.void, } afterEach(async () => { @@ -29,7 +31,7 @@ afterEach(async () => { }) 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* () { @@ -42,7 +44,7 @@ const run = Effect.fn("WriteToolTest.run")(function* ( next: Tool.Context = ctx, ) { 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) {