diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index d4412ae3026..dcea52120f3 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -5,6 +5,7 @@ import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { Effect, Schema } from "effect" import path from "path" import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" @@ -36,6 +37,7 @@ export const toModelOutput = (output: ModelOutput) => { export const Plugin = { id: "core-glob-tool", effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) { + const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service const permission = yield* PermissionV2.Service @@ -71,6 +73,13 @@ export const Plugin = { source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) const cwd = path.resolve(location.directory, input.path ?? ".") + yield* fs + .stat(cwd) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => + Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), + ), + ) return yield* ripgrep .glob({ cwd, @@ -88,7 +97,11 @@ export const Plugin = { ), ) }).pipe( - Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })), + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }), + ), ), }), }) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index e5256066098..35ac537c918 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -91,7 +91,13 @@ export const Plugin = { source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) const target = path.resolve(location.directory, input.path ?? ".") - const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined))) + const info = yield* fs + .stat(target) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => + Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), + ), + ) return yield* ripgrep .grep({ cwd: info?.type === "Directory" ? target : path.dirname(target), @@ -121,7 +127,13 @@ export const Plugin = { ), ), ) - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))), + }).pipe( + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to grep for ${input.pattern}` }), + ), + ), }), }) .pipe(Effect.orDie) diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts new file mode 100644 index 00000000000..b1bad341027 --- /dev/null +++ b/packages/core/test/tool-search.test.ts @@ -0,0 +1,87 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { GlobTool } from "@opencode-ai/core/tool/glob" +import { GrepTool } from "@opencode-ai/core/tool/grep" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" +import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" + +const globToolNode = makeLocationNode({ + name: "test/glob-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)), + deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], +}) +const grepToolNode = makeLocationNode({ + name: "test/grep-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)), + deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], +}) +const permission = Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: () => Effect.void, + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), +) +const sessionID = SessionV2.ID.make("ses_search_tool_test") + +const withTools = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => + Effect.gen(function* () { + return yield* body(yield* ToolRegistry.Service) + }).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, globToolNode, grepToolNode]), [ + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), + ), + ) + +const call = (name: "glob" | "grep", input: unknown) => ({ + sessionID, + ...toolIdentity, + call: { type: "tool-call" as const, id: `call-${name}`, name, input }, +}) + +const it = testEffect(Layer.empty) + +describe("search tools", () => { + for (const name of ["glob", "grep"] as const) { + it.live(`${name} reports a missing search path`, () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + withTools(tmp.path, (registry) => + Effect.gen(function* () { + const result = yield* executeTool( + registry, + call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }), + ) + expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" }) + }), + ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + } +})