From 67b62db36a6bf713ee6e042a535737b4b2284561 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 19 Apr 2026 01:02:19 +0800 Subject: [PATCH 1/4] feat: sync opencode tool changes --- packages/opencode/src/agent/agent.ts | 37 +- packages/opencode/src/cli/cmd/agent.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 54 ++- .../src/cli/cmd/tui/routes/session/index.tsx | 18 - .../cli/cmd/tui/routes/session/permission.tsx | 16 - packages/opencode/src/config/config.ts | 1 - packages/opencode/src/config/index.ts | 1 + packages/opencode/src/effect/app-runtime.ts | 2 - packages/opencode/src/effect/index.ts | 2 + packages/opencode/src/file/ripgrep.ts | 67 ++- packages/opencode/src/file/time.ts | 114 ----- packages/opencode/src/provider/index.ts | 1 + packages/opencode/src/question/index.ts | 3 + packages/opencode/src/session/prompt.ts | 4 - .../opencode/src/session/prompt/beast.txt | 2 +- .../opencode/src/session/prompt/default.txt | 8 +- .../opencode/src/session/prompt/trinity.txt | 8 +- packages/opencode/src/tool/apply_patch.ts | 14 +- packages/opencode/src/tool/bash.ts | 127 +++++- packages/opencode/src/tool/codesearch.ts | 2 +- packages/opencode/src/tool/edit.ts | 119 +++-- .../opencode/src/tool/external-directory.ts | 6 +- packages/opencode/src/tool/glob.ts | 25 +- packages/opencode/src/tool/grep.ts | 166 ++++--- packages/opencode/src/tool/index.ts | 3 + packages/opencode/src/tool/invalid.ts | 2 +- packages/opencode/src/tool/ls.ts | 134 ------ packages/opencode/src/tool/ls.txt | 1 - packages/opencode/src/tool/lsp.ts | 2 +- packages/opencode/src/tool/mcp-exa.ts | 4 +- packages/opencode/src/tool/multiedit.ts | 2 +- packages/opencode/src/tool/plan.ts | 4 +- packages/opencode/src/tool/question.ts | 6 +- packages/opencode/src/tool/read.ts | 7 +- packages/opencode/src/tool/registry.ts | 7 - packages/opencode/src/tool/schema.ts | 3 +- packages/opencode/src/tool/skill.ts | 129 +++--- packages/opencode/src/tool/skill.txt | 5 + packages/opencode/src/tool/task.ts | 5 +- packages/opencode/src/tool/todo.ts | 2 +- packages/opencode/src/tool/tool.ts | 22 + packages/opencode/src/tool/trash.ts | 64 --- packages/opencode/src/tool/trash.txt | 4 - packages/opencode/src/tool/truncate.ts | 28 +- packages/opencode/src/tool/webfetch.ts | 4 +- packages/opencode/src/tool/websearch.ts | 2 +- packages/opencode/src/tool/write.ts | 4 - packages/opencode/src/util/effect-zod.ts | 4 + packages/opencode/src/util/index.ts | 1 + packages/opencode/test/cli/run.test.ts | 49 ++ packages/opencode/test/file/ripgrep.test.ts | 93 ++++ packages/opencode/test/file/time.test.ts | 422 ------------------ .../test/session/prompt-effect.test.ts | 16 +- .../test/session/snapshot-tool-race.test.ts | 12 - packages/opencode/test/tool/bash.test.ts | 8 +- packages/opencode/test/tool/edit.test.ts | 121 ++--- packages/opencode/test/tool/glob.test.ts | 190 ++++++++ packages/opencode/test/tool/grep.test.ts | 197 +++++++- packages/opencode/test/tool/read.test.ts | 24 +- packages/opencode/test/tool/registry.test.ts | 12 + packages/opencode/test/tool/trash.test.ts | 142 ------ packages/opencode/test/tool/write.test.ts | 27 +- 62 files changed, 1151 insertions(+), 1410 deletions(-) create mode 100644 packages/opencode/src/config/index.ts create mode 100644 packages/opencode/src/effect/index.ts delete mode 100644 packages/opencode/src/file/time.ts create mode 100644 packages/opencode/src/provider/index.ts create mode 100644 packages/opencode/src/tool/index.ts delete mode 100644 packages/opencode/src/tool/ls.ts delete mode 100644 packages/opencode/src/tool/ls.txt create mode 100644 packages/opencode/src/tool/skill.txt delete mode 100644 packages/opencode/src/tool/trash.ts delete mode 100644 packages/opencode/src/tool/trash.txt create mode 100644 packages/opencode/src/util/index.ts create mode 100644 packages/opencode/test/cli/run.test.ts delete mode 100644 packages/opencode/test/file/time.test.ts create mode 100644 packages/opencode/test/tool/glob.test.ts delete mode 100644 packages/opencode/test/tool/trash.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ee063b00c..9c2f3a583 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -83,16 +83,22 @@ export namespace Agent { const skillDirs = yield* skill.dirs() const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] - const defaults = Permission.fromConfig({ + const defaults = Permission.fromConfig({ + "*": "allow", + doom_loop: "ask", + question: "deny", + plan_enter: "deny", + plan_exit: "deny", + read: { "*": "allow", - doom_loop: "ask", - question: "deny", - plan_enter: "deny", - plan_exit: "deny", - bash: { - "*": "allow", - "sudo *": "deny", - "dd *": "deny", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + bash: { + "*": "allow", + "sudo *": "deny", + "dd *": "deny", "mkfs*": "deny", "chmod *": "deny", "kill *": "deny", @@ -100,12 +106,12 @@ export namespace Agent { "rmdir *": "deny", "unlink *": "deny", "find * -delete*": "deny", - }, - external_directory: { - "*": "allow", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, - }) + }, + external_directory: { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + }, + }) const user = Permission.fromConfig(cfg.permission ?? {}) @@ -171,7 +177,6 @@ export namespace Agent { "*": "deny", grep: "allow", glob: "allow", - list: "allow", bash: "allow", webfetch: "allow", websearch: "allow", diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 70082c8e2..f49400a49 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -14,7 +14,7 @@ import type { Argv } from "yargs" type AgentMode = "all" | "primary" | "subagent" -const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"] +const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"] const AgentCreateCommand = cmd({ command: "create", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 04130aa95..910ff6037 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -15,7 +15,6 @@ import { Permission } from "../../permission" import { Tool } from "../../tool/tool" import { GlobTool } from "../../tool/glob" import { GrepTool } from "../../tool/grep" -import { ListTool } from "../../tool/ls" import { ReadTool } from "../../tool/read" import { WebFetchTool } from "../../tool/webfetch" import { EditTool } from "../../tool/edit" @@ -49,6 +48,8 @@ type Inline = { description?: string } +type RenderedTool = (Inline & { kind: "inline" }) | (Inline & { kind: "block"; output?: string }) + function inline(info: Inline) { const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) @@ -63,15 +64,20 @@ function block(info: Inline, output?: string) { } function fallback(part: ToolPart) { + inline(fallbackInfo(part)) +} + +function fallbackInfo(part: ToolPart): RenderedTool { const state = part.state const input = "input" in state ? state.input : undefined const title = ("title" in state && state.title ? state.title : undefined) || (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown") - inline({ + return { + kind: "inline", icon: "⚙", title: `${part.tool} ${title}`, - }) + } } function glob(info: ToolProps) { @@ -102,12 +108,28 @@ function grep(info: ToolProps) { }) } -function list(info: ToolProps) { - const dir = info.input.path ? normalizePath(info.input.path) : "" - inline({ - icon: "→", - title: dir ? `List ${dir}` : "List", - }) +function renderTool(info: RenderedTool) { + if (info.kind === "block") { + return block(info, info.output) + } + return inline(info) +} + +export function describeToolPartForRun(part: ToolPart): RenderedTool { + try { + if (part.tool === "bash") { + const info = props(part) + return { + kind: "block", + icon: "$", + title: `${info.input.command}`, + output: info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined, + } + } + return fallbackInfo(part) + } catch { + return fallbackInfo(part) + } } function read(info: ToolProps) { @@ -191,17 +213,6 @@ function skill(info: ToolProps) { }) } -function bash(info: ToolProps) { - const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined - block( - { - icon: "$", - title: `${info.input.command}`, - }, - output, - ) -} - function todo(info: ToolProps) { block( { @@ -416,10 +427,9 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (part.tool === "bash") return bash(props(part)) + if (part.tool === "bash") return renderTool(describeToolPartForRun(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) - if (part.tool === "list") return list(props(part)) if (part.tool === "read") return read(props(part)) if (part.tool === "write") return write(props(part)) if (part.tool === "webfetch") return webfetch(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index a106909fe..b97b9a9d8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -41,7 +41,6 @@ import { BashTool } from "@/tool/bash" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" -import type { ListTool } from "@/tool/ls" import type { EditTool } from "@/tool/edit" import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" @@ -1532,9 +1531,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess - - - @@ -1913,20 +1909,6 @@ function Grep(props: ToolProps) { ) } -function List(props: ToolProps) { - const dir = createMemo(() => { - if (props.input.path) { - return normalizePath(props.input.path) - } - return "" - }) - return ( - - List {dir()} - - ) -} - function WebFetch(props: ToolProps) { return ( diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 2f50fed00..5ebb255fa 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -267,22 +267,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "list") { - const raw = data.path - const dir = typeof raw === "string" ? raw : "" - return { - icon: "→", - title: `List ${normalizePath(dir)}`, - body: ( - - - {"Path: " + normalizePath(dir)} - - - ), - } - } - if (permission === "bash") { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index db3ed0755..9dc936e5d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -499,7 +499,6 @@ export namespace Config { edit: PermissionRule.optional(), glob: PermissionRule.optional(), grep: PermissionRule.optional(), - list: PermissionRule.optional(), bash: PermissionRule.optional(), task: PermissionRule.optional(), external_directory: PermissionRule.optional(), diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts new file mode 100644 index 000000000..082a9b72f --- /dev/null +++ b/packages/opencode/src/config/index.ts @@ -0,0 +1 @@ +export { Config } from "./config" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 0d32bce08..9b1d0a0c5 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -9,7 +9,6 @@ import { Account } from "@/account" import { Config } from "@/config/config" import { Git } from "@/git" import { Ripgrep } from "@/file/ripgrep" -import { FileTime } from "@/file/time" import { File } from "@/file" import { FileWatcher } from "@/file/watcher" import { Storage } from "@/storage/storage" @@ -57,7 +56,6 @@ export const AppLayer = Layer.mergeAll( Config.defaultLayer, Git.defaultLayer, Ripgrep.defaultLayer, - FileTime.defaultLayer, File.defaultLayer, FileWatcher.defaultLayer, Storage.defaultLayer, diff --git a/packages/opencode/src/effect/index.ts b/packages/opencode/src/effect/index.ts new file mode 100644 index 000000000..546ea1fd9 --- /dev/null +++ b/packages/opencode/src/effect/index.ts @@ -0,0 +1,2 @@ +export * as EffectLogger from "./logger" +export { InstanceState } from "./instance-state" diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 4d0fc5598..3e0e9053c 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -5,7 +5,6 @@ import fs from "fs/promises" import z from "zod" import { Effect, Layer, Context } from "effect" import * as Stream from "effect/Stream" -import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import type { PlatformError } from "effect/PlatformError" @@ -220,6 +219,12 @@ export namespace Ripgrep { return filepath } + function env() { + return { + RIPGREP_CONFIG_PATH: undefined, + } satisfies NodeJS.ProcessEnv + } + export async function* files(input: { cwd: string glob?: string[] @@ -251,12 +256,13 @@ export namespace Ripgrep { const proc = Process.spawn(args, { cwd: input.cwd, + env: env(), stdout: "pipe", - stderr: "ignore", + stderr: "pipe", abort: input.signal, }) - if (!proc.stdout) { + if (!proc.stdout || !proc.stderr) { throw new Error("Process output not available") } @@ -276,9 +282,12 @@ export namespace Ripgrep { } if (buffer) yield buffer - await proc.exited - + const exit = await proc.exited input.signal?.throwIfAborted() + if (exit !== 0) { + const stderr = (await text(proc.stderr)).trim() + throw new Error(stderr || `ripgrep failed with exit code ${exit}`) + } } export interface Interface { @@ -288,6 +297,7 @@ export namespace Ripgrep { hidden?: boolean follow?: boolean maxDepth?: number + signal?: AbortSignal }) => Stream.Stream } @@ -296,45 +306,21 @@ export namespace Ripgrep { export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner - const afs = yield* AppFileSystem.Service - - const files = Effect.fn("Ripgrep.files")(function* (input: { + const streamFiles = Effect.fn("Ripgrep.files")(function* (input: { cwd: string glob?: string[] hidden?: boolean follow?: boolean maxDepth?: number + signal?: AbortSignal }) { - const rgPath = yield* Effect.promise(() => filepath()) - const isDir = yield* afs.isDir(input.cwd) - if (!isDir) { - return yield* Effect.die( - Object.assign(new Error(`No such file or directory: '${input.cwd}'`), { - code: "ENOENT" as const, - errno: -2, - path: input.cwd, - }), - ) - } - - const args = [rgPath, "--files", "--glob=!.git/*"] - if (input.follow) args.push("--follow") - if (input.hidden !== false) args.push("--hidden") - if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) - if (input.glob) { - for (const g of input.glob) { - args.push(`--glob=${g}`) - } - } - - return spawner - .streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd })) - .pipe(Stream.filter((line: string) => line.length > 0)) + return Stream.fromAsyncIterable(Ripgrep.files(input), (error) => + error instanceof Error ? (error as PlatformError) : (new Error(String(error)) as PlatformError), + ) }) return Service.of({ - files: (input) => Stream.unwrap(files(input)), + files: (input) => Stream.unwrap(streamFiles(input)), }) }), ) @@ -427,12 +413,21 @@ export namespace Ripgrep { const result = await Process.text(args, { cwd: input.cwd, + env: env(), nothrow: true, }) - if (result.code !== 0) { + if (result.code === 1) { return [] } + if (result.code !== 0 && result.code !== 2) { + throw new Process.RunFailedError(args, result.code, result.stdout, result.stderr) + } + + if (result.code === 2 && !result.text.trim()) { + throw new Process.RunFailedError(args, result.code, result.stdout, result.stderr) + } + // Handle both Unix (\n) and Windows (\r\n) line endings const lines = result.text.trim().split(/\r?\n/).filter(Boolean) // Parse JSON lines from ripgrep output diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts deleted file mode 100644 index e5055b671..000000000 --- a/packages/opencode/src/file/time.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect" -import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@/filesystem" -import { Flag } from "@/flag/flag" -import type { SessionID } from "@/session/schema" -import { Filesystem } from "@/util/filesystem" -import { Log } from "../util/log" - -export namespace FileTime { - const log = Log.create({ service: "file.time" }) - - export type Stamp = { - readonly read: Date - readonly mtime: number | undefined - readonly size: number | undefined - } - - const session = (reads: Map>, sessionID: SessionID) => { - const value = reads.get(sessionID) - if (value) return value - - const next = new Map() - reads.set(sessionID, next) - return next - } - - interface State { - reads: Map> - locks: Map - } - - export interface Interface { - 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: () => Effect.Effect) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/FileTime") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fsys = yield* AppFileSystem.Service - const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK - - const stamp = Effect.fnUntraced(function* (file: string) { - const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void)) - return { - read: yield* DateTime.nowAsDate, - mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined, - size: info ? Number(info.size) : undefined, - } - }) - const state = yield* InstanceState.make( - Effect.fn("FileTime.state")(() => - Effect.succeed({ - reads: new Map>(), - locks: new Map(), - }), - ), - ) - - const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { - filepath = Filesystem.normalizePath(filepath) - const locks = (yield* InstanceState.get(state)).locks - const lock = locks.get(filepath) - if (lock) return lock - - const next = Semaphore.makeUnsafe(1) - locks.set(filepath, next) - return next - }) - - const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { - file = Filesystem.normalizePath(file) - const reads = (yield* InstanceState.get(state)).reads - log.info("read", { sessionID, file }) - session(reads, sessionID).set(file, yield* stamp(file)) - }) - - const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { - file = Filesystem.normalizePath(file) - const reads = (yield* InstanceState.get(state)).reads - return reads.get(sessionID)?.get(file)?.read - }) - - const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { - if (disableCheck) return - filepath = Filesystem.normalizePath(filepath) - - const reads = (yield* InstanceState.get(state)).reads - const time = reads.get(sessionID)?.get(filepath) - if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) - - const next = yield* stamp(filepath) - const changed = next.mtime !== time.mtime || next.size !== time.size - if (!changed) return - - throw new Error( - `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, - ) - }) - - 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 }) - }), - ).pipe(Layer.orDie) - - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) -} diff --git a/packages/opencode/src/provider/index.ts b/packages/opencode/src/provider/index.ts new file mode 100644 index 000000000..882d7f977 --- /dev/null +++ b/packages/opencode/src/provider/index.ts @@ -0,0 +1 @@ +export { Provider } from "./provider" diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 178bc7943..cf42c777e 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -31,6 +31,9 @@ export namespace Question { .meta({ ref: "QuestionInfo" }) export type Info = z.infer + export const Prompt = Info.omit({ custom: true }).meta({ ref: "QuestionPrompt" }) + export type Prompt = z.infer + export const Request = z .object({ id: QuestionID.zod, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 51332b3e5..f64e08110 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -22,7 +22,6 @@ import MAX_STEPS from "../session/prompt/max-steps.txt" import { ToolRegistry } from "../tool/registry" import { MCP } from "../mcp" import { LSP } from "../lsp" -import { FileTime } from "../file/time" import { Flag } from "../flag/flag" import { ulid } from "ulid" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -94,7 +93,6 @@ export namespace SessionPrompt { const fsys = yield* AppFileSystem.Service const mcp = yield* MCP.Service const lsp = yield* LSP.Service - const filetime = yield* FileTime.Service const registry = yield* ToolRegistry.Service const truncate = yield* Truncate.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -1192,7 +1190,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the ] } - yield* filetime.read(input.sessionID, filepath) return [ { messageID: info.id, @@ -1693,7 +1690,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the Layer.provide(Permission.defaultLayer), Layer.provide(MCP.defaultLayer), Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), Layer.provide(ToolRegistry.defaultLayer), Layer.provide(Truncate.defaultLayer), Layer.provide(Provider.defaultLayer), diff --git a/packages/opencode/src/session/prompt/beast.txt b/packages/opencode/src/session/prompt/beast.txt index a10b2e0a1..5ff9ad917 100644 --- a/packages/opencode/src/session/prompt/beast.txt +++ b/packages/opencode/src/session/prompt/beast.txt @@ -83,7 +83,7 @@ Carefully read the issue and think hard about a plan to solve it before coding. - Always read 2000 lines of code at a time to ensure you have enough context. - If a patch is not applied correctly, attempt to reapply it. - Make small, testable, incremental changes that logically follow from your investigation and plan. -- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it. +- Whenever you detect that a project requires an environment variable (such as an API key or secret), check the existing configuration first and tell the user what is missing. Do not create or modify .env files unless the user explicitly asks you to do that. ## 7. Debugging - Make code changes only if you have high confidence they can solve the problem diff --git a/packages/opencode/src/session/prompt/default.txt b/packages/opencode/src/session/prompt/default.txt index 2be513677..e2832775d 100644 --- a/packages/opencode/src/session/prompt/default.txt +++ b/packages/opencode/src/session/prompt/default.txt @@ -33,13 +33,13 @@ assistant: Yes -user: what command should I run to list files in the current directory? -assistant: ls +user: what command should I run to print the current directory? +assistant: pwd user: what command should I run to watch files in the current directory? -assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files] +assistant: [inspect the current directory and read docs/commands in the relevant file to find out how to watch files] npm run dev @@ -50,7 +50,7 @@ assistant: 150000 user: what files are in the directory src/? -assistant: [runs ls and sees foo.c, bar.c, baz.c] +assistant: [checks the directory contents and sees foo.c, bar.c, baz.c] user: which file contains the implementation of foo? assistant: src/foo.c diff --git a/packages/opencode/src/session/prompt/trinity.txt b/packages/opencode/src/session/prompt/trinity.txt index e3a17797d..88b3a96f0 100644 --- a/packages/opencode/src/session/prompt/trinity.txt +++ b/packages/opencode/src/session/prompt/trinity.txt @@ -25,13 +25,13 @@ assistant: Yes -user: what command should I run to list files in the current directory? -assistant: ls +user: what command should I run to print the current directory? +assistant: pwd user: what command should I run to watch files in the current directory? -assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files] +assistant: [inspect the current directory and read docs/commands in the relevant file to find out how to watch files] npm run dev @@ -42,7 +42,7 @@ assistant: 150000 user: what files are in the directory src/? -assistant: [runs ls and sees foo.c, bar.c, baz.c] +assistant: [checks the directory contents and sees foo.c, bar.c, baz.c] user: which file contains the implementation of foo? assistant: src/foo.c diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index fd38a9b22..5ada82b68 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,7 +1,7 @@ import z from "zod" import * as path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" @@ -143,9 +143,15 @@ export const ApplyPatchTool = Tool.define( } case "delete": { - const contentToDelete = yield* afs - .readFileString(filePath) - .pipe(Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`)))) + const contentToDelete = yield* afs.readFileString(filePath).pipe( + Effect.catch((error) => + Effect.fail( + new Error( + `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ), + ), + ) const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) const deletions = contentToDelete.split("\n").length diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 5509749d9..cdbaf2f38 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,9 +1,10 @@ import z from "zod" import os from "os" -import { Tool } from "./tool" +import { createWriteStream } from "node:fs" +import * as Tool from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" -import { Log } from "../util/log" +import { Log } from "../util" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -14,7 +15,7 @@ import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" -import { Truncate } from "./truncate" +import * as Truncate from "./truncate" import { Plugin } from "@/plugin" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" @@ -76,6 +77,11 @@ type Scan = { always: Set } +type Chunk = { + text: string + size: number +} + export const log = Log.create({ service: "bash-tool" }) const resolveWasm = (asset: string) => { @@ -211,7 +217,39 @@ function pathArgs(list: Part[], ps: boolean) { function preview(text: string) { if (text.length <= MAX_METADATA_LENGTH) return text - return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." + return "...\n\n" + text.slice(-MAX_METADATA_LENGTH) +} + +function tail(text: string, maxLines: number, maxBytes: number) { + const lines = text.split("\n") + if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) { + return { + text, + cut: false, + } + } + + const out: string[] = [] + let bytes = 0 + for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + if (out.length === 0) { + const buf = Buffer.from(lines[i], "utf-8") + let start = buf.length - maxBytes + if (start < 0) start = 0 + while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++ + out.unshift(buf.subarray(start).toString("utf-8")) + } + break + } + out.unshift(lines[i]) + bytes += size + } + return { + text: out.join("\n"), + cut: true, + } } const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { @@ -295,6 +333,7 @@ export const BashTool = Tool.define( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner const fs = yield* AppFileSystem.Service + const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { @@ -387,7 +426,16 @@ export const BashTool = Tool.define( }, ctx: Tool.Context, ) { - let output = "" + const bytes = Truncate.MAX_BYTES + const lines = Truncate.MAX_LINES + const keep = bytes * 2 + let full = "" + let last = "" + const list: Chunk[] = [] + let used = 0 + let file = "" + let sink: ReturnType | undefined + let cut = false let expired = false let aborted = false @@ -404,10 +452,47 @@ export const BashTool = Tool.define( yield* Effect.forkScoped( Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { - output += chunk + const size = Buffer.byteLength(chunk, "utf-8") + list.push({ text: chunk, size }) + used += size + while (used > keep && list.length > 1) { + const item = list.shift() + if (!item) break + used -= item.size + cut = true + } + + last = preview(last + chunk) + + if (file) { + sink?.write(chunk) + } else { + full += chunk + if (Buffer.byteLength(full, "utf-8") > bytes) { + return trunc.write(full).pipe( + Effect.andThen((next) => + Effect.sync(() => { + file = next + cut = true + sink = createWriteStream(next, { flags: "a" }) + full = "" + }), + ), + Effect.andThen( + ctx.metadata({ + metadata: { + output: last, + description: input.description, + }, + }), + ), + ) + } + } + return ctx.metadata({ metadata: { - output: preview(output), + output: last, description: input.description, }, }) @@ -449,16 +534,42 @@ export const BashTool = Tool.define( ) } if (aborted) meta.push("User aborted the command") + const raw = list.map((item) => item.text).join("") + const end = tail(raw, lines, bytes) + if (end.cut) cut = true + if (!file && end.cut) { + file = yield* trunc.write(raw) + } + + let output = end.text + if (!output) output = "(no output)" + + if (cut && file) { + output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output + } + if (meta.length > 0) { output += "\n\n\n" + meta.join("\n") + "\n" } + if (sink) { + const stream = sink + yield* Effect.promise( + () => + new Promise((resolve) => { + stream.end(() => resolve()) + stream.on("error", () => resolve()) + }), + ) + } return { title: input.description, metadata: { - output: preview(output), + output: last || preview(output), exit: code, description: input.description, + truncated: cut, + ...(cut && file ? { outputPath: file } : {}), }, output, } diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index d4d5779bf..ac9961e25 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect } from "effect" import { HttpClient } from "effect/unstable/http" -import { Tool } from "./tool" +import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./codesearch.txt" diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a835714c6..3de17ca61 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -14,7 +14,6 @@ import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Bus } from "../bus" import { Format } from "../format" -import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" @@ -45,7 +44,6 @@ 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 @@ -71,52 +69,11 @@ export const EditTool = Tool.define( let diff = "" let contentOld = "" let contentNew = "" - 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* 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)], @@ -126,26 +83,62 @@ export const EditTool = Tool.define( diff, }, }) - - yield* afs.writeWithDirs(filePath, contentNew) + 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: "change", + event: existed ? "change" : "add", }) - contentNew = yield* afs.readFileString(filePath) - diff = trimDiff( - createTwoFilesPatch( - filePath, - filePath, - normalizeLineEndings(contentOld), - normalizeLineEndings(contentNew), - ), - ) - yield* filetime.read(ctx.sessionID, filePath) - }).pipe(Effect.orDie), - ) + 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}`) + 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: ["*"], + metadata: { + filepath: filePath, + diff, + }, + }) + + 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: "change", + }) + contentNew = yield* afs.readFileString(filePath) + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) + }).pipe(Effect.orDie) const filediff: Snapshot.FileDiff = { file: filePath, diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index ff8854649..a619137c5 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,7 +1,8 @@ import path from "path" import { Effect } from "effect" import { EffectLogger } from "@/effect/logger" -import type { Tool } from "./tool" +import { InstanceState } from "@/effect/instance-state" +import type * as Tool from "./tool" import { Instance } from "../project/instance" import { AppFileSystem } from "../filesystem" @@ -21,8 +22,9 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec if (options?.bypass) return + const ins = yield* InstanceState.context const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target - if (Instance.containsPath(full)) return + if (Instance.containsPath(full, ins)) return const kind = options?.kind ?? "file" const dir = kind === "directory" ? full : path.dirname(full) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index a3ff5aef7..201d9d2e4 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,13 +1,13 @@ -import z from "zod" import path from "path" +import z from "zod" import { Effect, Option } from "effect" import * as Stream from "effect/Stream" -import { Tool } from "./tool" -import DESCRIPTION from "./glob.txt" +import { InstanceState } from "@/effect" +import { AppFileSystem } from "@/filesystem" import { Ripgrep } from "../file/ripgrep" -import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" -import { AppFileSystem } from "../filesystem" +import DESCRIPTION from "./glob.txt" +import * as Tool from "./tool" export const GlobTool = Tool.define( "glob", @@ -28,6 +28,7 @@ export const GlobTool = Tool.define( }), execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => Effect.gen(function* () { + const ins = yield* InstanceState.context yield* ctx.ask({ permission: "glob", patterns: [params.pattern], @@ -38,13 +39,17 @@ export const GlobTool = Tool.define( }, }) - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + let search = params.path ?? ins.directory + search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search) + const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (info?.type === "File") { + throw new Error(`glob path must be a directory: ${search}`) + } yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) const limit = 100 let truncated = false - const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe( + const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe( Stream.mapEffect((file) => Effect.gen(function* () { const full = path.resolve(search, file) @@ -71,7 +76,7 @@ export const GlobTool = Tool.define( const output = [] if (files.length === 0) output.push("No files found") if (files.length > 0) { - output.push(...files.map((f) => f.path)) + output.push(...files.map((file) => file.path)) if (truncated) { output.push("") output.push( @@ -81,7 +86,7 @@ export const GlobTool = Tool.define( } return { - title: path.relative(Instance.worktree, search), + title: path.relative(ins.worktree, search), metadata: { count: files.length, truncated, diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index b5ae6c350..43736cc76 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,23 +1,30 @@ import z from "zod" -import { Effect } from "effect" +import { Effect, Fiber, Option } from "effect" import * as Stream from "effect/Stream" -import { Tool } from "./tool" -import { Filesystem } from "../util/filesystem" +import * as Tool from "./tool" +import { InstanceState } from "@/effect" +import { AppFileSystem } from "@/filesystem" import { Ripgrep } from "../file/ripgrep" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import DESCRIPTION from "./grep.txt" -import { Instance } from "../project/instance" import path from "path" import { assertExternalDirectoryEffect } from "./external-directory" const MAX_LINE_LENGTH = 2000 +function ripgrepEnv() { + return { + RIPGREP_CONFIG_PATH: undefined, + } satisfies NodeJS.ProcessEnv +} + export const GrepTool = Tool.define( "grep", Effect.gen(function* () { const spawner = yield* ChildProcessSpawner + const fs = yield* AppFileSystem.Service return { description: DESCRIPTION, @@ -28,6 +35,11 @@ export const GrepTool = Tool.define( }), execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => Effect.gen(function* () { + const empty = { + title: params.pattern, + metadata: { matches: 0, truncated: false }, + output: "No files found", + } if (!params.pattern) { throw new Error("pattern is required") } @@ -43,47 +55,71 @@ export const GrepTool = Tool.define( }, }) - let searchPath = params.path ?? Instance.directory - searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) - yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) + const ins = yield* InstanceState.context + const search = AppFileSystem.resolve( + path.isAbsolute(params.path ?? ins.directory) + ? (params.path ?? ins.directory) + : path.join(ins.directory, params.path ?? "."), + ) + const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) + const cwd = info?.type === "Directory" ? search : path.dirname(search) + const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] + yield* assertExternalDirectoryEffect(ctx, search, { + kind: info?.type === "Directory" ? "directory" : "file", + }) const rgPath = yield* Effect.promise(() => Ripgrep.filepath()) - const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern] + const args = ["--json", "--hidden", "--glob=!.git/*"] if (params.include) { args.push("--glob", params.include) } - args.push(searchPath) + args.push("--", params.pattern) + if (file) args.push(...file) const result = yield* Effect.scoped( Effect.gen(function* () { + ctx.abort.throwIfAborted() const handle = yield* spawner.spawn( ChildProcess.make(rgPath, args, { + cwd, + env: ripgrepEnv(), stdin: "ignore", }), ) - const [output, errorOutput] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) + const outputFiber = yield* Stream.mkString(Stream.decodeText(handle.stdout)).pipe(Effect.forkScoped) + const errorFiber = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped) + + const abort = Effect.callback((resume) => { + if (ctx.abort.aborted) return resume(Effect.void) + const handler = () => resume(Effect.void) + ctx.abort.addEventListener("abort", handler, { once: true }) + return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) + }) - const exitCode = yield* handle.exitCode + const exit = yield* Effect.raceAll([ + handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), + abort.pipe(Effect.map(() => ({ kind: "abort" as const }))), + ]) - return { output, errorOutput, exitCode } + if (exit.kind === "abort") { + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + return yield* Effect.fail(new DOMException("This operation was aborted", "AbortError")) + } + + const output = yield* Fiber.join(outputFiber) + const errorOutput = yield* Fiber.join(errorFiber) + + return { output, errorOutput, exitCode: exit.code } }), ) const { output, errorOutput, exitCode } = result - // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) - // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc. - // Only fail if exit code is 2 AND no output was produced - if (exitCode === 1 || (exitCode === 2 && !output.trim())) { - return { - title: params.pattern, - metadata: { matches: 0, truncated: false }, - output: "No files found", - } + if (exitCode === 1) return empty + + if (exitCode === 2 && !output.trim()) { + throw new Error(`ripgrep failed: ${errorOutput.trim() || "unknown error"}`) } if (exitCode !== 0 && exitCode !== 2) { @@ -92,43 +128,55 @@ export const GrepTool = Tool.define( const hasErrors = exitCode === 2 - // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = output.trim().split(/\r?\n/) - const matches = [] - - for (const line of lines) { - if (!line) continue - - const [filePath, lineNumStr, ...lineTextParts] = line.split("|") - if (!filePath || !lineNumStr || lineTextParts.length === 0) continue - - const lineNum = parseInt(lineNumStr, 10) - const lineText = lineTextParts.join("|") - - const stats = Filesystem.stat(filePath) - if (!stats) continue - - matches.push({ - path: filePath, - modTime: stats.mtime.getTime(), - lineNum, - lineText, - }) - } + const lines = output.trim().split(/\r?\n/).filter(Boolean) + const rows = lines.flatMap((line) => { + try { + const parsed = JSON.parse(line) + const match = Ripgrep.Match.parse(parsed).data + return [ + { + path: AppFileSystem.resolve( + path.isAbsolute(match.path.text) ? match.path.text : path.join(cwd, match.path.text), + ), + line: match.line_number, + text: match.lines.text, + }, + ] + } catch { + return [] + } + }) + if (rows.length === 0) return empty + + const times = new Map( + (yield* Effect.forEach( + [...new Set(rows.map((row) => row.path))], + Effect.fnUntraced(function* (filePath) { + const stat = yield* fs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stat || stat.type === "Directory") return undefined + return [ + filePath, + stat.mtime.pipe( + Option.map((time) => time.getTime()), + Option.getOrElse(() => 0), + ) ?? 0, + ] as const + }), + { concurrency: 16 }, + )).filter((entry): entry is readonly [string, number] => Boolean(entry)), + ) + const matches = rows.flatMap((row) => { + const mtime = times.get(row.path) + if (mtime === undefined) return [] + return [{ ...row, mtime }] + }) - matches.sort((a, b) => b.modTime - a.modTime) + matches.sort((a, b) => b.mtime - a.mtime) const limit = 100 const truncated = matches.length > limit const finalMatches = truncated ? matches.slice(0, limit) : matches - - if (finalMatches.length === 0) { - return { - title: params.pattern, - metadata: { matches: 0, truncated: false }, - output: "No files found", - } - } + if (finalMatches.length === 0) return empty const totalMatches = matches.length const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`] @@ -143,10 +191,8 @@ export const GrepTool = Tool.define( outputLines.push(`${match.path}:`) } const truncatedLineText = - match.lineText.length > MAX_LINE_LENGTH - ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." - : match.lineText - outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) + match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text + outputLines.push(` Line ${match.line}: ${truncatedLineText}`) } if (truncated) { diff --git a/packages/opencode/src/tool/index.ts b/packages/opencode/src/tool/index.ts new file mode 100644 index 000000000..5b2463b50 --- /dev/null +++ b/packages/opencode/src/tool/index.ts @@ -0,0 +1,3 @@ +export * as Truncate from "./truncate" +export * as ToolRegistry from "./registry" +export * as Tool from "./tool" diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index b9794ed5f..aca3618b6 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" export const InvalidTool = Tool.define( "invalid", diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts deleted file mode 100644 index 600a5532a..000000000 --- a/packages/opencode/src/tool/ls.ts +++ /dev/null @@ -1,134 +0,0 @@ -import z from "zod" -import { Effect } from "effect" -import * as Stream from "effect/Stream" -import { Tool } from "./tool" -import * as path from "path" -import DESCRIPTION from "./ls.txt" -import { Instance } from "../project/instance" -import { Ripgrep } from "../file/ripgrep" -import { assertExternalDirectoryEffect } from "./external-directory" - -export const IGNORE_PATTERNS = [ - "node_modules/", - "__pycache__/", - ".git/", - "dist/", - "build/", - "target/", - "vendor/", - "bin/", - "obj/", - ".idea/", - ".vscode/", - ".zig-cache/", - "zig-out", - ".coverage", - "coverage/", - "vendor/", - "tmp/", - "temp/", - ".cache/", - "cache/", - "logs/", - ".venv/", - "venv/", - "env/", -] - -const LIMIT = 100 - -export const ListTool = Tool.define( - "list", - Effect.gen(function* () { - const rg = yield* Ripgrep.Service - - return { - description: DESCRIPTION, - parameters: z.object({ - path: z - .string() - .describe("The absolute path to the directory to list (must be absolute, not relative)") - .optional(), - ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), - }), - execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) => - Effect.gen(function* () { - const searchPath = path.resolve(Instance.directory, params.path || ".") - yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) - - 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( - Stream.take(LIMIT), - Stream.runCollect, - Effect.map((chunk) => [...chunk]), - ) - - // Build directory structure - const dirs = new Set() - const filesByDir = new Map() - - for (const file of files) { - const dir = path.dirname(file) - const parts = dir === "." ? [] : dir.split("/") - - // Add all parent directories - for (let i = 0; i <= parts.length; i++) { - const dirPath = i === 0 ? "." : parts.slice(0, i).join("/") - dirs.add(dirPath) - } - - // Add file to its directory - if (!filesByDir.has(dir)) filesByDir.set(dir, []) - filesByDir.get(dir)!.push(path.basename(file)) - } - - function renderDir(dirPath: string, depth: number): string { - const indent = " ".repeat(depth) - let output = "" - - if (depth > 0) { - output += `${indent}${path.basename(dirPath)}/\n` - } - - const childIndent = " ".repeat(depth + 1) - const children = Array.from(dirs) - .filter((d) => path.dirname(d) === dirPath && d !== dirPath) - .sort() - - // Render subdirectories first - for (const child of children) { - output += renderDir(child, depth + 1) - } - - // Render files - const files = filesByDir.get(dirPath) || [] - for (const file of files.sort()) { - output += `${childIndent}${file}\n` - } - - return output - } - - const output = `${searchPath}/\n` + renderDir(".", 0) - - return { - title: path.relative(Instance.worktree, searchPath), - metadata: { - count: files.length, - truncated: files.length >= LIMIT, - }, - output, - } - }).pipe(Effect.orDie), - } - }), -) diff --git a/packages/opencode/src/tool/ls.txt b/packages/opencode/src/tool/ls.txt deleted file mode 100644 index 543720d46..000000000 --- a/packages/opencode/src/tool/ls.txt +++ /dev/null @@ -1 +0,0 @@ -Lists files and directories in a given path. The path parameter must be absolute; omit it to use the current workspace directory. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search. diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index c5a5d6f81..8819cde3e 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import path from "path" import { LSP } from "../lsp" import DESCRIPTION from "./lsp.txt" diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-exa.ts index 638d68c24..3340d84ef 100644 --- a/packages/opencode/src/tool/mcp-exa.ts +++ b/packages/opencode/src/tool/mcp-exa.ts @@ -1,7 +1,9 @@ import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -const URL = "https://mcp.exa.ai/mcp" +const URL = process.env.EXA_API_KEY + ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}` + : "https://mcp.exa.ai/mcp" const McpResult = Schema.Struct({ result: Schema.Struct({ diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 449df3343..004d3c870 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { EditTool } from "./edit" import DESCRIPTION from "./multiedit.txt" import path from "path" diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 1613821fe..fd7276e09 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,11 +1,11 @@ import z from "zod" import path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Question } from "../question" import { Session } from "../session" import { MessageV2 } from "../session/message-v2" -import { Provider } from "../provider/provider" +import { Provider } from "../provider" import { Instance } from "../project/instance" import { type SessionID, MessageID, PartID } from "../session/schema" import EXIT_DESCRIPTION from "./plan-exit.txt" diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 8cfa700a5..379f39905 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,15 +1,15 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" const parameters = z.object({ - questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"), + questions: z.array(Question.Prompt).describe("Questions to ask"), }) type Metadata = { - answers: Question.Answer[] + answers: ReadonlyArray } export const QuestionTool = Tool.define( diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 501a8c97e..2a89e4afd 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -7,7 +7,6 @@ import { createInterface } from "readline" import { Tool } from "./tool" import { AppFileSystem } from "../filesystem" import { LSP } from "../lsp" -import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" @@ -31,7 +30,6 @@ export const ReadTool = Tool.define( const fs = yield* AppFileSystem.Service const instruction = yield* Instruction.Service const lsp = yield* LSP.Service - const time = yield* FileTime.Service const scope = yield* Scope.Scope const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { @@ -75,9 +73,8 @@ export const ReadTool = Tool.define( ).pipe(Effect.map((items: string[]) => items.sort((a, b) => a.localeCompare(b)))) }) - const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) { + const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) { yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) - yield* time.read(sessionID, filepath) }) const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { @@ -196,7 +193,7 @@ export const ReadTool = Tool.define( } output += "\n" - yield* warm(filepath, ctx.sessionID) + yield* warm(filepath) if (loaded.length > 0) { output += `\n\n\n${loaded.map((item) => item.content).join("\n\n")}\n` diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4d521f131..778b838ea 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -25,7 +25,6 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncate" -import { TrashTool } from "./trash" import { ApplyPatchTool } from "./apply_patch" import { Permission } from "../permission" import { Glob } from "../util/glob" @@ -43,7 +42,6 @@ import { Env } from "../env" import { Question } from "../question" import { Todo } from "../session/todo" import { LSP } from "../lsp" -import { FileTime } from "../file/time" import { Instruction } from "../session/instruction" import { AppFileSystem } from "../filesystem" import { Bus } from "../bus" @@ -89,7 +87,6 @@ export namespace ToolRegistry { | Session.Service | Provider.Service | LSP.Service - | FileTime.Service | Instruction.Service | AppFileSystem.Service | Bus.Service @@ -124,7 +121,6 @@ export namespace ToolRegistry { const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool - const trashtool = yield* TrashTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -212,7 +208,6 @@ export namespace ToolRegistry { search: Tool.init(websearch), code: Tool.init(codesearch), skill: Tool.init(skilltool), - trash: Tool.init(trashtool), patch: Tool.init(patchtool), question: Tool.init(question), lsp: Tool.init(lsptool), @@ -236,7 +231,6 @@ export namespace ToolRegistry { tool.search, tool.code, tool.skill, - tool.trash, tool.patch, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), @@ -352,7 +346,6 @@ export namespace ToolRegistry { Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Bus.layer), diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 823bb0aed..ac41fd160 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -2,9 +2,10 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -const toolIdSchema = Schema.String.pipe(Schema.brand("ToolID")) +const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) export type ToolID = typeof toolIdSchema.Type diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 14adaf231..1582a90f2 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -2,11 +2,11 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect } from "effect" -import { EffectLogger } from "@/effect/logger" import * as Stream from "effect/Stream" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Skill } from "../skill" import { Ripgrep } from "../file/ripgrep" +import DESCRIPTION from "./skill.txt" const Parameters = z.object({ name: z.string().describe("The name of the skill from available_skills"), @@ -17,84 +17,61 @@ export const SkillTool = Tool.define( Effect.gen(function* () { const skill = yield* Skill.Service const rg = yield* Ripgrep.Service - return () => - Effect.gen(function* () { - const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer)) - const description = - list.length === 0 - ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." - : [ - "Load a specialized skill that provides domain-specific instructions and workflows.", - "", - "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", - "", - "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", - "", - 'Tool output includes a `` block with the loaded content.', - "", - "The following skills provide specialized sets of instructions for particular tasks", - "Invoke this tool to load a skill when a task matches one of the available skills listed below:", - "", - Skill.fmt(list, { verbose: false }), - ].join("\n") + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const info = yield* skill.get(params.name) + if (!info) { + const all = yield* skill.all() + const available = all.map((s) => s.name).join(", ") + throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) + } - return { - description, - parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => - Effect.gen(function* () { - const info = yield* skill.get(params.name) + yield* ctx.ask({ + permission: "skill", + patterns: [params.name], + always: [params.name], + metadata: {}, + }) - if (!info) { - const all = yield* skill.all() - const available = all.map((s) => s.name).join(", ") - throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) - } + const dir = path.dirname(info.location) + const base = pathToFileURL(dir).href - yield* ctx.ask({ - permission: "skill", - patterns: [params.name], - always: [params.name], - metadata: {}, - }) + const limit = 10 + const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe( + Stream.filter((file) => !file.includes("SKILL.md")), + Stream.map((file) => path.resolve(dir, file)), + Stream.take(limit), + Stream.runCollect, + Effect.map((chunk) => [...chunk].map((file) => `${file}`).join("\n")), + ) - const dir = path.dirname(info.location) - const base = pathToFileURL(dir).href - - const limit = 10 - const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe( - Stream.filter((file) => !file.includes("SKILL.md")), - Stream.map((file) => path.resolve(dir, file)), - Stream.take(limit), - Stream.runCollect, - Effect.map((chunk) => [...chunk].map((file) => `${file}`).join("\n")), - ) - - return { - title: `Loaded skill: ${info.name}`, - output: [ - ``, - `# Skill: ${info.name}`, - "", - info.content.trim(), - "", - `Base directory for this skill: ${base}`, - "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", - "Note: file list is sampled.", - "", - "", - files, - "", - "", - ].join("\n"), - metadata: { - name: info.name, - dir, - }, - } - }).pipe(Effect.orDie), - } - }) + return { + title: `Loaded skill: ${info.name}`, + output: [ + ``, + `# Skill: ${info.name}`, + "", + info.content.trim(), + "", + `Base directory for this skill: ${base}`, + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + files, + "", + "", + ].join("\n"), + metadata: { + name: info.name, + dir, + }, + } + }).pipe(Effect.orDie), + } }), ) diff --git a/packages/opencode/src/tool/skill.txt b/packages/opencode/src/tool/skill.txt new file mode 100644 index 000000000..44d990317 --- /dev/null +++ b/packages/opencode/src/tool/skill.txt @@ -0,0 +1,5 @@ +Load a specialized skill when the task at hand matches one of the skills listed in the system prompt. + +Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill. + +The skill name must match one of the skills listed in your system prompt. diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index ce99ab299..3da0664f3 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,4 +1,4 @@ -import { Tool } from "./tool" +import * as Tool from "./tool" import DESCRIPTION from "./task.txt" import z from "zod" import { Session } from "../session" @@ -6,9 +6,8 @@ import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" -import { Config } from "../config/config" +import { Config } from "../config" import { Effect } from "effect" -import { Log } from "@/util/log" export interface TaskPromptOps { cancel(sessionID: SessionID): void diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 253bcfa32..5090f17a7 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 49dd2b060..9cd598d8c 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -139,3 +139,25 @@ export namespace Tool { }) } } + +export type DynamicDescription = Tool.DynamicDescription +export type Context = Record> = Tool.Context +export type ExecuteResult = Record> = Tool.ExecuteResult +export type Def< + Parameters extends z.ZodType = z.ZodType, + M extends Record = Record, +> = Tool.Def +export type DefWithoutID< + Parameters extends z.ZodType = z.ZodType, + M extends Record = Record, +> = Tool.DefWithoutID +export type Info< + Parameters extends z.ZodType = z.ZodType, + M extends Record = Record, +> = Tool.Info +export type InferParameters = Tool.InferParameters +export type InferMetadata = Tool.InferMetadata +export type InferDef = Tool.InferDef + +export const define = Tool.define +export const init = Tool.init diff --git a/packages/opencode/src/tool/trash.ts b/packages/opencode/src/tool/trash.ts deleted file mode 100644 index ce66ae533..000000000 --- a/packages/opencode/src/tool/trash.ts +++ /dev/null @@ -1,64 +0,0 @@ -import path from "path" -import z from "zod" -import trash from "trash" -import { Effect } from "effect" -import { Tool } from "./tool" -import { AppFileSystem } from "../filesystem" -import { Instance } from "../project/instance" -import DESCRIPTION from "./trash.txt" -import { assertExternalDirectoryEffect } from "./external-directory" - -const Parameters = z.object({ - path: z.string().describe("The file or directory path to move to the system Trash"), -}) - -export const TrashTool = Tool.define( - "trash", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - - return { - description: DESCRIPTION, - parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => - Effect.gen(function* () { - const target = path.isAbsolute(params.path) ? params.path : path.join(Instance.directory, params.path) - const info = yield* fs.stat(target).pipe( - // Only treat ENOENT (file not found) as a soft miss; propagate all other errors - Effect.catchIf( - (e) => (e as any)?.cause?.code === "ENOENT" || (e as any)?.reason?._tag === "NotFound", - () => Effect.succeed(undefined), - ), - ) - if (!info) { - throw new Error(`Path not found: ${target}`) - } - - yield* assertExternalDirectoryEffect(ctx, target, { - kind: info.type === "Directory" ? "directory" : "file", - }) - - const localTarget = path.relative(Instance.directory, target) - const permissionPattern = Instance.containsPath(target) ? localTarget : target - yield* ctx.ask({ - permission: "trash", - patterns: [permissionPattern], - always: ["*"], - metadata: { - filepath: target, - }, - }) - - yield* Effect.promise(() => trash([target], { glob: false })) - - return { - title: Instance.containsPath(target) ? localTarget : target, - metadata: { - filepath: target, - }, - output: "Moved item to Trash successfully.", - } - }).pipe(Effect.orDie), - } - }), -) diff --git a/packages/opencode/src/tool/trash.txt b/packages/opencode/src/tool/trash.txt deleted file mode 100644 index 167da065c..000000000 --- a/packages/opencode/src/tool/trash.txt +++ /dev/null @@ -1,4 +0,0 @@ -Moves a file or directory to the system Trash. - -Use this instead of shell deletion commands like `rm`. -Accepts a single file or directory path, absolute or relative to the current project directory. diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index fa9e0bcab..dcacdf7a2 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -5,7 +5,7 @@ import type { Agent } from "../agent/agent" import { AppFileSystem } from "@/filesystem" import { evaluate } from "@/permission/evaluate" import { Identifier } from "../id/id" -import { Log } from "../util/log" +import { Log } from "../util" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" @@ -33,6 +33,7 @@ export namespace Truncate { export interface Interface { readonly cleanup: () => Effect.Effect + readonly write: (text: string) => Effect.Effect /** * Returns output unchanged when it fits within the limits, otherwise writes the full text * to the truncation directory and returns a preview plus a hint to inspect the saved file. @@ -59,6 +60,13 @@ export namespace Truncate { } }) + const write = Effect.fn("Truncate.write")(function* (text: string) { + const file = path.join(TRUNCATION_DIR, ToolID.ascending()) + yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + return file + }) + const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { const maxLines = options.maxLines ?? MAX_LINES const maxBytes = options.maxBytes ?? MAX_BYTES @@ -100,10 +108,7 @@ export namespace Truncate { const removed = hitBytes ? totalBytes - bytes : lines.length - out.length const unit = hitBytes ? "bytes" : "lines" const preview = out.join("\n") - const file = path.join(TRUNCATION_DIR, ToolID.ascending()) - - yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) - yield* fs.writeFileString(file, text).pipe(Effect.orDie) + const file = yield* write(text) const hint = hasTaskTool(agent) ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` @@ -129,9 +134,20 @@ export namespace Truncate { Effect.forkScoped, ) - return Service.of({ cleanup, output }) + return Service.of({ cleanup, write, output }) }), ) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) } + +export const MAX_LINES = Truncate.MAX_LINES +export const MAX_BYTES = Truncate.MAX_BYTES +export const DIR = Truncate.DIR +export const GLOB = Truncate.GLOB +export type Result = Truncate.Result +export type Options = Truncate.Options +export type Interface = Truncate.Interface +export const Service = Truncate.Service +export const layer = Truncate.layer +export const defaultLayer = Truncate.defaultLayer diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 9339038b0..6498b871f 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect } from "effect" -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { Tool } from "./tool" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import * as Tool from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 968e1e34b..34cefd031 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect } from "effect" import { HttpClient } from "effect/unstable/http" -import { Tool } from "./tool" +import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./websearch.txt" diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 7a9d82cf8..43a3b665b 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -9,7 +9,6 @@ import { Bus } from "../bus" import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Format } from "../format" -import { FileTime } from "../file/time" import { AppFileSystem } from "../filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" @@ -22,7 +21,6 @@ export const WriteTool = Tool.define( 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 @@ -41,7 +39,6 @@ export const WriteTool = Tool.define( const exists = yield* fs.existsSafe(filepath) const contentOld = exists ? yield* fs.readFileString(filepath) : "" - if (exists) yield* filetime.assert(ctx.sessionID, filepath) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) yield* ctx.ask({ @@ -61,7 +58,6 @@ export const WriteTool = Tool.define( file: filepath, event: exists ? "change" : "add", }) - yield* filetime.read(ctx.sessionID, filepath) let output = "Wrote file successfully." yield* lsp.touchFile(filepath, true) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 97cbbd2fc..dac7d5a95 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -1,11 +1,15 @@ import { Schema, SchemaAST } from "effect" import z from "zod" +export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") + export function zod(schema: S): z.ZodType> { return walk(schema.ast) as z.ZodType> } function walk(ast: SchemaAST.AST): z.ZodTypeAny { + const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined + if (override) return override const out = body(ast) const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) diff --git a/packages/opencode/src/util/index.ts b/packages/opencode/src/util/index.ts new file mode 100644 index 000000000..e19a4a09e --- /dev/null +++ b/packages/opencode/src/util/index.ts @@ -0,0 +1 @@ +export { Log } from "./log" diff --git a/packages/opencode/test/cli/run.test.ts b/packages/opencode/test/cli/run.test.ts new file mode 100644 index 000000000..3dcf16588 --- /dev/null +++ b/packages/opencode/test/cli/run.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import type { ToolPart } from "@opencode-ai/sdk/v2" + +const completed = (tool: string, input: Record, title?: string, output?: string): ToolPart => + ({ + id: `part_${tool}`, + sessionID: "ses_test", + messageID: "msg_test", + type: "tool", + callID: `call_${tool}`, + tool, + state: { + status: "completed", + input, + output: output ?? "", + title, + metadata: {}, + time: { start: 1, end: 2 }, + }, + }) as ToolPart + +describe("cli run tool rendering", () => { + test("keeps specialized bash rendering", async () => { + const mod = await import("../../src/cli/cmd/run") + expect(typeof mod.describeToolPartForRun).toBe("function") + + const result = mod.describeToolPartForRun(completed("bash", { command: "ls -la" }, undefined, "file.txt")) + expect(result).toMatchObject({ + kind: "block", + icon: "$", + title: "ls -la", + output: "file.txt", + }) + }) + + test("falls back for retired list tool parts", async () => { + const mod = await import("../../src/cli/cmd/run") + expect(typeof mod.describeToolPartForRun).toBe("function") + + const result = mod.describeToolPartForRun( + completed("list", { path: "/tmp/project" }, "Legacy list title", "ignored output"), + ) + expect(result).toMatchObject({ + kind: "inline", + icon: "⚙", + title: "list Legacy list title", + }) + }) +}) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index 3982c184f..4bf4b7230 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -6,6 +6,24 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Ripgrep } from "../../src/file/ripgrep" +async function withRipgrepConfig(contents: string, fn: () => Promise) { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "ripgreprc"), contents) + }, + }) + + const previous = process.env.RIPGREP_CONFIG_PATH + process.env.RIPGREP_CONFIG_PATH = path.join(tmp.path, "ripgreprc") + + try { + await fn() + } finally { + if (previous === undefined) delete process.env.RIPGREP_CONFIG_PATH + else process.env.RIPGREP_CONFIG_PATH = previous + } +} + describe("file.ripgrep", () => { test("defaults to include hidden", async () => { await using tmp = await tmpdir({ @@ -53,6 +71,81 @@ describe("file.ripgrep", () => { expect(hits).toEqual([]) }) + + test("files throws when ripgrep exits with an invalid glob error", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const value = 1\n") + }, + }) + + await expect( + Array.fromAsync( + Ripgrep.files({ + cwd: tmp.path, + glob: ["["], + }), + ), + ).rejects.toThrow() + }) + + test("search keeps partial matches when ripgrep exits with code 2", async () => { + if (process.platform === "win32") return + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const needle = true\n") + const blocked = path.join(dir, "blocked") + await fs.mkdir(blocked, { recursive: true }) + await Bun.write(path.join(blocked, "hidden.ts"), "const needle = false\n") + }, + }) + + const blocked = path.join(tmp.path, "blocked") + await fs.chmod(blocked, 0o000) + + try { + const hits = await Ripgrep.search({ + cwd: tmp.path, + pattern: "needle", + }) + + expect(hits.length).toBeGreaterThan(0) + expect(hits.some((hit) => hit.path.text === "match.ts")).toBe(true) + } finally { + await fs.chmod(blocked, 0o755) + } + }) + + test("files ignores RIPGREP_CONFIG_PATH from the parent environment", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const value = true\n") + }, + }) + + await withRipgrepConfig("--glob=!*.ts\n", async () => { + const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path })) + expect(files).toContain("match.ts") + }) + }) + + test("search ignores RIPGREP_CONFIG_PATH from the parent environment", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const needle = true\n") + }, + }) + + await withRipgrepConfig("--glob=!*.ts\n", async () => { + const hits = await Ripgrep.search({ + cwd: tmp.path, + pattern: "needle", + }) + + expect(hits.some((hit) => hit.path.text === "match.ts")).toBe(true) + }) + }) }) describe("Ripgrep.Service", () => { diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts deleted file mode 100644 index 7f65d05ea..000000000 --- a/packages/opencode/test/file/time.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { afterEach, describe, expect } from "bun:test" -import fs from "fs/promises" -import path from "path" -import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { FileTime } from "../../src/file/time" -import { Instance } from "../../src/project/instance" -import { SessionID } from "../../src/session/schema" -import { Filesystem } from "../../src/util/filesystem" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -afterEach(async () => { - await Instance.disposeAll() -}) - -const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer)) - -const id = SessionID.make("ses_00000000000000000000000001") - -const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8")) - -const touch = (file: string, time: number) => - Effect.promise(() => { - const date = new Date(time) - return fs.utimes(file, date, date) - }) - -const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file)) - -const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file)) - -const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file)) - -const lock = (file: string, fn: () => Effect.Effect) => FileTime.Service.use((svc) => svc.withLock(file, fn)) - -const fail = Effect.fn("FileTimeTest.fail")(function* (self: Effect.Effect) { - const exit = yield* self.pipe(Effect.exit) - if (Exit.isFailure(exit)) { - const err = Cause.squash(exit.cause) - return err instanceof Error ? err : new Error(String(err)) - } - throw new Error("expected file time effect to fail") -}) - -describe("file/time", () => { - describe("read() and get()", () => { - it.live("stores read timestamp", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - const before = yield* get(id, file) - expect(before).toBeUndefined() - - yield* read(id, file) - - const after = yield* get(id, file) - expect(after).toBeInstanceOf(Date) - expect(after!.getTime()).toBeGreaterThan(0) - }), - ), - ) - - it.live("tracks separate timestamps per session", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - const one = SessionID.make("ses_00000000000000000000000002") - const two = SessionID.make("ses_00000000000000000000000003") - yield* read(one, file) - yield* read(two, file) - - const first = yield* get(one, file) - const second = yield* get(two, file) - - expect(first).toBeDefined() - expect(second).toBeDefined() - }), - ), - ) - - it.live("updates timestamp on subsequent reads", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - yield* read(id, file) - const first = yield* get(id, file) - - yield* read(id, file) - const second = yield* get(id, file) - - expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()) - }), - ), - ) - - it.live("isolates reads by directory", () => - Effect.gen(function* () { - const one = yield* tmpdirScoped() - const two = yield* tmpdirScoped() - const shared = yield* tmpdirScoped() - const file = path.join(shared, "file.txt") - yield* put(file, "content") - - yield* provideInstance(one)(read(id, file)) - const result = yield* provideInstance(two)(get(id, file)) - expect(result).toBeUndefined() - }), - ) - }) - - describe("assert()", () => { - it.live("passes when file has not been modified", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - yield* read(id, file) - yield* check(id, file) - }), - ), - ) - - it.live("throws when file was not read first", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - const err = yield* fail(check(id, file)) - expect(err.message).toContain("You must read file") - }), - ), - ) - - it.live("throws when file was modified after read", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - yield* read(id, file) - yield* put(file, "modified content") - yield* touch(file, 2_000) - - const err = yield* fail(check(id, file)) - expect(err.message).toContain("modified since it was last read") - }), - ), - ) - - it.live("includes timestamps in error message", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - yield* read(id, file) - yield* put(file, "modified") - yield* touch(file, 2_000) - - const err = yield* fail(check(id, file)) - expect(err.message).toContain("Last modification:") - expect(err.message).toContain("Last read:") - }), - ), - ) - }) - - describe("withLock()", () => { - it.live("executes function within lock", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - let hit = false - - yield* lock(file, () => - Effect.sync(() => { - hit = true - return "result" - }), - ) - - expect(hit).toBe(true) - }), - ), - ) - - it.live("returns function result", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - const result = yield* lock(file, () => Effect.succeed("success")) - expect(result).toBe("success") - }), - ), - ) - - it.live("serializes concurrent operations on same file", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - const order: number[] = [] - const hold = yield* Deferred.make() - const ready = yield* Deferred.make() - - const one = yield* lock(file, () => - Effect.gen(function* () { - order.push(1) - yield* Deferred.succeed(ready, void 0) - yield* Deferred.await(hold) - order.push(2) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.await(ready) - - const two = yield* lock(file, () => - Effect.sync(() => { - order.push(3) - order.push(4) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.succeed(hold, void 0) - yield* Fiber.join(one) - yield* Fiber.join(two) - - expect(order).toEqual([1, 2, 3, 4]) - }), - ), - ) - - it.live("allows concurrent operations on different files", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const onefile = path.join(dir, "file1.txt") - const twofile = path.join(dir, "file2.txt") - let one = false - let two = false - const hold = yield* Deferred.make() - const ready = yield* Deferred.make() - - const a = yield* lock(onefile, () => - Effect.gen(function* () { - one = true - yield* Deferred.succeed(ready, void 0) - yield* Deferred.await(hold) - expect(two).toBe(true) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.await(ready) - - const b = yield* lock(twofile, () => - Effect.sync(() => { - two = true - }), - ).pipe(Effect.forkScoped) - - yield* Fiber.join(b) - yield* Deferred.succeed(hold, void 0) - yield* Fiber.join(a) - - expect(one).toBe(true) - expect(two).toBe(true) - }), - ), - ) - - it.live("releases lock even if function throws", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - const err = yield* fail(lock(file, () => Effect.die(new Error("Test error")))) - expect(err.message).toContain("Test error") - - let hit = false - yield* lock(file, () => - Effect.sync(() => { - hit = true - }), - ) - expect(hit).toBe(true) - }), - ), - ) - }) - - describe("path normalization", () => { - it.live("read with forward slashes, assert with backslashes", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - const forward = file.replaceAll("\\", "/") - yield* read(id, forward) - yield* check(id, file) - }), - ), - ) - - it.live("read with backslashes, assert with forward slashes", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - const forward = file.replaceAll("\\", "/") - yield* read(id, file) - yield* check(id, forward) - }), - ), - ) - - it.live("get returns timestamp regardless of slash direction", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - const forward = file.replaceAll("\\", "/") - yield* read(id, forward) - - const result = yield* get(id, file) - expect(result).toBeInstanceOf(Date) - }), - ), - ) - - it.live("withLock serializes regardless of slash direction", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - const forward = file.replaceAll("\\", "/") - const order: number[] = [] - const hold = yield* Deferred.make() - const ready = yield* Deferred.make() - - const one = yield* lock(file, () => - Effect.gen(function* () { - order.push(1) - yield* Deferred.succeed(ready, void 0) - yield* Deferred.await(hold) - order.push(2) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.await(ready) - - const two = yield* lock(forward, () => - Effect.sync(() => { - order.push(3) - order.push(4) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.succeed(hold, void 0) - yield* Fiber.join(one) - yield* Fiber.join(two) - - expect(order).toEqual([1, 2, 3, 4]) - }), - ), - ) - }) - - describe("stat() Filesystem.stat pattern", () => { - it.live("reads file modification time via Filesystem.stat()", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - yield* read(id, file) - - const stat = Filesystem.stat(file) - expect(stat?.mtime).toBeInstanceOf(Date) - expect(stat!.mtime.getTime()).toBeGreaterThan(0) - - yield* check(id, file) - }), - ), - ) - - it.live("detects modification via stat mtime", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "original") - yield* touch(file, 1_000) - - yield* read(id, file) - - const first = Filesystem.stat(file) - - yield* put(file, "modified") - yield* touch(file, 2_000) - - const second = Filesystem.stat(file) - expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime()) - - yield* fail(check(id, file)) - }), - ), - ) - }) -}) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 470087be7..f4f00849d 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -8,7 +8,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config/config" -import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" @@ -139,16 +138,6 @@ const lsp = Layer.succeed( }), ) -const filetime = Layer.succeed( - FileTime.Service, - FileTime.Service.of({ - read: () => Effect.void, - get: () => Effect.succeed(undefined), - assert: () => Effect.void, - withLock: (_filepath, fn) => fn(), - }), -) - const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) @@ -163,7 +152,6 @@ function makeHttp() { Plugin.defaultLayer, Config.defaultLayer, ProviderSvc.defaultLayer, - filetime, lsp, mcp, AppFileSystem.defaultLayer, @@ -1399,8 +1387,8 @@ unix( expect(tool.state.metadata.truncated).toBe(true) expect(typeof tool.state.metadata.outputPath).toBe("string") - expect(tool.state.output).toContain("The tool call succeeded but the output was truncated.") - expect(tool.state.output).toContain("Full output saved to:") + expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/) expect(tool.state.output).not.toContain("Tool execution aborted") }), { git: true, config: providerCfg }, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 391d9d488..6f9c1c739 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -33,7 +33,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config/config" -import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" @@ -102,16 +101,6 @@ const lsp = Layer.succeed( }), ) -const filetime = Layer.succeed( - FileTime.Service, - FileTime.Service.of({ - read: () => Effect.void, - get: () => Effect.succeed(undefined), - assert: () => Effect.void, - withLock: (_filepath, fn) => fn(), - }), -) - const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) @@ -127,7 +116,6 @@ function makeHttp() { Plugin.defaultLayer, Config.defaultLayer, ProviderSvc.defaultLayer, - filetime, lsp, mcp, AppFileSystem.defaultLayer, diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index fc7a9e73e..565919056 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1116,8 +1116,8 @@ describe("tool.bash truncation", () => { ), ) mustTruncate(result) - expect(result.output).toContain("truncated") - expect(result.output).toContain("The tool call succeeded but the output was truncated") + expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(result.output).toMatch(/Full output saved to:\s+\S+/) }, }) }) @@ -1138,8 +1138,8 @@ describe("tool.bash truncation", () => { ), ) mustTruncate(result) - expect(result.output).toContain("truncated") - expect(result.output).toContain("The tool call succeeded but the output was truncated") + expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(result.output).toMatch(/Full output saved to:\s+\S+/) }, }) }) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index e3f28df35..845f5c0d3 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -5,7 +5,6 @@ import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" 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" @@ -30,15 +29,9 @@ afterEach(async () => { await Instance.disposeAll() }) -async function touch(file: string, time: number) { - const date = new Date(time) - await fs.utimes(file, date, date) -} - const runtime = ManagedRuntime.make( Layer.mergeAll( LSP.defaultLayer, - FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, @@ -59,9 +52,6 @@ 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))) @@ -151,6 +141,34 @@ describe("tool.edit", () => { }) describe("editing existing files", () => { + test("replaces text without requiring a prior read", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing-no-read.txt") + await fs.writeFile(filepath, "old content here", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "old content", + newString: "new content", + }, + ctx, + ), + ) + + expect(result.output).toContain("Edit applied successfully") + + const content = await fs.readFile(filepath, "utf-8") + expect(content).toBe("new content here") + }, + }) + }) + test("replaces text in existing file", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "existing.txt") @@ -159,8 +177,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() const result = await Effect.runPromise( edit.execute( @@ -188,8 +204,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await expect( Effect.runPromise( @@ -240,8 +254,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await expect( Effect.runPromise( @@ -259,65 +271,6 @@ describe("tool.edit", () => { }) }) - test("throws error when file was not read first (FileTime)", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "content", - newString: "modified", - }, - ctx, - ), - ), - ).rejects.toThrow("You must read file") - }, - }) - }) - - test("throws error when file has been modified since read", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original content", "utf-8") - await touch(filepath, 1_000) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Read first - await readFileTime(ctx.sessionID, filepath) - - // Simulate external modification - await fs.writeFile(filepath, "modified externally", "utf-8") - await touch(filepath, 2_000) - - // Try to edit with the new content - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "modified externally", - newString: "edited", - }, - ctx, - ), - ), - ).rejects.toThrow("modified since it was last read") - }, - }) - }) - test("replaces all occurrences with replaceAll option", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") @@ -326,8 +279,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await Effect.runPromise( edit.execute( @@ -355,8 +306,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const { FileWatcher } = await import("../../src/file/watcher") const events: string[] = [] @@ -390,8 +339,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await Effect.runPromise( edit.execute( @@ -418,8 +365,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await Effect.runPromise( edit.execute( @@ -471,8 +416,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, dirpath) - const edit = await resolve() await expect( Effect.runPromise( @@ -498,8 +441,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() const result = await Effect.runPromise( edit.execute( @@ -571,7 +512,6 @@ describe("tool.edit", () => { fn: async () => { const edit = await resolve() const filePath = path.join(tmp.path, "test.txt") - await readFileTime(ctx.sessionID, filePath) await Effect.runPromise( edit.execute( { @@ -714,8 +654,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() // Two concurrent edits @@ -730,9 +668,6 @@ describe("tool.edit", () => { ), ) - // Need to read again since FileTime tracks per-session - await readFileTime(ctx.sessionID, filepath) - const promise2 = Effect.runPromise( edit.execute( { diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts new file mode 100644 index 000000000..a3886ee1c --- /dev/null +++ b/packages/opencode/test/tool/glob.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, ManagedRuntime } from "effect" +import path from "path" +import { GlobTool } from "../../src/tool/glob" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { SessionID, MessageID } from "../../src/session/schema" +import { Truncate } from "../../src/tool/truncate" +import { Agent } from "../../src/agent/agent" +import { Ripgrep } from "../../src/file/ripgrep" +import { AppFileSystem } from "../../src/filesystem" +import type { Permission } from "../../src/permission" + +const runtime = ManagedRuntime.make( + Layer.mergeAll(AppFileSystem.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer), +) + +function initGlob() { + return runtime.runPromise(GlobTool.pipe(Effect.flatMap((info) => info.init()))) +} + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +describe("tool.glob", () => { + test("lists matching files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "a.ts"), "export const a = 1\n") + await Bun.write(path.join(dir, "b.ts"), "export const b = 2\n") + await Bun.write(path.join(dir, "c.txt"), "ignore\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await initGlob() + const result = await Effect.runPromise( + glob.execute( + { + pattern: "*.ts", + path: tmp.path, + }, + ctx, + ), + ) + + expect(result.metadata.count).toBe(2) + expect(result.output).toContain(path.join(tmp.path, "a.ts")) + expect(result.output).toContain(path.join(tmp.path, "b.ts")) + expect(result.output).not.toContain(path.join(tmp.path, "c.txt")) + }, + }) + }) + + test("sorts newer files first", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const older = path.join(dir, "older.ts") + const newer = path.join(dir, "newer.ts") + await Bun.write(older, "export const older = true\n") + await new Promise((resolve) => setTimeout(resolve, 20)) + await Bun.write(newer, "export const newer = true\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await initGlob() + const result = await Effect.runPromise( + glob.execute( + { + pattern: "*.ts", + path: tmp.path, + }, + ctx, + ), + ) + + const lines = result.output.split("\n").filter(Boolean) + expect(lines[0]).toBe(path.join(tmp.path, "newer.ts")) + expect(lines[1]).toBe(path.join(tmp.path, "older.ts")) + }, + }) + }) + + test("rejects file path as search root", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "single.ts"), "export const single = true\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await initGlob() + await expect( + Effect.runPromise( + glob.execute( + { + pattern: "*.ts", + path: path.join(tmp.path, "single.ts"), + }, + ctx, + ), + ), + ).rejects.toThrow("glob path must be a directory") + }, + }) + }) + + test("asks for external_directory permission when searching outside project", async () => { + await using tmp = await tmpdir() + await using outer = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "external.ts"), "export const ext = true\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await initGlob() + const requests: Array> = [] + await Effect.runPromise( + glob.execute( + { + pattern: "*.ts", + path: outer.path, + }, + { + ...ctx, + ask: (req: Omit) => + Effect.sync(() => { + requests.push(req) + }), + }, + ), + ) + + const ext = requests.find((item) => item.permission === "external_directory") + expect(ext).toBeDefined() + expect(ext!.patterns[0]).toContain("*") + }, + }) + }) + + test("honors an aborted signal before starting ripgrep", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "export const match = true\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await initGlob() + const controller = new AbortController() + controller.abort() + + await expect( + Effect.runPromise( + glob.execute( + { + pattern: "*.ts", + path: tmp.path, + }, + { + ...ctx, + abort: controller.signal, + }, + ), + ), + ).rejects.toThrow(/abort/i) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 4715ad925..0db6b9b6d 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { Effect, Layer, ManagedRuntime } from "effect" +import { Effect, Layer, ManagedRuntime, Stream } from "effect" +import { ChildProcessSpawner } from "effect/unstable/process" import { GrepTool } from "../../src/tool/grep" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -8,15 +9,42 @@ import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Truncate } from "../../src/tool/truncate" import { Agent } from "../../src/agent/agent" +import { AppFileSystem } from "../../src/filesystem" const runtime = ManagedRuntime.make( - Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer), + Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer), ) function initGrep() { return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init()))) } +function deferred() { + let resolve!: () => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +async function withRipgrepConfig(contents: string, fn: () => Promise) { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "ripgreprc"), contents) + }, + }) + + const previous = process.env.RIPGREP_CONFIG_PATH + process.env.RIPGREP_CONFIG_PATH = path.join(tmp.path, "ripgreprc") + + try { + await fn() + } finally { + if (previous === undefined) delete process.env.RIPGREP_CONFIG_PATH + else process.env.RIPGREP_CONFIG_PATH = previous + } +} + const ctx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make(""), @@ -102,6 +130,171 @@ describe("tool.grep", () => { }, }) }) + + test("supports searching a single file path", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "export const target = 'hit'\n") + await Bun.write(path.join(dir, "other.ts"), "export const other = 'miss'\n") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await initGrep() + const result = await Effect.runPromise( + grep.execute( + { + pattern: "target", + path: path.join(tmp.path, "match.ts"), + }, + ctx, + ), + ) + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(path.join(tmp.path, "match.ts")) + expect(result.output).not.toContain(path.join(tmp.path, "other.ts")) + }, + }) + }) + + test("throws on invalid regex instead of returning an empty result", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "target\n") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await initGrep() + await expect( + Effect.runPromise( + grep.execute( + { + pattern: "[", + path: tmp.path, + }, + ctx, + ), + ), + ).rejects.toThrow() + }, + }) + }) + + test("ignores RIPGREP_CONFIG_PATH from the parent environment", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const needle = true\n") + }, + }) + + await withRipgrepConfig("--glob=!*.ts\n", async () => { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await initGrep() + const result = await Effect.runPromise( + grep.execute( + { + pattern: "needle", + path: tmp.path, + }, + ctx, + ), + ) + + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(path.join(tmp.path, "match.ts")) + }, + }) + }) + }) + + test("kills ripgrep promptly when the tool is aborted mid-run", async () => { + const started = deferred() + let killCount = 0 + let releaseExit!: () => void + let running = true + + const exitPromise = new Promise((resolve) => { + releaseExit = () => { + if (!running) return + running = false + resolve(130) + } + }) + + const spawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + started.resolve() + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.promise(async () => ChildProcessSpawner.ExitCode(await exitPromise)), + isRunning: Effect.sync(() => running), + kill: () => + Effect.sync(() => { + killCount += 1 + releaseExit() + }), + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }) + }), + ), + ) + + const testRuntime = ManagedRuntime.make( + Layer.mergeAll(spawner, AppFileSystem.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "target\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await testRuntime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init()))) + const controller = new AbortController() + const run = Effect.runPromise( + grep.execute( + { + pattern: "target", + path: tmp.path, + }, + { + ...ctx, + abort: controller.signal, + }, + ), + ) + + await started.promise + controller.abort() + + await expect( + Promise.race([ + run, + new Promise((_, reject) => { + setTimeout(() => reject(new Error("grep abort did not resolve within 2s")), 2_000) + }), + ]), + ).rejects.toThrow(/abort/i) + expect(killCount).toBe(1) + }, + }) + }) }) describe("CRLF regex handling", () => { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index e775899e8..3a9c0e8e3 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,7 +4,6 @@ import path from "path" import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { AppFileSystem } from "../../src/filesystem" -import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" @@ -39,7 +38,6 @@ const it = testEffect( Agent.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, - FileTime.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, Truncate.defaultLayer, @@ -205,10 +203,10 @@ describe("tool.read external_directory permission", () => { describe("tool.read env file permissions", () => { const cases: [string, boolean][] = [ - [".env", false], - [".env.local", false], - [".env.production", false], - [".env.development.local", false], + [".env", true], + [".env.local", true], + [".env.production", true], + [".env.development.local", true], [".env.example", false], [".envrc", false], ["environment.ts", false], @@ -256,6 +254,20 @@ describe("tool.read env file permissions", () => { } }) +it.live("default build agent asks for external_directory access outside the project", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const rule = yield* provideInstance(dir)( + Effect.gen(function* () { + const agent = yield* Agent.Service + const info = yield* agent.get("build") + return Permission.evaluate("external_directory", "/tmp/external/*", info.permission) + }), + ) + expect(rule.action).toBe("ask") + }), +) + describe("tool.read truncation", () => { it.live("truncates large file by bytes and sets truncated metadata", () => Effect.gen(function* () { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 9363da1f2..a1cc287ee 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -12,6 +12,18 @@ afterEach(async () => { }) describe("tool.registry", () => { + test("does not expose retired trash tool", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).not.toContain("trash") + }, + }) + }) + test("loads tools from .opencode/tool (singular)", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/trash.test.ts b/packages/opencode/test/tool/trash.test.ts deleted file mode 100644 index 041bd5b9d..000000000 --- a/packages/opencode/test/tool/trash.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" -import fs from "fs/promises" -import path from "path" -import { Agent } from "../../src/agent/agent" -import { AppFileSystem } from "../../src/filesystem" -import { Instance } from "../../src/project/instance" -import { SessionID, MessageID } from "../../src/session/schema" -import { Tool } from "../../src/tool/tool" -import { Truncate } from "../../src/tool/truncate" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -const trashCalls: string[][] = [] - -type AskRequest = { - permission: string - patterns: string[] - always: string[] - metadata: Record -} - -mock.module("trash", () => ({ - default: async (paths: string[]) => { - trashCalls.push(paths) - }, -})) - -const { TrashTool } = await import("../../src/tool/trash") - -const ctx = { - sessionID: SessionID.make("ses_test-trash-session"), - messageID: MessageID.make(""), - callID: "", - agent: "build", - abort: AbortSignal.any([]), - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, -} - -const glob = (p: string) => - process.platform === "win32" ? AppFileSystem.normalizePathPattern(p) : p.replaceAll("\\", "/") - -afterEach(async () => { - await Instance.disposeAll() -}) - -beforeEach(() => { - trashCalls.length = 0 -}) - -const it = testEffect( - Layer.mergeAll( - AppFileSystem.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - ), -) - -const init = Effect.fn("TrashToolTest.init")(function* () { - const info = yield* TrashTool - return yield* info.init() -}) - -const run = Effect.fn("TrashToolTest.run")(function* ( - args: Tool.InferParameters, - next: Tool.Context = ctx, -) { - const tool = yield* init() - return yield* tool.execute(args, next) -}) - -describe("tool.trash", () => { - it.live("moves project files to trash using absolute paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "notes.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "hello", "utf-8")) - - const requests: AskRequest[] = [] - const result = yield* run( - { path: "notes.txt" }, - { - ...ctx, - ask: (req) => - Effect.sync(() => { - requests.push(req) - }), - }, - ) - - expect(result.output).toContain("Moved item to Trash") - expect(trashCalls).toEqual([[filepath]]) - expect(requests.find((item) => item.permission === "trash")?.patterns).toEqual(["notes.txt"]) - expect(requests.find((item) => item.permission === "external_directory")).toBeUndefined() - }), - ), - ) - - it.live("asks for external_directory before trashing outside the project", () => - Effect.gen(function* () { - const outer = yield* tmpdirScoped() - const filepath = path.join(outer, "outside.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "outside", "utf-8")) - - yield* provideTmpdirInstance((dir) => - Effect.gen(function* () { - const requests: AskRequest[] = [] - yield* run( - { path: filepath }, - { - ...ctx, - ask: (req) => - Effect.sync(() => { - requests.push(req) - }), - }, - ) - - expect(trashCalls).toEqual([[filepath]]) - expect(requests.find((item) => item.permission === "external_directory")?.patterns).toEqual([ - glob(path.join(outer, "*")), - ]) - expect(requests.find((item) => item.permission === "trash")?.patterns).toEqual([filepath]) - }), - ) - }), - ) - - it.live("fails when the target path does not exist", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const exit = yield* run({ path: path.join(dir, "missing.txt") }).pipe(Effect.exit) - expect(exit._tag).toBe("Failure") - expect(trashCalls).toEqual([]) - }), - ), - ) -}) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index f7daa1e97..05b1749ad 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -6,7 +6,6 @@ import { WriteTool } from "../../src/tool/write" 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 { Truncate } from "../../src/tool/truncate" @@ -36,7 +35,6 @@ const it = testEffect( Layer.mergeAll( LSP.defaultLayer, AppFileSystem.defaultLayer, - FileTime.defaultLayer, Bus.layer, Format.defaultLayer, CrossSpawnSpawner.defaultLayer, @@ -58,11 +56,6 @@ const run = Effect.fn("WriteToolTest.run")(function* ( return yield* tool.execute(args, next) }) -const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) { - const ft = yield* FileTime.Service - yield* ft.read(sessionID as any, filepath) -}) - describe("tool.write", () => { describe("new file creation", () => { it.live("writes content to new file", () => @@ -105,12 +98,28 @@ describe("tool.write", () => { }) describe("existing file overwrite", () => { + it.live("overwrites existing file without requiring a prior read", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "existing-no-read.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) + + const result = yield* run({ filePath: filepath, content: "new content" }) + + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(true) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("new content") + }), + ), + ) + it.live("overwrites existing file content", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const filepath = path.join(dir, "existing.txt") yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) - yield* markRead(ctx.sessionID, filepath) const result = yield* run({ filePath: filepath, content: "new content" }) @@ -128,7 +137,6 @@ describe("tool.write", () => { Effect.gen(function* () { const filepath = path.join(dir, "file.txt") yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) - yield* markRead(ctx.sessionID, filepath) const result = yield* run({ filePath: filepath, content: "new" }) @@ -231,7 +239,6 @@ describe("tool.write", () => { const readonlyPath = path.join(dir, "readonly.txt") yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) - yield* markRead(ctx.sessionID, readonlyPath) const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) expect(exit._tag).toBe("Failure") From 32aed6472a00cf62938e48b0d2fd5c7e126d4ef0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 19 Apr 2026 01:12:26 +0800 Subject: [PATCH 2/4] test: align permission defaults expectations --- packages/opencode/test/agent/agent.test.ts | 4 ++-- packages/opencode/test/permission/pawwork-defaults.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 237300235..5d9a2c7f4 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -423,14 +423,14 @@ test("Agent.get returns undefined for non-existent agent", async () => { }) }) -test("default permission keeps doom_loop as ask and external_directory as allow", async () => { +test("default permission keeps doom_loop and external_directory as ask", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const build = await Agent.get("build") expect(evalPerm(build, "doom_loop")).toBe("ask") - expect(evalPerm(build, "external_directory")).toBe("allow") + expect(evalPerm(build, "external_directory")).toBe("ask") }, }) }) diff --git a/packages/opencode/test/permission/pawwork-defaults.test.ts b/packages/opencode/test/permission/pawwork-defaults.test.ts index 87377d806..18f06a31a 100644 --- a/packages/opencode/test/permission/pawwork-defaults.test.ts +++ b/packages/opencode/test/permission/pawwork-defaults.test.ts @@ -18,7 +18,7 @@ test("build agent uses PawWork permission defaults", async () => { expect(build).toBeDefined() expect(Permission.evaluate("read", "notes.txt", build!.permission).action).toBe("allow") expect(Permission.evaluate("edit", "notes.txt", build!.permission).action).toBe("allow") - expect(Permission.evaluate("external_directory", "/tmp/outside", build!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", "/tmp/outside", build!.permission).action).toBe("ask") expect(Permission.evaluate("bash", "ls -la", build!.permission).action).toBe("allow") expect(Permission.evaluate("bash", "git status", build!.permission).action).toBe("allow") expect(Permission.evaluate("bash", "rm file.txt", build!.permission).action).toBe("deny") From 6a01a3d6d55bf94e9aa873ee42fad3285215d3b7 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 19 Apr 2026 09:39:04 +0800 Subject: [PATCH 3/4] test: serialize config dependency tests --- packages/opencode/test/config/config.test.ts | 367 +++++++++--------- .../opencode/test/config/seed-e2e.test.ts | 77 ++-- .../test/plugin/loader-shared.test.ts | 75 ++-- .../opencode/test/shared/config-deps-lock.ts | 18 + packages/opencode/test/tool/registry.test.ts | 151 +++---- 5 files changed, 364 insertions(+), 324 deletions(-) create mode 100644 packages/opencode/test/shared/config-deps-lock.ts diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index dbbd6c7ed..7fb55b2c3 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -23,6 +23,7 @@ import { Filesystem } from "../../src/util/filesystem" import * as Network from "../../src/util/network" import { Npm } from "../../src/npm" import { writeMockConfigInstall } from "../shared/mock-npm-install" +import { withConfigDepsLock } from "../shared/config-deps-lock" import { Installation } from "../../src/installation" const emptyAccount = Layer.mock(Account.Service)({ @@ -741,211 +742,221 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as }) test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const cfg = path.join(dir, "configdir") - await fs.mkdir(cfg, { recursive: true }) - return cfg - }, - }) - - const prev = process.env.OPENCODE_CONFIG_DIR - process.env.OPENCODE_CONFIG_DIR = tmp.extra - const online = spyOn(Network, "online").mockReturnValue(false) - const install = spyOn(Npm, "install").mockImplementation((dir: string) => writeMockConfigInstall(dir)) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Config.get() - await Config.waitForDependencies() + await withConfigDepsLock(async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const cfg = path.join(dir, "configdir") + await fs.mkdir(cfg, { recursive: true }) + return cfg }, }) - expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true) - expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true) - expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json") - } finally { - online.mockRestore() - install.mockRestore() - if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR - else process.env.OPENCODE_CONFIG_DIR = prev - } -}) + const prev = process.env.OPENCODE_CONFIG_DIR + process.env.OPENCODE_CONFIG_DIR = tmp.extra + const online = spyOn(Network, "online").mockReturnValue(false) + const install = spyOn(Npm, "install").mockImplementation((dir: string) => writeMockConfigInstall(dir)) -test("dedupes concurrent config dependency installs for the same dir", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "a") - await fs.mkdir(dir, { recursive: true }) - - const ticks: number[] = [] - let calls = 0 - let start = () => {} - let done = () => {} - let blocked = () => {} - const ready = new Promise((resolve) => { - start = resolve - }) - const gate = new Promise((resolve) => { - done = resolve - }) - const waiting = new Promise((resolve) => { - blocked = resolve - }) - const online = spyOn(Network, "online").mockReturnValue(false) - const targetDir = dir - const run = spyOn(Npm, "install").mockImplementation(async (d: string) => { - const hit = path.normalize(d) === path.normalize(targetDir) - if (hit) { - calls += 1 - start() - await gate - } - await writeMockConfigInstall(d) - if (hit) { - start() - await gate + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + await Config.waitForDependencies() + }, + }) + + expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true) + expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json") + } finally { + online.mockRestore() + install.mockRestore() + if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = prev } }) +}) - try { - const first = Config.installDependencies(dir) - await ready - const second = Config.installDependencies(dir, { - waitTick: (tick) => { - ticks.push(tick.attempt) - blocked() - blocked = () => {} - }, +test("dedupes concurrent config dependency installs for the same dir", async () => { + await withConfigDepsLock(async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "a") + await fs.mkdir(dir, { recursive: true }) + + const ticks: number[] = [] + let calls = 0 + let start = () => {} + let done = () => {} + let blocked = () => {} + const ready = new Promise((resolve) => { + start = resolve + }) + const gate = new Promise((resolve) => { + done = resolve + }) + const waiting = new Promise((resolve) => { + blocked = resolve + }) + const online = spyOn(Network, "online").mockReturnValue(false) + const targetDir = dir + const run = spyOn(Npm, "install").mockImplementation(async (d: string) => { + const hit = path.normalize(d) === path.normalize(targetDir) + if (hit) { + calls += 1 + start() + await gate + } + await writeMockConfigInstall(d) + if (hit) { + start() + await gate + } }) - await waiting - done() - await Promise.all([first, second]) - } finally { - online.mockRestore() - run.mockRestore() - } - expect(calls).toBe(1) - expect(ticks.length).toBeGreaterThan(0) - expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true) + try { + const first = Config.installDependencies(dir) + await ready + const second = Config.installDependencies(dir, { + waitTick: (tick) => { + ticks.push(tick.attempt) + blocked() + blocked = () => {} + }, + }) + await waiting + done() + await Promise.all([first, second]) + } finally { + online.mockRestore() + run.mockRestore() + } + + expect(calls).toBe(1) + expect(ticks.length).toBeGreaterThan(0) + expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true) + }) }) test("serializes config dependency installs across dirs", async () => { if (process.platform !== "win32") return - await using tmp = await tmpdir() - const a = path.join(tmp.path, "a") - const b = path.join(tmp.path, "b") - await fs.mkdir(a, { recursive: true }) - await fs.mkdir(b, { recursive: true }) - - let calls = 0 - let open = 0 - let peak = 0 - let start = () => {} - let done = () => {} - const ready = new Promise((resolve) => { - start = resolve - }) - const gate = new Promise((resolve) => { - done = resolve - }) - - const online = spyOn(Network, "online").mockReturnValue(false) - const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => { - const cwd = path.normalize(dir) - const hit = cwd === path.normalize(a) || cwd === path.normalize(b) - if (hit) { - calls += 1 - open += 1 - peak = Math.max(peak, open) - if (calls === 1) { - start() - await gate + await withConfigDepsLock(async () => { + await using tmp = await tmpdir() + const a = path.join(tmp.path, "a") + const b = path.join(tmp.path, "b") + await fs.mkdir(a, { recursive: true }) + await fs.mkdir(b, { recursive: true }) + + let calls = 0 + let open = 0 + let peak = 0 + let start = () => {} + let done = () => {} + const ready = new Promise((resolve) => { + start = resolve + }) + const gate = new Promise((resolve) => { + done = resolve + }) + + const online = spyOn(Network, "online").mockReturnValue(false) + const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => { + const cwd = path.normalize(dir) + const hit = cwd === path.normalize(a) || cwd === path.normalize(b) + if (hit) { + calls += 1 + open += 1 + peak = Math.max(peak, open) + if (calls === 1) { + start() + await gate + } } - } - await writeMockConfigInstall(cwd) - if (hit) { - open -= 1 - } - }) + await writeMockConfigInstall(cwd) + if (hit) { + open -= 1 + } + }) - try { - const first = Config.installDependencies(a) - await ready - const second = Config.installDependencies(b) - done() - await Promise.all([first, second]) - } finally { - online.mockRestore() - run.mockRestore() - } + try { + const first = Config.installDependencies(a) + await ready + const second = Config.installDependencies(b) + done() + await Promise.all([first, second]) + } finally { + online.mockRestore() + run.mockRestore() + } - expect(calls).toBe(2) - expect(peak).toBe(1) + expect(calls).toBe(2) + expect(peak).toBe(1) + }) }) test("skips reinstall when config dependencies are already bootstrapped", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "configdir") - await fs.mkdir(dir, { recursive: true }) - const target = Installation.isLocal() ? "*" : Installation.VERSION - await Filesystem.writeJson(path.join(dir, "package.json"), { - dependencies: { - "@opencode-ai/plugin": target, - }, - }) - await Filesystem.write( - path.join(dir, ".gitignore"), - ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), - ) - await writeMockConfigInstall(dir) + await withConfigDepsLock(async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "configdir") + await fs.mkdir(dir, { recursive: true }) + const target = Installation.isLocal() ? "*" : Installation.VERSION + await Filesystem.writeJson(path.join(dir, "package.json"), { + dependencies: { + "@opencode-ai/plugin": target, + }, + }) + await Filesystem.write( + path.join(dir, ".gitignore"), + ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), + ) + await writeMockConfigInstall(dir) + + const install = spyOn(Npm, "install").mockImplementation(async () => { + throw new Error("should not reinstall bootstrapped config dependencies") + }) - const install = spyOn(Npm, "install").mockImplementation(async () => { - throw new Error("should not reinstall bootstrapped config dependencies") + try { + await expect(Config.installDependencies(dir)).resolves.toBeUndefined() + expect(install).not.toHaveBeenCalled() + } finally { + install.mockRestore() + } }) - - try { - await expect(Config.installDependencies(dir)).resolves.toBeUndefined() - expect(install).not.toHaveBeenCalled() - } finally { - install.mockRestore() - } }) test("reinstalls when declared config dependencies are missing from node_modules", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "configdir") - await fs.mkdir(path.join(dir, "node_modules", "@opencode-ai", "plugin"), { recursive: true }) - const target = Installation.isLocal() ? "*" : Installation.VERSION - await Filesystem.writeJson(path.join(dir, "package.json"), { - dependencies: { - "@opencode-ai/plugin": target, - "late-dep": "^1.0.0", - }, - }) - await Filesystem.write( - path.join(dir, ".gitignore"), - ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), - ) - await Filesystem.writeJson(path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json"), { - name: "@opencode-ai/plugin", - version: "1.0.0", - type: "module", - exports: "./index.js", - }) + await withConfigDepsLock(async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "configdir") + await fs.mkdir(path.join(dir, "node_modules", "@opencode-ai", "plugin"), { recursive: true }) + const target = Installation.isLocal() ? "*" : Installation.VERSION + await Filesystem.writeJson(path.join(dir, "package.json"), { + dependencies: { + "@opencode-ai/plugin": target, + "late-dep": "^1.0.0", + }, + }) + await Filesystem.write( + path.join(dir, ".gitignore"), + ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), + ) + await Filesystem.writeJson(path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json"), { + name: "@opencode-ai/plugin", + version: "1.0.0", + type: "module", + exports: "./index.js", + }) - const install = spyOn(Npm, "install").mockImplementation(async (cwd: string) => writeMockConfigInstall(cwd)) + const install = spyOn(Npm, "install").mockImplementation(async (cwd: string) => writeMockConfigInstall(cwd)) - try { - await expect(Config.installDependencies(dir)).resolves.toBeUndefined() - expect(install).toHaveBeenCalledTimes(1) - await expect(Filesystem.exists(path.join(dir, "node_modules", "late-dep", "package.json"))).resolves.toBe(true) - } finally { - install.mockRestore() - } + try { + await expect(Config.installDependencies(dir)).resolves.toBeUndefined() + expect(install).toHaveBeenCalledTimes(1) + await expect(Filesystem.exists(path.join(dir, "node_modules", "late-dep", "package.json"))).resolves.toBe(true) + } finally { + install.mockRestore() + } + }) }) test("resolves scoped npm plugins in config", async () => { diff --git a/packages/opencode/test/config/seed-e2e.test.ts b/packages/opencode/test/config/seed-e2e.test.ts index a0ea3d0a7..95c0dc4ed 100644 --- a/packages/opencode/test/config/seed-e2e.test.ts +++ b/packages/opencode/test/config/seed-e2e.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { Process } from "../../src/util/process" +import { withConfigDepsLock } from "../shared/config-deps-lock" const packageRoot = path.resolve(import.meta.dir, "../..") const repoRoot = path.resolve(import.meta.dir, "../../../../") @@ -13,45 +14,47 @@ async function mkdir(name: string) { describe("seed e2e script", () => { test("exits cleanly after creating the seeded session", async () => { - const [home, data, cache, config, state] = await Promise.all([ - mkdir("opencode-seed-home"), - mkdir("opencode-seed-data"), - mkdir("opencode-seed-cache"), - mkdir("opencode-seed-config"), - mkdir("opencode-seed-state"), - ]) + await withConfigDepsLock(async () => { + const [home, data, cache, config, state] = await Promise.all([ + mkdir("opencode-seed-home"), + mkdir("opencode-seed-data"), + mkdir("opencode-seed-cache"), + mkdir("opencode-seed-config"), + mkdir("opencode-seed-state"), + ]) - const abort = new AbortController() - const timer = setTimeout(() => abort.abort(), 5_000) + const abort = new AbortController() + const timer = setTimeout(() => abort.abort(), 5_000) - try { - const out = await Process.run(["bun", "script/seed-e2e.ts"], { - cwd: packageRoot, - abort: abort.signal, - timeout: 100, - nothrow: true, - env: { - OPENCODE_CLIENT: "app", - OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", - OPENCODE_DISABLE_LSP_DOWNLOAD: "true", - OPENCODE_DISABLE_SHARE: "true", - OPENCODE_E2E_PROJECT_DIR: repoRoot, - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", - OPENCODE_STRICT_CONFIG_DEPS: "true", - OPENCODE_TEST_HOME: home, - XDG_CACHE_HOME: cache, - XDG_CONFIG_HOME: config, - XDG_DATA_HOME: data, - XDG_STATE_HOME: state, - }, - }) + try { + const out = await Process.run(["bun", "script/seed-e2e.ts"], { + cwd: packageRoot, + abort: abort.signal, + timeout: 100, + nothrow: true, + env: { + OPENCODE_CLIENT: "app", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + OPENCODE_DISABLE_SHARE: "true", + OPENCODE_E2E_PROJECT_DIR: repoRoot, + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + OPENCODE_STRICT_CONFIG_DEPS: "true", + OPENCODE_TEST_HOME: home, + XDG_CACHE_HOME: cache, + XDG_CONFIG_HOME: config, + XDG_DATA_HOME: data, + XDG_STATE_HOME: state, + }, + }) - expect(out.code).toBe(0) - } finally { - clearTimeout(timer) - await Promise.allSettled( - [home, data, cache, config, state].map((dir) => fs.rm(dir, { recursive: true, force: true })), - ) - } + expect(out.code).toBe(0) + } finally { + clearTimeout(timer) + await Promise.allSettled( + [home, data, cache, config, state].map((dir) => fs.rm(dir, { recursive: true, force: true })), + ) + } + }) }, 15_000) }) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 1637d9267..525bf9b81 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -15,6 +15,7 @@ const { Instance } = await import("../../src/project/instance") const { Npm } = await import("../../src/npm") const { Config } = await import("../../src/config/config") const { writeMockConfigInstall } = await import("../shared/mock-npm-install") +const { withConfigDepsLock } = await import("../shared/config-deps-lock") afterAll(() => { if (disableDefault === undefined) { @@ -750,45 +751,47 @@ describe("plugin.loader.shared", () => { }) test("retries auto-discovered file plugins that reach config deps through helper imports", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const pluginsDir = path.join(dir, ".opencode", "plugins") - const pluginFile = path.join(pluginsDir, "plugin.ts") - const helperFile = path.join(pluginsDir, "helper.ts") - const mark = path.join(dir, "plugin.txt") + await withConfigDepsLock(async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginsDir = path.join(dir, ".opencode", "plugins") + const pluginFile = path.join(pluginsDir, "plugin.ts") + const helperFile = path.join(pluginsDir, "helper.ts") + const mark = path.join(dir, "plugin.txt") + + await fs.mkdir(pluginsDir, { recursive: true }) + await Bun.write( + helperFile, + ["import { ready } from 'late-dep'", "export { ready }", ""].join("\n"), + ) + await Bun.write( + pluginFile, + [ + "import { ready } from './helper'", + "export default {", + ' id: "demo.helper",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, ready)`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + + return { mark } + }, + }) - await fs.mkdir(pluginsDir, { recursive: true }) - await Bun.write( - helperFile, - ["import { ready } from 'late-dep'", "export { ready }", ""].join("\n"), - ) - await Bun.write( - pluginFile, - [ - "import { ready } from './helper'", - "export default {", - ' id: "demo.helper",', - " server: async () => {", - ` await Bun.write(${JSON.stringify(mark)}, ready)`, - " return {}", - " },", - "}", - "", - ].join("\n"), - ) + const install = spyOn(Npm, "install").mockImplementation(async (dir: string) => writeMockConfigInstall(dir)) - return { mark } - }, + try { + await load(tmp.path) + expect(await Bun.file(tmp.extra.mark).text()).toBe("hello") + } finally { + install.mockRestore() + } }) - - const install = spyOn(Npm, "install").mockImplementation(async (dir: string) => writeMockConfigInstall(dir)) - - try { - await load(tmp.path) - expect(await Bun.file(tmp.extra.mark).text()).toBe("hello") - } finally { - install.mockRestore() - } }) test("loads object plugin via plugin.server", async () => { diff --git a/packages/opencode/test/shared/config-deps-lock.ts b/packages/opencode/test/shared/config-deps-lock.ts new file mode 100644 index 000000000..d703c4592 --- /dev/null +++ b/packages/opencode/test/shared/config-deps-lock.ts @@ -0,0 +1,18 @@ +import os from "os" +import path from "path" +import { Flock } from "../../src/util/flock" + +const LOCK_KEY = "test-config-deps" +const LOCK_DIR = path.join(os.tmpdir(), "opencode-test-locks") +const LOCK_TIMEOUT_MS = 10_000 + +export async function withConfigDepsLock(fn: () => Promise): Promise { + await using _ = await Flock.acquire(LOCK_KEY, { + dir: LOCK_DIR, + staleMs: 30_000, + timeoutMs: LOCK_TIMEOUT_MS, + baseDelayMs: 20, + maxDelayMs: 200, + }) + return await fn() +} diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index a1cc287ee..6297f7ca9 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -3,6 +3,7 @@ import path from "path" import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { writeMockConfigInstall } from "../shared/mock-npm-install" +import { withConfigDepsLock } from "../shared/config-deps-lock" import { Instance } from "../../src/project/instance" import { ToolRegistry } from "../../src/tool/registry" import { Npm } from "../../src/npm" @@ -170,86 +171,90 @@ describe("tool.registry", () => { }) test("waits for config-scoped dependencies before importing local tools with bare imports", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const toolsDir = path.join(dir, ".opencode", "tools") - await fs.mkdir(toolsDir, { recursive: true }) - - await Bun.write( - path.join(toolsDir, "late.ts"), - [ - "import { ready } from 'late-dep'", - "export default {", - " description: 'tool that waits for dependencies',", - " args: {},", - " execute: async () => ready,", - "}", - "", - ].join("\n"), - ) - }, - }) - - const install = spyOn(Npm, "install").mockImplementation((dir: string) => writeMockConfigInstall(dir)) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ids = await ToolRegistry.ids() - expect(ids).toContain("late") + await withConfigDepsLock(async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const toolsDir = path.join(dir, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + + await Bun.write( + path.join(toolsDir, "late.ts"), + [ + "import { ready } from 'late-dep'", + "export default {", + " description: 'tool that waits for dependencies',", + " args: {},", + " execute: async () => ready,", + "}", + "", + ].join("\n"), + ) }, }) - expect( - install.mock.calls.some(([dir]) => path.normalize(dir) === path.normalize(path.join(tmp.path, ".opencode"))), - ).toBe(true) - } finally { - install.mockRestore() - } - }) - test("waits for config-scoped dependencies used through local helper imports", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const toolsDir = path.join(dir, ".opencode", "tools") - await fs.mkdir(toolsDir, { recursive: true }) - - await Bun.write( - path.join(toolsDir, "helper.ts"), - ["import { ready } from 'late-dep'", "export { ready }", ""].join("\n"), - ) - - await Bun.write( - path.join(toolsDir, "late.ts"), - [ - "import { ready } from './helper'", - "export default {", - " description: 'tool that waits for helper dependencies',", - " args: {},", - " execute: async () => ready,", - "}", - "", - ].join("\n"), - ) - }, + const install = spyOn(Npm, "install").mockImplementation((dir: string) => writeMockConfigInstall(dir)) + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("late") + }, + }) + expect( + install.mock.calls.some(([dir]) => path.normalize(dir) === path.normalize(path.join(tmp.path, ".opencode"))), + ).toBe(true) + } finally { + install.mockRestore() + } }) + }) - const install = spyOn(Npm, "install").mockImplementation((dir: string) => writeMockConfigInstall(dir)) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ids = await ToolRegistry.ids() - expect(ids).toContain("late") + test("waits for config-scoped dependencies used through local helper imports", async () => { + await withConfigDepsLock(async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const toolsDir = path.join(dir, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + + await Bun.write( + path.join(toolsDir, "helper.ts"), + ["import { ready } from 'late-dep'", "export { ready }", ""].join("\n"), + ) + + await Bun.write( + path.join(toolsDir, "late.ts"), + [ + "import { ready } from './helper'", + "export default {", + " description: 'tool that waits for helper dependencies',", + " args: {},", + " execute: async () => ready,", + "}", + "", + ].join("\n"), + ) }, }) - expect( - install.mock.calls.some(([dir]) => path.normalize(dir) === path.normalize(path.join(tmp.path, ".opencode"))), - ).toBe(true) - } finally { - install.mockRestore() - } + + const install = spyOn(Npm, "install").mockImplementation((dir: string) => writeMockConfigInstall(dir)) + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("late") + }, + }) + expect( + install.mock.calls.some(([dir]) => path.normalize(dir) === path.normalize(path.join(tmp.path, ".opencode"))), + ).toBe(true) + } finally { + install.mockRestore() + } + }) }) test("skips disabled tools before importing them", async () => { From 82590661b671c5ed4c000188f204d2981bede018 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 19 Apr 2026 09:56:17 +0800 Subject: [PATCH 4/4] fix: restore trash tool --- packages/opencode/src/tool/registry.ts | 4 + packages/opencode/src/tool/trash.ts | 64 +++++++ packages/opencode/src/tool/trash.txt | 4 + packages/opencode/test/tool/registry.test.ts | 4 +- packages/opencode/test/tool/trash.test.ts | 171 +++++++++++++++++++ 5 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/tool/trash.ts create mode 100644 packages/opencode/src/tool/trash.txt create mode 100644 packages/opencode/test/tool/trash.test.ts diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 778b838ea..8f68e833b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -8,6 +8,7 @@ import { GrepTool } from "./grep" import { ReadTool } from "./read" import { TaskTool } from "./task" import { TodoWriteTool } from "./todo" +import { TrashTool } from "./trash" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" @@ -121,6 +122,7 @@ export namespace ToolRegistry { const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool + const trashtool = yield* TrashTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -202,6 +204,7 @@ export namespace ToolRegistry { grep: Tool.init(greptool), edit: Tool.init(edit), write: Tool.init(writetool), + trash: Tool.init(trashtool), task: Tool.init(task), fetch: Tool.init(webfetch), todo: Tool.init(todo), @@ -225,6 +228,7 @@ export namespace ToolRegistry { tool.grep, tool.edit, tool.write, + tool.trash, tool.task, tool.fetch, tool.todo, diff --git a/packages/opencode/src/tool/trash.ts b/packages/opencode/src/tool/trash.ts new file mode 100644 index 000000000..610fc90ee --- /dev/null +++ b/packages/opencode/src/tool/trash.ts @@ -0,0 +1,64 @@ +import path from "path" +import z from "zod" +import trash from "trash" +import { Effect } from "effect" +import { Tool } from "./tool" +import { AppFileSystem } from "../filesystem" +import { Instance } from "../project/instance" +import DESCRIPTION from "./trash.txt" +import { assertExternalDirectoryEffect } from "./external-directory" + +const Parameters = z.object({ + path: z.string().describe("The file or directory path to move to the system Trash"), +}) + +export const TrashTool = Tool.define( + "trash", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const target = path.isAbsolute(params.path) ? params.path : path.join(Instance.directory, params.path) + const info = yield* fs.stat(target).pipe( + Effect.catchIf( + (e) => (e as any)?.cause?.code === "ENOENT" || (e as any)?.reason?._tag === "NotFound", + () => Effect.succeed(undefined), + ), + ) + + yield* assertExternalDirectoryEffect(ctx, target, { + kind: info?.type === "Directory" ? "directory" : "file", + }) + + if (!info) { + throw new Error(`Path not found: ${target}`) + } + + const localTarget = path.relative(Instance.directory, target) + const permissionPattern = Instance.containsPath(target) ? localTarget : target + yield* ctx.ask({ + permission: "trash", + patterns: [permissionPattern], + always: ["*"], + metadata: { + filepath: target, + }, + }) + + yield* Effect.promise(() => trash([target], { glob: false })) + + return { + title: Instance.containsPath(target) ? localTarget : target, + metadata: { + filepath: target, + }, + output: "Moved item to Trash successfully.", + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/trash.txt b/packages/opencode/src/tool/trash.txt new file mode 100644 index 000000000..167da065c --- /dev/null +++ b/packages/opencode/src/tool/trash.txt @@ -0,0 +1,4 @@ +Moves a file or directory to the system Trash. + +Use this instead of shell deletion commands like `rm`. +Accepts a single file or directory path, absolute or relative to the current project directory. diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 6297f7ca9..7f7326232 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -13,14 +13,14 @@ afterEach(async () => { }) describe("tool.registry", () => { - test("does not expose retired trash tool", async () => { + test("exposes trash tool", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const ids = await ToolRegistry.ids() - expect(ids).not.toContain("trash") + expect(ids).toContain("trash") }, }) }) diff --git a/packages/opencode/test/tool/trash.test.ts b/packages/opencode/test/tool/trash.test.ts new file mode 100644 index 000000000..d0263ee78 --- /dev/null +++ b/packages/opencode/test/tool/trash.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, mock } from "bun:test" +import { Effect, Layer } from "effect" +import fs from "fs/promises" +import path from "path" +import { Agent } from "../../src/agent/agent" +import { AppFileSystem } from "../../src/filesystem" +import { Instance } from "../../src/project/instance" +import { SessionID, MessageID } from "../../src/session/schema" +import { Tool } from "../../src/tool/tool" +import { Truncate } from "../../src/tool/truncate" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const trashCalls: string[][] = [] + +type AskRequest = { + permission: string + patterns: string[] + always: string[] + metadata: Record +} + +mock.module("trash", () => ({ + default: async (paths: string[]) => { + trashCalls.push(paths) + }, +})) + +const { TrashTool } = await import("../../src/tool/trash") + +const ctx = { + sessionID: SessionID.make("ses_test-trash-session"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const glob = (p: string) => + process.platform === "win32" ? AppFileSystem.normalizePathPattern(p) : p.replaceAll("\\", "/") + +afterEach(async () => { + await Instance.disposeAll() +}) + +beforeEach(() => { + trashCalls.length = 0 +}) + +const it = testEffect( + Layer.mergeAll( + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +const init = Effect.fn("TrashToolTest.init")(function* () { + const info = yield* TrashTool + return yield* info.init() +}) + +const run = Effect.fn("TrashToolTest.run")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const tool = yield* init() + return yield* tool.execute(args, next) +}) + +describe("tool.trash", () => { + it.live("moves project files to trash using absolute paths", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "notes.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "hello", "utf-8")) + + const requests: AskRequest[] = [] + const result = yield* run( + { path: "notes.txt" }, + { + ...ctx, + ask: (req) => + Effect.sync(() => { + requests.push(req) + }), + }, + ) + + expect(result.output).toContain("Moved item to Trash") + expect(trashCalls).toEqual([[filepath]]) + expect(requests.find((item) => item.permission === "trash")?.patterns).toEqual(["notes.txt"]) + expect(requests.find((item) => item.permission === "external_directory")).toBeUndefined() + }), + ), + ) + + it.live("asks for external_directory before trashing outside the project", () => + Effect.gen(function* () { + const outer = yield* tmpdirScoped() + const filepath = path.join(outer, "outside.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "outside", "utf-8")) + + yield* provideTmpdirInstance(() => + Effect.gen(function* () { + const requests: AskRequest[] = [] + yield* run( + { path: filepath }, + { + ...ctx, + ask: (req) => + Effect.sync(() => { + requests.push(req) + }), + }, + ) + + expect(trashCalls).toEqual([[filepath]]) + expect(requests.find((item) => item.permission === "external_directory")?.patterns).toEqual([ + glob(path.join(outer, "*")), + ]) + expect(requests.find((item) => item.permission === "trash")?.patterns).toEqual([filepath]) + }), + ) + }), + ) + + it.live("fails when the target path does not exist", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const exit = yield* run({ path: path.join(dir, "missing.txt") }).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + expect(trashCalls).toEqual([]) + }), + ), + ) + + it.live("asks for external_directory before failing on missing path outside the project", () => + Effect.gen(function* () { + const outer = yield* tmpdirScoped() + const missing = path.join(outer, "missing.txt") + + yield* provideTmpdirInstance(() => + Effect.gen(function* () { + const requests: AskRequest[] = [] + const exit = yield* run( + { path: missing }, + { + ...ctx, + ask: (req) => + Effect.sync(() => { + requests.push(req) + }), + }, + ).pipe(Effect.exit) + + expect(exit._tag).toBe("Failure") + expect(requests.find((item) => item.permission === "external_directory")?.patterns).toEqual([ + glob(path.join(outer, "*")), + ]) + expect(trashCalls).toEqual([]) + }), + ) + }), + ) +})