Jelajahi Sumber

effectify Command service (#18568)

Kit Langton 3 minggu lalu
induk
melakukan
0e0e7a4a4b

+ 1 - 1
packages/opencode/specs/effect-migration.md

@@ -132,7 +132,7 @@ Still open and likely worth migrating:
 - [ ] `Pty`
 - [ ] `Worktree`
 - [ ] `Bus`
-- [ ] `Command`
+- [x] `Command`
 - [ ] `Config`
 - [ ] `Session`
 - [ ] `SessionProcessor`

+ 121 - 87
packages/opencode/src/command/index.ts

@@ -1,15 +1,23 @@
 import { BusEvent } from "@/bus/bus-event"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
 import { SessionID, MessageID } from "@/session/schema"
+import { Effect, Layer, ServiceMap } from "effect"
 import z from "zod"
 import { Config } from "../config/config"
-import { Instance } from "../project/instance"
-import { Identifier } from "../id/id"
-import PROMPT_INITIALIZE from "./template/initialize.txt"
-import PROMPT_REVIEW from "./template/review.txt"
 import { MCP } from "../mcp"
 import { Skill } from "../skill"
+import { Log } from "../util/log"
+import PROMPT_INITIALIZE from "./template/initialize.txt"
+import PROMPT_REVIEW from "./template/review.txt"
 
 export namespace Command {
+  const log = Log.create({ service: "command" })
+
+  type State = {
+    commands: Record<string, Info>
+  }
+
   export const Event = {
     Executed: BusEvent.define(
       "command.executed",
@@ -42,7 +50,7 @@ export namespace Command {
   // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
   export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
 
-  export function hints(template: string): string[] {
+  export function hints(template: string) {
     const result: string[] = []
     const numbered = template.match(/\$\d+/g)
     if (numbered) {
@@ -57,95 +65,121 @@ export namespace Command {
     REVIEW: "review",
   } as const
 
-  const state = Instance.state(async () => {
-    const cfg = await Config.get()
-
-    const result: Record<string, Info> = {
-      [Default.INIT]: {
-        name: Default.INIT,
-        description: "create/update AGENTS.md",
-        source: "command",
-        get template() {
-          return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
-        },
-        hints: hints(PROMPT_INITIALIZE),
-      },
-      [Default.REVIEW]: {
-        name: Default.REVIEW,
-        description: "review changes [commit|branch|pr], defaults to uncommitted",
-        source: "command",
-        get template() {
-          return PROMPT_REVIEW.replace("${path}", Instance.worktree)
-        },
-        subtask: true,
-        hints: hints(PROMPT_REVIEW),
-      },
-    }
+  export interface Interface {
+    readonly get: (name: string) => Effect.Effect<Info | undefined>
+    readonly list: () => Effect.Effect<Info[]>
+  }
 
-    for (const [name, command] of Object.entries(cfg.command ?? {})) {
-      result[name] = {
-        name,
-        agent: command.agent,
-        model: command.model,
-        description: command.description,
-        source: "command",
-        get template() {
-          return command.template
-        },
-        subtask: command.subtask,
-        hints: hints(command.template),
-      }
-    }
-    for (const [name, prompt] of Object.entries(await MCP.prompts())) {
-      result[name] = {
-        name,
-        source: "mcp",
-        description: prompt.description,
-        get template() {
-          // since a getter can't be async we need to manually return a promise here
-          return new Promise<string>(async (resolve, reject) => {
-            const template = await MCP.getPrompt(
-              prompt.client,
-              prompt.name,
-              prompt.arguments
-                ? // substitute each argument with $1, $2, etc.
-                  Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
-                : {},
-            ).catch(reject)
-            resolve(
-              template?.messages
-                .map((message) => (message.content.type === "text" ? message.content.text : ""))
-                .join("\n") || "",
-            )
-          })
-        },
-        hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
-      }
-    }
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
 
-    // Add skills as invokable commands
-    for (const skill of await Skill.all()) {
-      // Skip if a command with this name already exists
-      if (result[skill.name]) continue
-      result[skill.name] = {
-        name: skill.name,
-        description: skill.description,
-        source: "skill",
-        get template() {
-          return skill.content
-        },
-        hints: [],
-      }
-    }
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const init = Effect.fn("Command.state")(function* (ctx) {
+        const cfg = yield* Effect.promise(() => Config.get())
+        const commands: Record<string, Info> = {}
 
-    return result
-  })
+        commands[Default.INIT] = {
+          name: Default.INIT,
+          description: "create/update AGENTS.md",
+          source: "command",
+          get template() {
+            return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
+          },
+          hints: hints(PROMPT_INITIALIZE),
+        }
+        commands[Default.REVIEW] = {
+          name: Default.REVIEW,
+          description: "review changes [commit|branch|pr], defaults to uncommitted",
+          source: "command",
+          get template() {
+            return PROMPT_REVIEW.replace("${path}", ctx.worktree)
+          },
+          subtask: true,
+          hints: hints(PROMPT_REVIEW),
+        }
+
+        for (const [name, command] of Object.entries(cfg.command ?? {})) {
+          commands[name] = {
+            name,
+            agent: command.agent,
+            model: command.model,
+            description: command.description,
+            source: "command",
+            get template() {
+              return command.template
+            },
+            subtask: command.subtask,
+            hints: hints(command.template),
+          }
+        }
+
+        for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
+          commands[name] = {
+            name,
+            source: "mcp",
+            description: prompt.description,
+            get template() {
+              return new Promise<string>(async (resolve, reject) => {
+                const template = await MCP.getPrompt(
+                  prompt.client,
+                  prompt.name,
+                  prompt.arguments
+                    ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
+                    : {},
+                ).catch(reject)
+                resolve(
+                  template?.messages
+                    .map((message) => (message.content.type === "text" ? message.content.text : ""))
+                    .join("\n") || "",
+                )
+              })
+            },
+            hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
+          }
+        }
+
+        for (const skill of yield* Effect.promise(() => Skill.all())) {
+          if (commands[skill.name]) continue
+          commands[skill.name] = {
+            name: skill.name,
+            description: skill.description,
+            source: "skill",
+            get template() {
+              return skill.content
+            },
+            hints: [],
+          }
+        }
+
+        return {
+          commands,
+        }
+      })
+
+      const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
+
+      const get = Effect.fn("Command.get")(function* (name: string) {
+        const state = yield* InstanceState.get(cache)
+        return state.commands[name]
+      })
+
+      const list = Effect.fn("Command.list")(function* () {
+        const state = yield* InstanceState.get(cache)
+        return Object.values(state.commands)
+      })
+
+      return Service.of({ get, list })
+    }),
+  )
+
+  const runPromise = makeRunPromise(Service, layer)
 
   export async function get(name: string) {
-    return state().then((x) => x[name])
+    return runPromise((svc) => svc.get(name))
   }
 
   export async function list() {
-    return state().then((x) => Object.values(x))
+    return runPromise((svc) => svc.list())
   }
 }

+ 3 - 0
packages/opencode/src/session/prompt.ts

@@ -1782,6 +1782,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
   export async function command(input: CommandInput) {
     log.info("command", input)
     const command = await Command.get(input.command)
+    if (!command) {
+      throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` })
+    }
     const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
 
     const raw = input.arguments.match(argsRegex) ?? []

+ 7 - 13
packages/opencode/test/file/watcher.test.ts

@@ -5,9 +5,9 @@ import path from "path"
 import { Deferred, Effect, Option } from "effect"
 import { tmpdir } from "../fixture/fixture"
 import { watcherConfigLayer, withServices } from "../fixture/instance"
+import { Bus } from "../../src/bus"
 import { FileWatcher } from "../../src/file/watcher"
 import { Instance } from "../../src/project/instance"
-import { GlobalBus } from "../../src/bus/global"
 
 // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
 const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
 // Helpers
 // ---------------------------------------------------------------------------
 
-type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
 type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
 
 /** Run `body` with a live FileWatcher service. */
@@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
 function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
   let done = false
 
-  function on(evt: BusUpdate) {
+  const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
     if (done) return
-    if (evt.directory !== directory) return
-    if (evt.payload.type !== FileWatcher.Event.Updated.type) return
-    if (!check(evt.payload.properties)) return
-    hit(evt.payload.properties)
-  }
+    if (!check(evt.properties)) return
+    hit(evt.properties)
+  })
 
-  function cleanup() {
+  return () => {
     if (done) return
     done = true
-    GlobalBus.off("event", on)
+    unsub()
   }
-
-  GlobalBus.on("event", on)
-  return cleanup
 }
 
 function wait(directory: string, check: (evt: WatcherEvent) => boolean) {