From 5e4550304c849685a3d2b72e6c3fafad0155385c Mon Sep 17 00:00:00 2001 From: GoldJohnKing Date: Sat, 29 Aug 2026 22:35:26 +0800 Subject: [PATCH 1/2] fix(core): report missing glob and grep search paths Restore the path-specific missing-path failure that was lost in the V2 tool rewrite (#35337): when the resolved search path does not exist, glob and grep now fail with "Search path does not exist: " instead of either silently searching the parent directory or surfacing a generic ripgrep failure. ToolFailure errors now pass through the generic mapError unchanged. --- packages/core/src/tool/glob.ts | 13 +- packages/core/src/tool/grep.ts | 10 +- packages/core/test/tool-search.test.ts | 187 +++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/tool-search.test.ts diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index f8bd1869e11e..7a21d81ffb55 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -5,6 +5,7 @@ import { Effect, Layer, Schema } from "effect" import path from "path" import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" @@ -38,6 +39,7 @@ export const toModelOutput = (output: ModelOutput) => { const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service + const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service const permission = yield* PermissionV2.Service @@ -73,6 +75,9 @@ const layer = Layer.effectDiscard( source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) const cwd = path.resolve(location.directory, input.path ?? ".") + const info = yield* fs.stat(cwd).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (info === undefined) + return yield* new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` }) return yield* ripgrep .glob({ cwd, @@ -90,7 +95,11 @@ const layer = Layer.effectDiscard( ), ) }).pipe( - Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })), + Effect.mapError((cause) => + cause instanceof ToolFailure + ? cause + : new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }), + ), ), }), }) @@ -101,5 +110,5 @@ const layer = Layer.effectDiscard( export const node = makeLocationNode({ name: "tool/glob", layer, - deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node], + deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], }) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index f455bd4c8a0a..cc8579763768 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -94,6 +94,8 @@ const layer = Layer.effectDiscard( }) const target = path.resolve(location.directory, input.path ?? ".") const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (info === undefined) + return yield* new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` }) return yield* ripgrep .grep({ cwd: info?.type === "Directory" ? target : path.dirname(target), @@ -123,7 +125,13 @@ const layer = Layer.effectDiscard( ), ), ) - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))), + }).pipe( + Effect.mapError((cause) => + cause instanceof ToolFailure + ? cause + : 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 000000000000..e3844c351733 --- /dev/null +++ b/packages/core/test/tool-search.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Location } from "@opencode-ai/core/location" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { SessionV2 } from "@opencode-ai/core/session" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { GrepTool } from "@opencode-ai/core/tool/grep" +import { GlobTool } from "@opencode-ai/core/tool/glob" +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 { toolIdentity, executeTool, settleTool } from "./lib/tool" + +const sessionID = SessionV2.ID.make("ses_search_tool_test") +const assertions: PermissionV2.AssertInput[] = [] +let allow = true +const permission = Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: (input) => + Effect.sync(() => { + assertions.push(input) + }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))), + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), +) +let locationDirectory: string | undefined +const locationLayer = Layer.unwrap( + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.map((tmp) => { + locationDirectory = tmp.path + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + return Layer.succeed(Location.Service, Location.Service.of(location(ref))) + }), + ), +) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, GlobTool.node, GrepTool.node]), [ + [PermissionV2.node, permission], + [Location.node, locationLayer], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), +) + +const fixture = Effect.promise(async () => { + if (locationDirectory === undefined) throw new Error("location layer was not built before the test body") + await fs.mkdir(path.join(locationDirectory, "src"), { recursive: true }) + await fs.writeFile(path.join(locationDirectory, "src", "haystack.ts"), "needle") +}) + +describe("GlobTool", () => { + beforeEach(() => { + assertions.length = 0 + allow = true + }) + + it.effect("fails with a path-specific error when the search path does not exist", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + + expect( + yield* settleTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-glob-missing", + name: "glob", + input: { pattern: "**/*", path: "missing-dir" }, + }, + }), + ).toEqual({ + result: { type: "error", value: "Search path does not exist: missing-dir" }, + }) + }), + ) + + it.live("finds files under an existing search path", () => + Effect.gen(function* () { + yield* fixture + const registry = yield* ToolRegistry.Service + + const result = yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-glob-src", + name: "glob", + input: { pattern: "**/*.ts", path: "src" }, + }, + }) + + expect(result).toEqual({ + type: "text", + value: `${locationDirectory}/src/haystack.ts`, + }) + expect(assertions).toMatchObject([{ action: "glob", resources: ["**/*.ts"], save: ["*"] }]) + }), + ) +}) + +describe("GrepTool", () => { + beforeEach(() => { + assertions.length = 0 + allow = true + }) + + it.effect("fails with a path-specific error when the search path does not exist", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + + expect( + yield* settleTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-grep-missing", + name: "grep", + input: { pattern: "needle", path: "missing-dir" }, + }, + }), + ).toEqual({ + result: { type: "error", value: "Search path does not exist: missing-dir" }, + }) + }), + ) + + it.effect("fails with a path-specific error when the path points at a missing file", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + + expect( + yield* settleTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-grep-missing-file", + name: "grep", + input: { pattern: "needle", path: "src/missing.ts" }, + }, + }), + ).toEqual({ + result: { type: "error", value: "Search path does not exist: src/missing.ts" }, + }) + }), + ) + + it.live("searches file contents under an existing search path", () => + Effect.gen(function* () { + yield* fixture + const registry = yield* ToolRegistry.Service + + const result = yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-grep-src", + name: "grep", + input: { pattern: "needle", path: "src" }, + }, + }) + + expect(result).toEqual({ + type: "text", + value: `Found 1 matches\n${locationDirectory}/src/haystack.ts:\n Line 1: needle`, + }) + expect(assertions).toMatchObject([{ action: "grep", resources: ["needle"], save: ["*"] }]) + }), + ) +}) From 08b7771b4c323325c6ba25f56a21b3ccef02b301 Mon Sep 17 00:00:00 2001 From: GoldJohnKing Date: Sat, 29 Aug 2026 22:46:27 +0800 Subject: [PATCH 2/2] refactor(core): narrow missing-path detection to NotFound platform errors Align with the original #35337 implementation and existing codebase conventions (file-mutation.ts, fs-util.ts): only a NotFound stat failure reports "Search path does not exist"; other platform errors such as EACCES still propagate to the generic failure wrapper instead of being misreported as a missing path. --- packages/core/src/tool/glob.ts | 10 +++++++--- packages/core/src/tool/grep.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 7a21d81ffb55..4af02b206f40 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -75,9 +75,13 @@ const layer = Layer.effectDiscard( source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) const cwd = path.resolve(location.directory, input.path ?? ".") - const info = yield* fs.stat(cwd).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (info === undefined) - return yield* new ToolFailure({ message: `Search path does not exist: ${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, diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index cc8579763768..c00f8acf2a69 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -93,9 +93,13 @@ const layer = Layer.effectDiscard( 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))) - if (info === undefined) - return yield* new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` }) + 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),