Parcourir la source

fix(core): report missing search paths (#35337)

Kit Langton il y a 1 mois
Parent
commit
8f4b62eb49

+ 14 - 1
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}` }),
+              ),
             ),
         }),
       })

+ 14 - 2
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)

+ 87 - 0
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 = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
+  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]()),
+      ),
+    )
+  }
+})