Procházet zdrojové kódy

feat(acp-next): add directory snapshot service (#29241)

Shoubhit Dash před 3 měsíci
rodič
revize
b2d76434ba

+ 193 - 0
packages/opencode/src/acp-next/directory.ts

@@ -0,0 +1,193 @@
+import { Agent } from "@/agent/agent"
+import { Command } from "@/command"
+import { InstanceRef } from "@/effect/instance-ref"
+import { InstanceStore } from "@/project/instance-store"
+import { ModelID, ProviderID } from "@/provider/schema"
+import { Provider } from "@/provider/provider"
+import { Context, Effect, Layer, SynchronizedRef } from "effect"
+
+export type ModelOption = {
+  readonly providerID: ProviderID
+  readonly providerName: string
+  readonly modelID: ModelID
+  readonly modelName: string
+}
+
+export type ModeOption = {
+  readonly id: string
+  readonly name: string
+  readonly description?: string
+}
+
+export type ModelVariants = NonNullable<Provider.Model["variants"]>
+
+export type DefaultModel = {
+  readonly providerID: ProviderID
+  readonly modelID: ModelID
+}
+
+export type Snapshot = {
+  readonly directory: string
+  readonly providers: Record<ProviderID, Provider.Info>
+  readonly modelOptions: readonly ModelOption[]
+  readonly variantsByModel: Readonly<Record<string, ModelVariants>>
+  readonly availableModes: readonly ModeOption[]
+  readonly defaultModeID: string
+  readonly availableCommands: readonly Command.Info[]
+  readonly defaultModel?: DefaultModel
+}
+
+export interface LoaderInterface {
+  readonly load: (directory: string) => Effect.Effect<Snapshot>
+}
+
+export interface Interface {
+  readonly get: (directory: string) => Effect.Effect<Snapshot>
+  readonly refresh: (directory: string) => Effect.Effect<Snapshot>
+  readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined
+}
+
+export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPNextDirectoryLoader") {}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextDirectory") {}
+
+export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}`
+
+export const variants = (snapshot: Snapshot, model: DefaultModel) => snapshot.variantsByModel[modelKey(model)]
+
+export const build = (input: {
+  readonly directory: string
+  readonly providers: Record<ProviderID, Provider.Info>
+  readonly modes: readonly ModeOption[]
+  readonly defaultModeID: string
+  readonly commands: readonly Command.Info[]
+  readonly defaultModel?: DefaultModel
+}): Snapshot => {
+  const modelOptions = Provider.sort(
+    Object.values(input.providers).flatMap((provider) =>
+      Object.values(provider.models).map((model) => ({
+        id: model.id,
+        providerID: provider.id,
+        providerName: provider.name,
+        modelID: model.id,
+        modelName: model.name,
+      })),
+    ),
+  ).map((model) => ({
+    providerID: model.providerID,
+    providerName: model.providerName,
+    modelID: model.modelID,
+    modelName: model.modelName,
+  }))
+
+  return {
+    directory: input.directory,
+    providers: input.providers,
+    modelOptions,
+    variantsByModel: Object.fromEntries(
+      Object.values(input.providers).flatMap((provider) =>
+        Object.values(provider.models).flatMap((model) =>
+          model.variants ? [[modelKey({ providerID: provider.id, modelID: model.id }), model.variants]] : [],
+        ),
+      ),
+    ),
+    availableModes: input.modes,
+    defaultModeID: input.modes.some((mode) => mode.id === input.defaultModeID)
+      ? input.defaultModeID
+      : (input.modes[0]?.id ?? input.defaultModeID),
+    availableCommands: input.commands,
+    ...(input.defaultModel ? { defaultModel: input.defaultModel } : {}),
+  }
+}
+
+export const loaderLayer = Layer.effect(
+  Loader,
+  Effect.gen(function* () {
+    const store = yield* InstanceStore.Service
+    const provider = yield* Provider.Service
+    const agent = yield* Agent.Service
+    const command = yield* Command.Service
+
+    return Loader.of({
+      load: Effect.fn("ACPNextDirectoryLoader.load")(function* (directory) {
+        const ctx = yield* store.load({ directory })
+        return yield* Effect.gen(function* () {
+          const providers = yield* provider.list()
+          const [agents, defaultAgent, commands, defaultModel] = yield* Effect.all(
+            [
+              agent.list(),
+              agent.defaultInfo(),
+              command.list(),
+              provider.defaultModel().pipe(Effect.option),
+            ],
+            { concurrency: "unbounded" },
+          )
+          return build({
+            directory,
+            providers,
+            modes: agents
+              .filter((item) => item.mode !== "subagent" && item.hidden !== true)
+              .map((item) => ({
+                id: item.name,
+                name: item.name,
+                ...(item.description ? { description: item.description } : {}),
+              })),
+            defaultModeID: defaultAgent.name,
+            commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
+            ...(defaultModel._tag === "Some" ? { defaultModel: defaultModel.value } : {}),
+          })
+        }).pipe(Effect.provideService(InstanceRef, ctx))
+      }),
+    })
+  }),
+)
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const loader = yield* Loader
+    const snapshots = yield* SynchronizedRef.make(new Map<string, Effect.Effect<Snapshot>>())
+
+    const cached = Effect.fnUntraced(function* (directory: string) {
+      return yield* SynchronizedRef.modifyEffect(
+        snapshots,
+        Effect.fnUntraced(function* (items) {
+          const current = items.get(directory)
+          if (current) return [current, items] as const
+          const next = yield* Effect.cached(loader.load(directory))
+          return [next, new Map(items).set(directory, next)] as const
+        }),
+      )
+    })
+
+    const get = Effect.fn("ACPNextDirectory.get")(function* (directory: string) {
+      return yield* (yield* cached(directory))
+    })
+
+    const refresh = Effect.fn("ACPNextDirectory.refresh")(function* (directory: string) {
+      return yield* SynchronizedRef.modifyEffect(
+        snapshots,
+        Effect.fnUntraced(function* (items) {
+          const next = yield* Effect.cached(loader.load(directory))
+          return [next, new Map(items).set(directory, next)] as const
+        }),
+      ).pipe(Effect.flatten)
+    })
+
+    return Service.of({
+      get,
+      refresh,
+      variants,
+    })
+  }),
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(loaderLayer),
+  Layer.provide(Provider.defaultLayer),
+  Layer.provide(Agent.defaultLayer),
+  Layer.provide(Command.defaultLayer),
+  Layer.provide(InstanceStore.defaultLayer),
+)
+
+export * as Directory from "./directory"

+ 185 - 0
packages/opencode/test/acp-next/directory.test.ts

@@ -0,0 +1,185 @@
+import { describe, expect } from "bun:test"
+import { Directory } from "@/acp-next/directory"
+import { Command } from "@/command"
+import { ModelID, ProviderID } from "@/provider/schema"
+import { Provider } from "@/provider/provider"
+import { Effect, Layer } from "effect"
+import { it } from "../lib/effect"
+
+const command = (name: string): Command.Info => ({
+  name,
+  source: "command",
+  template: `run ${name}`,
+  hints: [],
+})
+
+const model = (providerID: ProviderID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
+  id: ModelID.make(id),
+  providerID,
+  api: {
+    id,
+    url: "https://example.com",
+    npm: "@ai-sdk/openai-compatible",
+  },
+  name: id,
+  family: "test",
+  capabilities: {
+    temperature: true,
+    reasoning: Boolean(variants),
+    attachment: false,
+    toolcall: true,
+    input: { text: true, audio: false, image: false, video: false, pdf: false },
+    output: { text: true, audio: false, image: false, video: false, pdf: false },
+    interleaved: false,
+  },
+  cost: {
+    input: 0,
+    output: 0,
+    cache: { read: 0, write: 0 },
+  },
+  limit: {
+    context: 128000,
+    output: 4096,
+  },
+  status: "active",
+  options: {},
+  headers: {},
+  release_date: "2026-01-01",
+  ...(variants ? { variants } : {}),
+})
+
+const snapshot = (directory: string) => {
+  const providerID = ProviderID.make(`provider-${directory}`)
+  const modelID = ModelID.make(`model-${directory}`)
+  const providers = {
+    [providerID]: {
+      id: providerID,
+      name: `Provider ${directory}`,
+      source: "config",
+      env: [],
+      options: {},
+      models: {
+        [modelID]: model(providerID, modelID, {
+          low: { reasoningEffort: "low" },
+          high: { reasoningEffort: "high" },
+        }),
+        [ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
+      },
+    },
+  } satisfies Record<ProviderID, Provider.Info>
+
+  return Directory.build({
+    directory,
+    providers,
+    modes: [
+      { id: "build", name: `build-${directory}` },
+      { id: "plan", name: `plan-${directory}`, description: "plan first" },
+    ],
+    defaultModeID: "build",
+    commands: [command(`init-${directory}`), command(`review-${directory}`)],
+    defaultModel: { providerID, modelID },
+  })
+}
+
+const fakeLayer = (calls: string[]) =>
+  Directory.layer.pipe(
+    Layer.provide(
+      Layer.succeed(
+        Directory.Loader,
+        Directory.Loader.of({
+          load: (directory) =>
+            Effect.sync(() => {
+              calls.push(directory)
+              return snapshot(directory)
+            }),
+        }),
+      ),
+    ),
+  )
+
+describe("ACP next directory snapshot", () => {
+  it.effect("two concurrent callers share one load", () => {
+    const calls: string[] = []
+    return Effect.gen(function* () {
+      const directory = yield* Directory.Service
+      const [first, second] = yield* Effect.all([directory.get("alpha"), directory.get("alpha")], {
+        concurrency: "unbounded",
+      })
+
+      expect(calls).toEqual(["alpha"])
+      expect(first).toBe(second)
+    }).pipe(Effect.provide(fakeLayer(calls)))
+  })
+
+  it.effect("warm calls use cached data", () => {
+    const calls: string[] = []
+    return Effect.gen(function* () {
+      const directory = yield* Directory.Service
+      const first = yield* directory.get("alpha")
+      const second = yield* directory.get("alpha")
+
+      expect(calls).toEqual(["alpha"])
+      expect(first).toBe(second)
+    }).pipe(Effect.provide(fakeLayer(calls)))
+  })
+
+  it.effect("different directories get different snapshots", () => {
+    const calls: string[] = []
+    return Effect.gen(function* () {
+      const directory = yield* Directory.Service
+      const [alpha, beta] = yield* Effect.all([directory.get("alpha"), directory.get("beta")], {
+        concurrency: "unbounded",
+      })
+
+      expect(calls.toSorted()).toEqual(["alpha", "beta"])
+      expect(alpha.directory).toBe("alpha")
+      expect(beta.directory).toBe("beta")
+      expect(alpha.defaultModel?.providerID).not.toBe(beta.defaultModel?.providerID)
+    }).pipe(Effect.provide(fakeLayer(calls)))
+  })
+
+  it.effect("model variant lookup works", () =>
+    Effect.gen(function* () {
+      const directory = yield* Directory.Service
+      const alpha = yield* directory.get("alpha")
+      const model = alpha.defaultModel!
+
+      expect(directory.variants(alpha, model)).toEqual({
+        low: { reasoningEffort: "low" },
+        high: { reasoningEffort: "high" },
+      })
+      expect(directory.variants(alpha, { ...model, modelID: ModelID.make("missing") })).toBeUndefined()
+    }).pipe(Effect.provide(fakeLayer([]))),
+  )
+
+  it.effect("commands and modes are included", () =>
+    Effect.gen(function* () {
+      const directory = yield* Directory.Service
+      const alpha = yield* directory.get("alpha")
+
+      expect(alpha.availableCommands.map((item) => item.name)).toEqual(["init-alpha", "review-alpha"])
+      expect(alpha.availableModes).toEqual([
+        { id: "build", name: "build-alpha" },
+        { id: "plan", name: "plan-alpha", description: "plan first" },
+      ])
+      expect(alpha.defaultModeID).toBe("build")
+    }).pipe(Effect.provide(fakeLayer([]))),
+  )
+
+  it.effect("falls back when the default mode is not available", () =>
+    Effect.sync(() => {
+      expect(
+        Directory.build({
+          directory: "alpha",
+          providers: {},
+          modes: [
+            { id: "build", name: "Build" },
+            { id: "plan", name: "Plan" },
+          ],
+          defaultModeID: "hidden",
+          commands: [],
+        }).defaultModeID,
+      ).toBe("build")
+    }),
+  )
+})