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]()),
+ ),
+ )
+ }
+})