diff --git a/packages/opencode/src/acp-next/directory.ts b/packages/opencode/src/acp-next/directory.ts new file mode 100644 index 0000000000..bf8eb55751 --- /dev/null +++ b/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 + +export type DefaultModel = { + readonly providerID: ProviderID + readonly modelID: ModelID +} + +export type Snapshot = { + readonly directory: string + readonly providers: Record + readonly modelOptions: readonly ModelOption[] + readonly variantsByModel: Readonly> + readonly availableModes: readonly ModeOption[] + readonly defaultModeID: string + readonly availableCommands: readonly Command.Info[] + readonly defaultModel?: DefaultModel +} + +export interface LoaderInterface { + readonly load: (directory: string) => Effect.Effect +} + +export interface Interface { + readonly get: (directory: string) => Effect.Effect + readonly refresh: (directory: string) => Effect.Effect + readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined +} + +export class Loader extends Context.Service()("@opencode/ACPNextDirectoryLoader") {} + +export class Service extends Context.Service()("@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 + 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>()) + + 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" diff --git a/packages/opencode/test/acp-next/directory.test.ts b/packages/opencode/test/acp-next/directory.test.ts new file mode 100644 index 0000000000..050ff06d45 --- /dev/null +++ b/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 + + 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") + }), + ) +})